From a495694eaefaabb86003e1bb90a6acdc63ea1fd3 Mon Sep 17 00:00:00 2001 From: akafredperry Date: Tue, 24 Feb 2026 18:49:51 +0000 Subject: [PATCH 1/5] feature: code and test complete --- at_client/pom.xml | 16 + .../org/atsign/client/api/AtKeyNames.java | 5 + .../java/org/atsign/client/api/Secondary.java | 41 +- .../client/api/impl/clients/AtClientImpl.java | 2 +- .../client/api/impl/clients/AtClients.java | 62 ++ .../api/impl/clients/DefaultAtClientImpl.java | 345 ++++++++ .../api/impl/secondaries/RemoteSecondary.java | 2 +- .../org/atsign/client/cli/AbstractCli.java | 209 +---- .../java/org/atsign/client/cli/Activate.java | 308 ++----- .../connection/api/AtClientConnection.java | 84 ++ .../connection/api/AtEndpointSupplier.java | 17 + .../connection/api/ReconnectStrategy.java | 50 ++ .../client/connection/common/Command.java | 95 +++ .../connection/common/CommandQueue.java | 152 ++++ .../common/SimpleReconnectStrategy.java | 66 ++ .../netty/NettyAtClientConnection.java | 570 +++++++++++++ .../netty/NettyAtCommandEncoder.java | 23 + .../netty/NettyAtEndpointSupplier.java | 60 ++ .../netty/NettyAtResponseDecoder.java | 84 ++ .../netty/NettyAtThreadFactory.java | 49 ++ .../connection/protocol/AtExceptions.java | 90 ++ .../connection/protocol/Authentication.java | 104 +++ .../client/connection/protocol/Data.java | 117 +++ .../client/connection/protocol/Enroll.java | 322 ++++++++ .../client/connection/protocol/Error.java | 50 ++ .../client/connection/protocol/Keys.java | 82 ++ .../connection/protocol/Notifications.java | 104 +++ .../connection/protocol/PublicKeys.java | 109 +++ .../client/connection/protocol/Responses.java | 70 ++ .../client/connection/protocol/Scan.java | 36 + .../client/connection/protocol/SelfKeys.java | 77 ++ .../connection/protocol/SharedKeys.java | 232 ++++++ .../connection/protocol/package-info.java | 8 + .../java/org/atsign/client/util/AuthUtil.java | 1 + .../atsign/client/util/EncryptionUtil.java | 39 +- .../main/java/org/atsign/common/Metadata.java | 3 +- .../AtInvalidSyntaxException.java | 2 +- .../AtServerRuntimeException.java | 2 +- .../common/exceptions/AtOnReadyException.java | 19 + .../common/options/GetRequestOptions.java | 30 +- .../atsign/common/options/RequestOptions.java | 8 - .../org/atsign/client/cli/ActivateIT.java | 1 - .../connection/common/CommandQueueTest.java | 365 +++++++++ .../client/connection/common/CommandTest.java | 144 ++++ .../common/SimpleReconnectStrategyTest.java | 157 ++++ .../netty/NettyAtClientConnectionIT.java | 72 ++ .../netty/NettyAtClientConnectionTest.java | 769 ++++++++++++++++++ .../netty/NettyAtCommandEncoderTest.java | 49 ++ .../netty/NettyAtEndpointSupplierTest.java | 107 +++ .../NettyAtServerResponseDecoderTest.java | 140 ++++ .../client/connection/netty/TestServer.java | 262 ++++++ .../connection/protocol/AtExceptionsTest.java | 185 +++++ .../protocol/AuthenticationTest.java | 94 +++ .../client/connection/protocol/DataTest.java | 99 +++ .../connection/protocol/EnrollTest.java | 250 ++++++ .../client/connection/protocol/ErrorTest.java | 39 + .../client/connection/protocol/KeysTest.java | 94 +++ .../protocol/NotificationsTest.java | 184 +++++ .../connection/protocol/PublicKeysTest.java | 174 ++++ .../connection/protocol/ResponsesTest.java | 96 +++ .../client/connection/protocol/ScanTest.java | 54 ++ .../connection/protocol/SelfKeysTest.java | 105 +++ .../connection/protocol/SharedKeysTest.java | 157 ++++ .../protocol/TestConnectionBuilder.java | 76 ++ .../atsign/cucumber/steps/ActivateSteps.java | 60 +- .../cucumber/steps/AtClientContext.java | 25 +- .../test/resources/features/Activate.feature | 4 +- .../test/resources/features/Monitor.feature | 2 + .../test/resources/simpleLogger.properties | 4 +- .../main/java/org/atsign/client/cli/REPL.java | 2 +- .../PublicKeyGetBypassCacheExample.java | 2 +- pom.xml | 33 + 72 files changed, 6987 insertions(+), 563 deletions(-) create mode 100644 at_client/src/main/java/org/atsign/client/api/impl/clients/AtClients.java create mode 100644 at_client/src/main/java/org/atsign/client/api/impl/clients/DefaultAtClientImpl.java create mode 100644 at_client/src/main/java/org/atsign/client/connection/api/AtClientConnection.java create mode 100644 at_client/src/main/java/org/atsign/client/connection/api/AtEndpointSupplier.java create mode 100644 at_client/src/main/java/org/atsign/client/connection/api/ReconnectStrategy.java create mode 100644 at_client/src/main/java/org/atsign/client/connection/common/Command.java create mode 100644 at_client/src/main/java/org/atsign/client/connection/common/CommandQueue.java create mode 100644 at_client/src/main/java/org/atsign/client/connection/common/SimpleReconnectStrategy.java create mode 100644 at_client/src/main/java/org/atsign/client/connection/netty/NettyAtClientConnection.java create mode 100644 at_client/src/main/java/org/atsign/client/connection/netty/NettyAtCommandEncoder.java create mode 100644 at_client/src/main/java/org/atsign/client/connection/netty/NettyAtEndpointSupplier.java create mode 100644 at_client/src/main/java/org/atsign/client/connection/netty/NettyAtResponseDecoder.java create mode 100644 at_client/src/main/java/org/atsign/client/connection/netty/NettyAtThreadFactory.java create mode 100644 at_client/src/main/java/org/atsign/client/connection/protocol/AtExceptions.java create mode 100644 at_client/src/main/java/org/atsign/client/connection/protocol/Authentication.java create mode 100644 at_client/src/main/java/org/atsign/client/connection/protocol/Data.java create mode 100644 at_client/src/main/java/org/atsign/client/connection/protocol/Enroll.java create mode 100644 at_client/src/main/java/org/atsign/client/connection/protocol/Error.java create mode 100644 at_client/src/main/java/org/atsign/client/connection/protocol/Keys.java create mode 100644 at_client/src/main/java/org/atsign/client/connection/protocol/Notifications.java create mode 100644 at_client/src/main/java/org/atsign/client/connection/protocol/PublicKeys.java create mode 100644 at_client/src/main/java/org/atsign/client/connection/protocol/Responses.java create mode 100644 at_client/src/main/java/org/atsign/client/connection/protocol/Scan.java create mode 100644 at_client/src/main/java/org/atsign/client/connection/protocol/SelfKeys.java create mode 100644 at_client/src/main/java/org/atsign/client/connection/protocol/SharedKeys.java create mode 100644 at_client/src/main/java/org/atsign/client/connection/protocol/package-info.java rename at_client/src/main/java/org/atsign/common/exceptions/{ => AtExceptions}/AtInvalidSyntaxException.java (85%) rename at_client/src/main/java/org/atsign/common/exceptions/{ => AtExceptions}/AtServerRuntimeException.java (85%) create mode 100644 at_client/src/main/java/org/atsign/common/exceptions/AtOnReadyException.java delete mode 100644 at_client/src/main/java/org/atsign/common/options/RequestOptions.java create mode 100644 at_client/src/test/java/org/atsign/client/connection/common/CommandQueueTest.java create mode 100644 at_client/src/test/java/org/atsign/client/connection/common/CommandTest.java create mode 100644 at_client/src/test/java/org/atsign/client/connection/common/SimpleReconnectStrategyTest.java create mode 100644 at_client/src/test/java/org/atsign/client/connection/netty/NettyAtClientConnectionIT.java create mode 100644 at_client/src/test/java/org/atsign/client/connection/netty/NettyAtClientConnectionTest.java create mode 100644 at_client/src/test/java/org/atsign/client/connection/netty/NettyAtCommandEncoderTest.java create mode 100644 at_client/src/test/java/org/atsign/client/connection/netty/NettyAtEndpointSupplierTest.java create mode 100644 at_client/src/test/java/org/atsign/client/connection/netty/NettyAtServerResponseDecoderTest.java create mode 100644 at_client/src/test/java/org/atsign/client/connection/netty/TestServer.java create mode 100644 at_client/src/test/java/org/atsign/client/connection/protocol/AtExceptionsTest.java create mode 100644 at_client/src/test/java/org/atsign/client/connection/protocol/AuthenticationTest.java create mode 100644 at_client/src/test/java/org/atsign/client/connection/protocol/DataTest.java create mode 100644 at_client/src/test/java/org/atsign/client/connection/protocol/EnrollTest.java create mode 100644 at_client/src/test/java/org/atsign/client/connection/protocol/ErrorTest.java create mode 100644 at_client/src/test/java/org/atsign/client/connection/protocol/KeysTest.java create mode 100644 at_client/src/test/java/org/atsign/client/connection/protocol/NotificationsTest.java create mode 100644 at_client/src/test/java/org/atsign/client/connection/protocol/PublicKeysTest.java create mode 100644 at_client/src/test/java/org/atsign/client/connection/protocol/ResponsesTest.java create mode 100644 at_client/src/test/java/org/atsign/client/connection/protocol/ScanTest.java create mode 100644 at_client/src/test/java/org/atsign/client/connection/protocol/SelfKeysTest.java create mode 100644 at_client/src/test/java/org/atsign/client/connection/protocol/SharedKeysTest.java create mode 100644 at_client/src/test/java/org/atsign/client/connection/protocol/TestConnectionBuilder.java diff --git a/at_client/pom.xml b/at_client/pom.xml index 1f3daa51..c33e0527 100644 --- a/at_client/pom.xml +++ b/at_client/pom.xml @@ -85,6 +85,11 @@ maven-checkstyle-plugin + + org.jacoco + jacoco-maven-plugin + + org.codehaus.mojo exec-maven-plugin @@ -120,6 +125,11 @@ bcprov-jdk15to18 + + io.netty + netty-all + + info.picocli picocli @@ -136,6 +146,12 @@ provided + + org.bouncycastle + bcpkix-jdk18on + test + + org.junit.jupiter junit-jupiter diff --git a/at_client/src/main/java/org/atsign/client/api/AtKeyNames.java b/at_client/src/main/java/org/atsign/client/api/AtKeyNames.java index eee0d3ab..b93fe8fc 100644 --- a/at_client/src/main/java/org/atsign/client/api/AtKeyNames.java +++ b/at_client/src/main/java/org/atsign/client/api/AtKeyNames.java @@ -54,4 +54,9 @@ public static String toSharedWithMeKeyName(AtSign sharedBy, AtSign sharedWith) { return String.format("%s:%s%s", SHARED_KEY, sharedWith, sharedBy); } + + public static boolean isManagementKeyName(String s) { + return s.matches(".+\\.__manage@.+"); + } + } diff --git a/at_client/src/main/java/org/atsign/client/api/Secondary.java b/at_client/src/main/java/org/atsign/client/api/Secondary.java index 5fccbad0..b6a0619b 100644 --- a/at_client/src/main/java/org/atsign/client/api/Secondary.java +++ b/at_client/src/main/java/org/atsign/client/api/Secondary.java @@ -3,6 +3,7 @@ import java.io.Closeable; import java.io.IOException; +import org.atsign.client.connection.protocol.AtExceptions; import org.atsign.common.AtException; import org.atsign.common.AtSign; import org.atsign.common.exceptions.*; @@ -104,45 +105,7 @@ public AtException getException() { if (!isError()) { return null; } - if (AtServerRuntimeException.CODE.equals(errorCode)) { - return new AtServerRuntimeException(errorText); - } else if (AtInvalidSyntaxException.CODE.equals(errorCode)) { - return new AtInvalidSyntaxException(errorText); - } else if (AtBufferOverFlowException.CODE.equals(errorCode)) { - return new AtBufferOverFlowException(errorText); - } else if (AtOutboundConnectionLimitException.CODE.equals(errorCode)) { - return new AtOutboundConnectionLimitException(errorText); - } else if (AtSecondaryNotFoundException.CODE.equals(errorCode)) { - return new AtSecondaryNotFoundException(errorText); - } else if (AtHandShakeException.CODE.equals(errorCode)) { - return new AtHandShakeException(errorText); - } else if (AtUnauthorizedException.CODE.equals(errorCode)) { - return new AtUnauthorizedException(errorText); - } else if (AtInternalServerError.CODE.equals(errorCode)) { - return new AtInternalServerError(errorText); - } else if (AtInternalServerException.CODE.equals(errorCode)) { - return new AtInternalServerException(errorText); - } else if (AtInboundConnectionLimitException.CODE.equals(errorCode)) { - return new AtInboundConnectionLimitException(errorText); - } else if (AtBlockedConnectionException.CODE.equals(errorCode)) { - return new AtBlockedConnectionException(errorText); - } else if (AtKeyNotFoundException.CODE.equals(errorCode)) { - return new AtKeyNotFoundException(errorText); - } else if (AtInvalidAtKeyException.CODE.equals(errorCode)) { - return new AtInvalidAtKeyException(errorText); - } else if (AtSecondaryConnectException.CODE.equals(errorCode)) { - return new AtSecondaryConnectException(errorText); - } else if (AtIllegalArgumentException.CODE.equals(errorCode)) { - return new AtIllegalArgumentException(errorText); - } else if (AtTimeoutException.CODE.equals(errorCode)) { - return new AtTimeoutException(errorText); - } else if (AtServerIsPausedException.CODE.equals(errorCode)) { - return new AtServerIsPausedException(errorText); - } else if (AtUnauthenticatedException.CODE.equals(errorCode)) { - return new AtUnauthenticatedException(errorText); - } - - return new AtNewErrorCodeWhoDisException(errorCode, errorText); + return AtExceptions.toTypedException(errorCode, errorText); } } diff --git a/at_client/src/main/java/org/atsign/client/api/impl/clients/AtClientImpl.java b/at_client/src/main/java/org/atsign/client/api/impl/clients/AtClientImpl.java index 0a97523f..9f320e31 100644 --- a/at_client/src/main/java/org/atsign/client/api/impl/clients/AtClientImpl.java +++ b/at_client/src/main/java/org/atsign/client/api/impl/clients/AtClientImpl.java @@ -521,7 +521,7 @@ private String _get(PublicKey key, GetRequestOptions getRequestOptions) throws A if (atSign.equals(key.sharedBy())) { command = llookupCommandBuilder().key(key).operation(all).build(); } else { - boolean bypassCache = getRequestOptions != null && getRequestOptions.getBypassCache(); + boolean bypassCache = getRequestOptions != null && getRequestOptions.isBypassCache(); command = plookupCommandBuilder().key(key).bypassCache(bypassCache).operation(all).build(); } diff --git a/at_client/src/main/java/org/atsign/client/api/impl/clients/AtClients.java b/at_client/src/main/java/org/atsign/client/api/impl/clients/AtClients.java new file mode 100644 index 00000000..9de3f770 --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/api/impl/clients/AtClients.java @@ -0,0 +1,62 @@ +package org.atsign.client.api.impl.clients; + + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.atsign.client.api.AtClient; +import org.atsign.client.api.AtKeys; +import org.atsign.client.api.impl.events.SimpleAtEventBus; +import org.atsign.client.connection.api.AtClientConnection; +import org.atsign.client.connection.api.AtEndpointSupplier; +import org.atsign.client.connection.api.ReconnectStrategy; +import org.atsign.client.connection.common.SimpleReconnectStrategy; +import org.atsign.client.connection.netty.NettyAtClientConnection; +import org.atsign.client.connection.netty.NettyAtEndpointSupplier; +import org.atsign.client.connection.protocol.Authentication; +import org.atsign.common.AtException; +import org.atsign.common.AtSign; + +import lombok.Builder; + +/** + * Utilities for instantiating AtClient implementations + */ +public class AtClients { + + @Builder(builderClassName = "AtClientsBuilder", builderMethodName = "builder") + public static AtClient createAtClient(String url, + AtSign atSign, + AtKeys keys, + ReconnectStrategy reconnect, + Boolean isVerbose) + throws AtException { + + // Netty based connection implementation + AtClientConnection connection = NettyAtClientConnection.builder() + .endpoint(createEndpointSupplier(url, atSign)) + .isVerbose(isVerbose) + .reconnect(reconnect != null ? reconnect : SimpleReconnectStrategy.builder().build()) + .onReady(Authentication.pkamAuthenticator(atSign, keys)) + .build(); + + return DefaultAtClientImpl.builder() + .atSign(atSign) + .keys(keys) + .connection(connection) + .eventBus(new SimpleAtEventBus()) + .build(); + } + + private static AtEndpointSupplier createEndpointSupplier(String url, AtSign atSign) { + Matcher matcher = Pattern.compile("proxy:(.+)").matcher(url); + if (matcher.matches()) { + return () -> matcher.group(1); + } else { + return NettyAtEndpointSupplier.builder() + .rootUrl(url) + .atsign(atSign) + .build(); + } + } +} diff --git a/at_client/src/main/java/org/atsign/client/api/impl/clients/DefaultAtClientImpl.java b/at_client/src/main/java/org/atsign/client/api/impl/clients/DefaultAtClientImpl.java new file mode 100644 index 00000000..f8e03a98 --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/api/impl/clients/DefaultAtClientImpl.java @@ -0,0 +1,345 @@ +package org.atsign.client.api.impl.clients; + +import static org.atsign.client.api.AtEvents.AtEventType.decryptedUpdateNotification; +import static org.atsign.client.connection.protocol.Data.matchData; +import static org.atsign.client.connection.protocol.Error.matchError; +import static org.atsign.client.connection.protocol.Error.throwExceptionIfError; +import static org.atsign.client.util.EncryptionUtil.aesDecryptFromBase64; +import static org.atsign.client.util.EncryptionUtil.rsaDecryptFromBase64; +import static org.atsign.client.util.Preconditions.checkNotNull; + +import java.io.IOException; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.atsign.client.api.AtClient; +import org.atsign.client.api.AtEvents.AtEventBus; +import org.atsign.client.api.AtEvents.AtEventListener; +import org.atsign.client.api.AtEvents.AtEventType; +import org.atsign.client.api.AtKeys; +import org.atsign.client.api.Secondary; +import org.atsign.client.connection.api.AtClientConnection; +import org.atsign.client.connection.protocol.*; +import org.atsign.common.AtException; +import org.atsign.common.AtSign; +import org.atsign.common.Keys.AtKey; +import org.atsign.common.Keys.PublicKey; +import org.atsign.common.Keys.SelfKey; +import org.atsign.common.Keys.SharedKey; +import org.atsign.common.exceptions.AtDecryptionException; +import org.atsign.common.options.GetRequestOptions; + +import lombok.Builder; +import lombok.extern.slf4j.Slf4j; + +/** + * Implementation of an {@link AtClient} which wraps a + * {@link org.atsign.client.connection.api.AtClientConnection} + * in order to implement the "map like" features of the {@link AtClient} interface + */ +@SuppressWarnings({"RedundantThrows", "unused"}) +@Slf4j +public class DefaultAtClientImpl implements AtClient { + + private final AtSign atSign; + private final AtKeys keys; + private final AtClientConnection connection; + private final AtEventBus eventBus; + private final AtomicBoolean isMonitoring = new AtomicBoolean(); + private final Notifications.EventBusBridge eventBusBridge; + + @Override + public AtSign getAtSign() { + return atSign; + } + + @Override + public AtKeys getEncryptionKeys() { + return keys; + } + + @Builder + public DefaultAtClientImpl(AtSign atSign, AtKeys keys, AtClientConnection connection, AtEventBus eventBus) { + checkNotNull(keys.getEncryptPrivateKey(), "AtKeys have not been fully enrolled"); + this.atSign = atSign; + this.keys = keys; + this.connection = connection; + this.eventBus = eventBus; + this.eventBus.addEventListener(this::handleEvent, EnumSet.allOf(AtEventType.class)); + this.eventBusBridge = new Notifications.EventBusBridge(eventBus, atSign); + } + + @Override + public void close() throws IOException { + try { + connection.close(); + } catch (Exception e) { + throw new IOException(e); + } + } + + @Override + public void startMonitor() { + isMonitoring.compareAndSet(false, true); + connection.onReady(Notifications.monitor(atSign, keys, eventBusBridge::accept)); + } + + @Override + public void stopMonitor() { + isMonitoring.compareAndSet(true, false); + connection.onReady(Authentication.pkamAuthenticator(atSign, keys)); + } + + @Override + public boolean isMonitorRunning() { + return isMonitoring.get(); + } + + @Override + public synchronized void addEventListener(AtEventListener listener, Set eventTypes) { + eventBus.addEventListener(listener, eventTypes); + } + + @Override + public synchronized void removeEventListener(AtEventListener listener) { + eventBus.removeEventListener(listener); + } + + @Override + public int publishEvent(AtEventType eventType, Map eventData) { + return eventBus.publishEvent(eventType, eventData); + } + + @Override + public CompletableFuture get(SharedKey sharedKey) { + return CompletableFuture.supplyAsync(() -> { + try { + return SharedKeys.get(connection, atSign, keys, sharedKey); + } catch (Exception e) { + throw new CompletionException(e); + } + }); + } + + @Override + public CompletableFuture getBinary(SharedKey sharedKey) { + throw new UnsupportedOperationException("to be implemented"); + } + + @Override + public CompletableFuture put(SharedKey sharedKey, String value) { + return CompletableFuture.supplyAsync(() -> { + try { + SharedKeys.put(connection, atSign, keys, sharedKey, value); + return null; + } catch (Exception e) { + throw new CompletionException(e); + } + }); + } + + @Override + public CompletableFuture delete(SharedKey sharedKey) { + return deleteKey(sharedKey); + } + + @Override + public CompletableFuture get(SelfKey selfKey) { + return CompletableFuture.supplyAsync(() -> { + try { + return SelfKeys.get(connection, keys, selfKey); + } catch (Exception e) { + throw new CompletionException(e); + } + }); + } + + @Override + public CompletableFuture getBinary(SelfKey selfKey) { + throw new UnsupportedOperationException("to be implemented"); + } + + @Override + public CompletableFuture put(SelfKey selfKey, String value) { + return CompletableFuture.supplyAsync(() -> { + try { + SelfKeys.put(connection, keys, selfKey, value); + return null; + } catch (Exception e) { + throw new CompletionException(e); + } + }); + } + + @Override + public CompletableFuture delete(SelfKey selfKey) { + return deleteKey(selfKey); + } + + @Override + public CompletableFuture get(PublicKey publicKey) { + return CompletableFuture.supplyAsync(() -> { + try { + return PublicKeys.get(connection, atSign, publicKey, null); + } catch (Exception e) { + throw new CompletionException(e); + } + }); + } + + @Override + public CompletableFuture get(PublicKey publicKey, GetRequestOptions options) { + return CompletableFuture.supplyAsync(() -> { + try { + return PublicKeys.get(connection, atSign, publicKey, options); + } catch (Exception e) { + throw new CompletionException(e); + } + }); + } + + @Override + public CompletableFuture getBinary(PublicKey publicKey) { + throw new UnsupportedOperationException("to be implemented"); + } + + @Override + public CompletableFuture getBinary(PublicKey publicKey, GetRequestOptions getRequestOptions) { + throw new UnsupportedOperationException("to be implemented"); + } + + @Override + public CompletableFuture put(PublicKey publicKey, String value) { + return CompletableFuture.supplyAsync(() -> { + try { + PublicKeys.put(connection, keys, publicKey, value); + return null; + } catch (Exception e) { + throw new CompletionException(e); + } + }); + } + + @Override + public CompletableFuture delete(PublicKey publicKey) { + return deleteKey(publicKey); + } + + @Override + public CompletableFuture put(SharedKey sharedKey, byte[] value) { + throw new UnsupportedOperationException("to be implemented"); + } + + @Override + public CompletableFuture put(SelfKey selfKey, byte[] value) { + throw new UnsupportedOperationException("to be implemented"); + } + + @Override + public CompletableFuture put(PublicKey publicKey, byte[] value) { + throw new UnsupportedOperationException("to be implemented"); + } + + @Override + public CompletableFuture> getAtKeys(String regex) { + return getAtKeys(regex, true); + } + + @Override + public CompletableFuture> getAtKeys(String regex, boolean fetchMetadata) { + return CompletableFuture.supplyAsync(() -> { + try { + return Keys.getKeys(connection, regex, fetchMetadata); + } catch (Exception e) { + throw new CompletionException(e); + } + }); + } + + @Override + public Response executeCommand(String command, boolean throwExceptionOnErrorResponse) + throws AtException, IOException { + + try { + String s = connection.sendSync(command); + Response response = new Response(); + if (throwExceptionOnErrorResponse) { + response.setRawDataResponse(throwExceptionIfError(matchData(s))); + } else { + if (s.startsWith("error:")) { + response.setRawErrorResponse(matchError(s)); + } else { + response.setRawDataResponse(matchData(s)); + } + } + return response; + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + } + + @Override + public Secondary getSecondary() { + throw new UnsupportedOperationException("not supported"); + } + + private CompletableFuture deleteKey(AtKey key) { + return CompletableFuture.supplyAsync(() -> { + try { + Keys.deleteKey(connection, key); + return null; + } catch (Exception e) { + throw new CompletionException(e); + } + }); + } + + @Override + public void handleEvent(AtEventType eventType, Map eventData) { + try { + switch (eventType) { + case sharedKeyNotification: + onSharedKeyNotification(eventData); + break; + case updateNotification: + onUpdateNotification(eventData); + break; + default: + break; + } + } catch (Exception e) { + log.error("unexpected exception handling {} : {}", eventType, eventData, e); + } + } + + private void onSharedKeyNotification(Map eventData) throws AtDecryptionException { + // We've got notification that someone has shared an encryption key with us + // If we also got a value, we can decrypt it and add it to our keys map + // Note: a value isn't supplied when the ttr on the shared key was set to 0 + if (eventData.get("value") != null) { + String keyName = (String) eventData.get("key"); + String value = (String) eventData.get("value"); + String decrypted = rsaDecryptFromBase64(value, keys.getEncryptPrivateKey()); + keys.put(keyName, decrypted); + } + } + + private void onUpdateNotification(Map eventData) throws AtException { + // Let's see if we can decrypt it on the fly + if (eventData.get("value") != null) { + String key = (String) eventData.get("key"); + String encryptedValue = (String) eventData.get("value"); + Map metadata = (Map) eventData.get("metadata"); + String ivNonce = (String) metadata.get("ivNonce"); + SharedKey sk = org.atsign.common.Keys.sharedKeyBuilder().rawKey(key).build(); + String encryptKeySharedByOther = SharedKeys.getEncryptKeySharedByOther(connection, keys, sk); + String decryptedValue = aesDecryptFromBase64(encryptedValue, encryptKeySharedByOther, ivNonce); + HashMap newEventData = new HashMap<>(eventData); + newEventData.put("decryptedValue", decryptedValue); + eventBus.publishEvent(decryptedUpdateNotification, newEventData); + } + } +} diff --git a/at_client/src/main/java/org/atsign/client/api/impl/secondaries/RemoteSecondary.java b/at_client/src/main/java/org/atsign/client/api/impl/secondaries/RemoteSecondary.java index 389d00a1..a9bb108e 100644 --- a/at_client/src/main/java/org/atsign/client/api/impl/secondaries/RemoteSecondary.java +++ b/at_client/src/main/java/org/atsign/client/api/impl/secondaries/RemoteSecondary.java @@ -17,7 +17,7 @@ import org.atsign.common.AtSign; import org.atsign.common.exceptions.AtIllegalArgumentException; import org.atsign.common.exceptions.AtInvalidAtKeyException; -import org.atsign.common.exceptions.AtInvalidSyntaxException; +import org.atsign.common.exceptions.AtExceptions.AtInvalidSyntaxException; import org.atsign.common.exceptions.AtUnknownResponseException; /** diff --git a/at_client/src/main/java/org/atsign/client/cli/AbstractCli.java b/at_client/src/main/java/org/atsign/client/cli/AbstractCli.java index fa40866f..d14e2e54 100644 --- a/at_client/src/main/java/org/atsign/client/cli/AbstractCli.java +++ b/at_client/src/main/java/org/atsign/client/cli/AbstractCli.java @@ -1,28 +1,21 @@ package org.atsign.client.cli; import static org.atsign.client.util.Preconditions.checkNotNull; -import static org.atsign.common.VerbBuilders.*; import java.io.File; -import java.io.IOException; -import java.util.List; -import java.util.Map; -import java.util.function.Function; -import java.util.regex.Matcher; -import java.util.regex.Pattern; +import java.util.concurrent.TimeUnit; import org.atsign.client.api.AtKeys; -import org.atsign.client.api.impl.connections.AtRootConnection; -import org.atsign.client.api.impl.connections.AtSecondaryConnection; -import org.atsign.client.api.impl.events.SimpleAtEventBus; -import org.atsign.client.util.AuthUtil; +import org.atsign.client.connection.api.AtClientConnection; +import org.atsign.client.connection.common.SimpleReconnectStrategy; +import org.atsign.client.connection.netty.NettyAtClientConnection; +import org.atsign.client.connection.netty.NettyAtClientConnection.NettyAtClientConnectionBuilder; +import org.atsign.client.connection.netty.NettyAtEndpointSupplier; +import org.atsign.client.connection.protocol.Authentication; import org.atsign.client.util.KeysUtil; import org.atsign.common.AtException; import org.atsign.common.AtSign; -import org.atsign.common.Json; -import org.atsign.common.exceptions.AtSecondaryNotFoundException; - -import com.fasterxml.jackson.core.type.TypeReference; +import org.atsign.common.exceptions.AtClientConfigException; import picocli.CommandLine.ITypeConverter; import picocli.CommandLine.Option; @@ -35,31 +28,6 @@ */ public abstract class AbstractCli> { - /** - * models server response string which is non-empty JSON map - */ - protected static final Pattern DATA_JSON_NON_EMPTY_MAP = Pattern.compile("data:(\\{.+})"); - - /** - * models server response string which is JSON map - */ - protected static final Pattern DATA_JSON_MAP = Pattern.compile("data:(\\{.*})"); - - /** - * models server response string which is non-empty JSON list - */ - protected static final Pattern DATA_JSON_NO_EMPTY_LIST = Pattern.compile("data:(\\[.+])"); - - /** - * models server response string which is integer - */ - protected static final Pattern DATA_INT = Pattern.compile("data:\\d+"); - - /** - * models server response string containing no whitespace - */ - public static final Pattern DATA_NON_WHITESPACE = Pattern.compile("data:(\\S+)"); - protected String rootUrl = "root.atsign.org"; protected AtSign atSign; protected File keysFile; @@ -115,154 +83,47 @@ protected static File getAtKeysFile(File keysFile, AtSign atSign) { return keysFile != null ? keysFile : KeysUtil.getKeysFile(atSign); } - protected static void checkAtServerMatchesAtSign(AtSecondaryConnection connection, AtSign atSign) throws IOException { - String command = scanCommandBuilder().build(); - if (!matchDataJsonList(connection.executeCommand(command)).contains("signing_publickey" + atSign)) { - // TODO: understand precisely what this means (observed in Dart SDK) - throw new IllegalStateException("TBC"); - } - } - - protected static void deleteKey(AtSecondaryConnection connection, String rawKey) { - try { - String command = deleteCommandBuilder().rawKey(rawKey).build(); - match(connection.executeCommand(command), DATA_INT); - } catch (IOException e) { - throw new RuntimeException(e); - } - } - - protected static void authenticateWithApkam(AtSecondaryConnection connection, AtSign atSign, AtKeys keys) - throws AtException, IOException { - new AuthUtil().authenticateWithPkam(connection, atSign, keys); - } - - protected AtSecondaryConnection createAtSecondaryConnection(AtSign atSign, - String rootUrl, - int retries) - throws Exception { - checkNotNull(atSign, "atsign not set"); - checkNotNull(rootUrl, "root server endpoint not set"); - String secondaryUrl = resolveSecondaryUrl(atSign, rootUrl, retries); - AtSecondaryConnection conn = - new AtSecondaryConnection(new SimpleAtEventBus(), atSign, secondaryUrl, null, false, verbose); - int retriesRemaining = retries; - Exception ex; - do { - try { - conn.connect(); - return conn; - } catch (Exception e) { - ex = e; - Thread.sleep(2000); - } - } while (retriesRemaining-- > 0); - throw ex; + protected static String ensureNotNull(String value, String defaultValue) { + return value != null ? value : defaultValue; } - protected static String resolveSecondaryUrl(AtSign atSign, String rootUrl, int retries) throws Exception { - int retriesRemaining = retries; - Exception ex; - do { - try { - return new AtRootConnection(rootUrl).lookupAtSign(atSign); - } catch (AtSecondaryNotFoundException e) { - ex = e; - Thread.sleep(1000); - } - } while (retriesRemaining-- > 0); - throw ex; + protected AtClientConnection createConnection(String rootUrl, AtSign atSign, int retries) throws AtException { + return creatConnectionBuilder(rootUrl, atSign, retries, verbose).build(); } - protected static Map decodeJsonMapOfStrings(String json) { - try { - return Json.MAPPER.readValue(json, new TypeReference>() {}); - } catch (Exception e) { - throw new RuntimeException(e); - } + protected AtClientConnection createAuthenticatedConnection(String rootUrl, AtSign atSign, int retries) + throws AtException { + return creatConnectionBuilder(rootUrl, atSign, retries, verbose) + .onReady(Authentication.pkamAuthenticator(atSign, getKeys())) + .build(); } - protected static Map decodeJsonMapOfObjects(String json) { - try { - return Json.MAPPER.readValue(json, new TypeReference>() {}); - } catch (Exception e) { - throw new RuntimeException(e); - } + private static NettyAtClientConnectionBuilder creatConnectionBuilder(String rootUrl, AtSign atSign, int retries, + boolean verbose) { + NettyAtEndpointSupplier endpoint = NettyAtEndpointSupplier.builder() + .rootUrl(checkNotNull(rootUrl, "root server endpoint not set")) + .atsign(checkNotNull(atSign, "atsign not set")) + .build(); + SimpleReconnectStrategy reconnect = SimpleReconnectStrategy.builder() + .maxReconnectRetries(retries) + .reconnectPauseMillis(TimeUnit.SECONDS.toMillis(2)) + .build(); + return NettyAtClientConnection.builder() + .endpoint(endpoint) + .reconnect(reconnect) + .isVerbose(verbose); } - protected static List decodeJsonList(String json) { + protected AtKeys getKeys() { try { - return Json.MAPPER.readValue(json, new TypeReference>() {}); - } catch (Exception e) { + File file = checkExists(getAtKeysFile(keysFile, atSign)); + return KeysUtil.loadKeys(file); + } catch (AtClientConfigException e) { throw new RuntimeException(e); } } - public static List decodeJsonListOfStrings(String json) { - try { - return Json.MAPPER.readValue(json, new TypeReference>() {}); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - protected static String match(String input, Pattern pattern) { - Matcher matcher = pattern.matcher(input); - if (!matcher.matches()) { - throw new RuntimeException("expected [" + pattern + "] but input was : " + input); - } - StringBuilder builder = new StringBuilder(); - if (matcher.groupCount() == 0) { - builder.append(input); - } else { - for (int i = 1; i <= matcher.groupCount(); i++) { - builder.append(matcher.group(i)); - } - } - return builder.toString(); - } - - protected static T match(String input, Pattern pattern, Function transformer) { - return transformer.apply(match(input, pattern)); - } - - protected static String matchDataString(String input) { - return match(input, DATA_NON_WHITESPACE, s -> s); - } - - protected static int matchDataInt(String input) { - return match(input, DATA_INT, Integer::parseInt); - } - - protected static List matchDataJsonList(String input) { - return match(input, DATA_JSON_NO_EMPTY_LIST, AbstractCli::decodeJsonList); - } - - public static List matchDataJsonListOfStrings(String input) { - return match(input, DATA_JSON_NO_EMPTY_LIST, AbstractCli::decodeJsonListOfStrings); - } - - protected static Map matchDataJsonMapOfStrings(String input, boolean allowEmpty) { - return match(input, allowEmpty ? DATA_JSON_MAP : DATA_JSON_NON_EMPTY_MAP, AbstractCli::decodeJsonMapOfStrings); - } - - protected static Map matchDataJsonMapOfObjects(String input, boolean allowEmpty) { - return match(input, allowEmpty ? DATA_JSON_MAP : DATA_JSON_NON_EMPTY_MAP, AbstractCli::decodeJsonMapOfObjects); - } - - protected static Map matchDataJsonMapOfStrings(String input) { - return matchDataJsonMapOfStrings(input, false); - } - - protected static Map matchDataJsonMapOfObjects(String input) { - return matchDataJsonMapOfObjects(input, false); - } - - protected static String ensureNotNull(String value, String defaultValue) { - return value != null ? value : defaultValue; - } - static class AtSignConverter implements ITypeConverter { @Override public AtSign convert(String s) { diff --git a/at_client/src/main/java/org/atsign/client/cli/Activate.java b/at_client/src/main/java/org/atsign/client/cli/Activate.java index 9284a1e1..2e5d31e2 100644 --- a/at_client/src/main/java/org/atsign/client/cli/Activate.java +++ b/at_client/src/main/java/org/atsign/client/cli/Activate.java @@ -1,30 +1,24 @@ package org.atsign.client.cli; -import static org.atsign.client.util.EncryptionUtil.*; -import static org.atsign.client.util.EnrollmentId.createEnrollmentId; +import static org.atsign.client.util.EncryptionUtil.generateAESKeyBase64; +import static org.atsign.client.util.EncryptionUtil.generateRSAKeyPair; import static org.atsign.client.util.KeysUtil.saveKeys; import static org.atsign.client.util.Preconditions.checkNotNull; -import static org.atsign.common.VerbBuilders.*; import java.io.File; -import java.io.IOException; -import java.security.NoSuchAlgorithmException; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.Callable; import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; -import org.atsign.client.api.AtKeyNames; import org.atsign.client.api.AtKeys; -import org.atsign.client.api.impl.connections.AtSecondaryConnection; -import org.atsign.client.util.AuthUtil; +import org.atsign.client.connection.api.AtClientConnection; +import org.atsign.client.connection.protocol.Enroll; +import org.atsign.client.connection.protocol.Scan; import org.atsign.client.util.EnrollmentId; import org.atsign.client.util.KeysUtil; -import org.atsign.common.AtException; -import org.atsign.common.AtSign; -import org.atsign.common.VerbBuilders; +import org.atsign.common.exceptions.AtEncryptionException; import org.atsign.common.exceptions.AtUnauthenticatedException; import picocli.CommandLine; @@ -186,66 +180,35 @@ public Activate addNamespace(String namespace, String accessControl) { } public EnrollmentId onboard() throws Exception { - try (AtSecondaryConnection connection = createAtSecondaryConnection(atSign, rootUrl, connectionRetries)) { + try (AtClientConnection connection = createConnection(rootUrl, atSign, connectionRetries)) { return onboard(connection); } } - public EnrollmentId onboard(AtSecondaryConnection connection) throws Exception { + public EnrollmentId onboard(AtClientConnection connection) throws Exception { File file = getAtKeysFile(keysFile, atSign); if (!overwriteKeysFile) { checkNotExists(file); } - checkAtServerMatchesAtSign(connection, atSign); - authenticateWithCram(connection, atSign, cramSecret); AtKeys keys = generateAtKeys(true); - EnrollmentId enrollmentId = enroll(connection, - keys, - ensureNotNull(appName, DEFAULT_FIRST_APP), - ensureNotNull(deviceName, DEFAULT_FIRST_DEVICE)); - keys = keys.toBuilder().enrollmentId(enrollmentId).build(); - authenticateWithApkam(connection, atSign, keys); + keys = Enroll.onboard(connection, + atSign, + keys, + cramSecret, + ensureNotNull(appName, DEFAULT_FIRST_APP), + ensureNotNull(deviceName, DEFAULT_FIRST_DEVICE), + deleteCramKey); saveKeys(keys, file); - storeEncryptPublicKey(connection, atSign, keys); - if (deleteCramKey) { - deleteCramSecret(connection); - } return enrollmentId; } - public Activate authenticate(AtSecondaryConnection connection) throws Exception { - File file = checkExists(getAtKeysFile(keysFile, atSign)); - keys = KeysUtil.loadKeys(file); - authenticateWithApkam(connection, atSign, keys); - return this; - } - public List list() throws Exception { return list(requestStatus); } public List list(String status) throws Exception { - try (AtSecondaryConnection connection = createAtSecondaryConnection(atSign, rootUrl, connectionRetries)) { - return authenticate(connection).list(connection, status); - } - } - - public List list(AtSecondaryConnection connection, String status) throws Exception { - String command = enrollCommandBuilder() - .operation(VerbBuilders.EnrollOperation.list) - .status(status) - .build(); - return matchDataJsonMapOfObjects(connection.executeCommand(command), true).keySet().stream() - .map(Activate::inferEnrollmentId) - .collect(Collectors.toList()); - } - - private static EnrollmentId inferEnrollmentId(String key) { - int endIndex = key.indexOf('.'); - if (key.contains("__manage") && endIndex > 0) { - return EnrollmentId.createEnrollmentId(key.substring(0, endIndex)); - } else { - throw new RuntimeException(key + " doesn't match expected enrollment key pattern"); + try (AtClientConnection connection = createAuthenticatedConnection(rootUrl, atSign, connectionRetries)) { + return Enroll.list(connection, status); } } @@ -254,54 +217,18 @@ public void approve() throws Exception { } public void approve(EnrollmentId enrollmentId) throws Exception { - try (AtSecondaryConnection connection = createAtSecondaryConnection(atSign, rootUrl, connectionRetries)) { - authenticate(connection).approve(connection, enrollmentId); - } - } - - public void approve(AtSecondaryConnection connection, EnrollmentId enrollmentId) throws Exception { - String key = fetchApkamSymmetricKey(connection, enrollmentId); - String privateKeyIv = generateRandomIvBase64(16); - String encryptPrivateKey = aesEncryptToBase64(keys.getEncryptPrivateKey(), key, privateKeyIv); - String selfEncryptKeyIv = generateRandomIvBase64(16); - String selfEncryptKey = aesEncryptToBase64(keys.getSelfEncryptKey(), key, selfEncryptKeyIv); - - String command = enrollCommandBuilder() - .operation(VerbBuilders.EnrollOperation.approve) - .enrollmentId(enrollmentId) - .encryptPrivateKey(encryptPrivateKey) - .encryptPrivateKeyIv(privateKeyIv) - .selfEncryptKey(selfEncryptKey) - .selfEncryptKeyIv(selfEncryptKeyIv) - .build(); - - Map response = matchDataJsonMapOfStrings(connection.executeCommand(command)); - if (!"approved".equals(response.get("status"))) { - throw new RuntimeException("status is not approved : " + response.get("status")); + try (AtClientConnection connection = createAuthenticatedConnection(rootUrl, atSign, connectionRetries)) { + Enroll.approve(connection, getKeys(), enrollmentId); } } - private String fetchApkamSymmetricKey(AtSecondaryConnection connection, EnrollmentId enrollmentId) throws Exception { - String command = enrollCommandBuilder() - .operation(VerbBuilders.EnrollOperation.fetch) - .enrollmentId(enrollmentId) - .build(); - Map request = matchDataJsonMapOfObjects(connection.executeCommand(command)); - if (!"pending".equals(request.get("status"))) { - throw new RuntimeException("status is not pending : " + request.get("status")); - } - String encryptedApkamSymmetricKey = - (String) request.get(VerbBuilders.EnrollParameters.ENCRYPTED_APKAM_SYMMETRIC_KEY); - return rsaDecryptFromBase64(encryptedApkamSymmetricKey, keys.getEncryptPrivateKey()); - } - public void deny() throws Exception { deny(checkNotNull(enrollmentId, "enrollment id not set")); } public void deny(EnrollmentId enrollmentId) throws Exception { - try (AtSecondaryConnection connection = createAtSecondaryConnection(atSign, rootUrl, connectionRetries)) { - authenticate(connection).deny(connection, enrollmentId); + try (AtClientConnection connection = createAuthenticatedConnection(rootUrl, atSign, connectionRetries)) { + Enroll.deny(connection, enrollmentId); } } @@ -310,8 +237,8 @@ public void revoke() throws Exception { } public void revoke(EnrollmentId enrollmentId) throws Exception { - try (AtSecondaryConnection connection = createAtSecondaryConnection(atSign, rootUrl, connectionRetries)) { - authenticate(connection).revoke(connection, enrollmentId); + try (AtClientConnection connection = createAuthenticatedConnection(rootUrl, atSign, connectionRetries)) { + Enroll.revoke(connection, enrollmentId); } } @@ -320,194 +247,69 @@ public void unrevoke() throws Exception { } public void unrevoke(EnrollmentId enrollmentId) throws Exception { - try (AtSecondaryConnection connection = createAtSecondaryConnection(atSign, rootUrl, connectionRetries)) { - authenticate(connection).unrevoke(connection, enrollmentId); + try (AtClientConnection connection = createAuthenticatedConnection(rootUrl, atSign, connectionRetries)) { + Enroll.unrevoke(connection, enrollmentId); } } public void delete(EnrollmentId enrollmentId) throws Exception { - try (AtSecondaryConnection connection = createAtSecondaryConnection(atSign, rootUrl, connectionRetries)) { - authenticate(connection).delete(connection, enrollmentId); + try (AtClientConnection connection = createAuthenticatedConnection(rootUrl, atSign, connectionRetries)) { + delete(connection, enrollmentId); } } - public void deny(AtSecondaryConnection connection, EnrollmentId enrollmentId) throws Exception { - singleArgEnrollAction(connection, "deny", enrollmentId, "denied"); - } - - public void revoke(AtSecondaryConnection connection, EnrollmentId enrollmentId) throws Exception { - singleArgEnrollAction(connection, "revoke", enrollmentId, "revoked"); - } - - public void unrevoke(AtSecondaryConnection connection, EnrollmentId enrollmentId) throws Exception { - singleArgEnrollAction(connection, "unrevoke", enrollmentId, "approved"); - } - - public void delete(AtSecondaryConnection connection, EnrollmentId enrollmentId) throws Exception { - singleArgEnrollAction(connection, "delete", enrollmentId, "deleted"); + public static void delete(AtClientConnection connection, EnrollmentId enrollmentId) throws Exception { + Enroll.delete(connection, enrollmentId); } public String otp() throws Exception { - try (AtSecondaryConnection connection = createAtSecondaryConnection(atSign, rootUrl, connectionRetries)) { - return otp(connection); + try (AtClientConnection connection = createAuthenticatedConnection(rootUrl, atSign, connectionRetries)) { + return Enroll.otp(connection); } } - public String otp(AtSecondaryConnection connection) throws IOException, AtException { - File file = checkExists(getAtKeysFile(keysFile, atSign)); - AtKeys keys = KeysUtil.loadKeys(file); - authenticateWithApkam(connection, atSign, keys); - String command = otpCommandBuilder().build(); - return match(connection.executeCommand(command), DATA_NON_WHITESPACE); - } - public List scan() throws Exception { - try (AtSecondaryConnection connection = createAtSecondaryConnection(atSign, rootUrl, connectionRetries)) { - return scan(connection); - } - } - - public List scan(AtSecondaryConnection connection) throws Exception { - String command = scanCommandBuilder() - .showHidden(true) - .regex(".*") - .build(); - return matchDataJsonListOfStrings(connection.executeCommand(command)); - } - - private void singleArgEnrollAction(AtSecondaryConnection connection, - String action, - EnrollmentId enrollmentId, - String expectedStatus) - throws Exception { - String command = enrollCommandBuilder() - .operation(VerbBuilders.EnrollOperation.valueOf(action)) - .enrollmentId(enrollmentId) - .build(); - Map map = matchDataJsonMapOfStrings(connection.executeCommand(command)); - if (!expectedStatus.equals(map.get("status"))) { - throw new RuntimeException("status is not " + expectedStatus + " : " + map.get("status")); - } - } - - protected static void authenticateWithCram(AtSecondaryConnection connection, AtSign atSign, String cramSecret) - throws AtException, IOException { - checkNotNull(cramSecret, "CRAM secret not set"); - new AuthUtil().authenticateWithCram(connection, atSign, cramSecret); - } - - private static EnrollmentId enroll(AtSecondaryConnection connection, - AtKeys keys, - String appName, - String deviceName) - throws Exception { - String command = enrollCommandBuilder() - .operation(VerbBuilders.EnrollOperation.request) - .appName(appName) - .deviceName(deviceName) - .apkamPublicKey(keys.getApkamPublicKey()) - .build(); - Map response = matchDataJsonMapOfStrings(connection.executeCommand(command)); - if (!response.get("status").equals("approved")) { - throw new RuntimeException("enroll request failed, expected status approved : " + response); + try (AtClientConnection connection = createConnection(rootUrl, atSign, connectionRetries)) { + return Scan.scan(connection, true, ".*"); } - return createEnrollmentId(response.get("enrollmentId")); - } - - protected static void storeEncryptPublicKey(AtSecondaryConnection connection, AtSign atSign, AtKeys keys) - throws IOException { - String command = updateCommandBuilder() - .sharedBy(atSign) - .keyName(AtKeyNames.PUBLIC_ENCRYPT) - .isPublic(true) - .value(keys.getEncryptPublicKey()) - .build(); - match(connection.executeCommand(command), DATA_INT); - } - - protected static void deleteCramSecret(AtSecondaryConnection connection) { - deleteKey(connection, AtKeyNames.PRIVATE_AT_SECRET); - } - - protected static AtKeys generateAtKeys(boolean generateEncryptionKeyPair) throws NoSuchAlgorithmException { - AtKeys.AtKeysBuilder builder = AtKeys.builder() - .selfEncryptKey(generateAESKeyBase64()) - .apkamKeyPair(generateRSAKeyPair()) - .apkamSymmetricKey(generateAESKeyBase64()); - if (generateEncryptionKeyPair) { - builder.encryptKeyPair(generateRSAKeyPair()); - } - return builder.build(); } public EnrollmentId enroll() throws Exception { - try (AtSecondaryConnection connection = createAtSecondaryConnection(atSign, rootUrl, connectionRetries)) { + try (AtClientConnection connection = createConnection(rootUrl, atSign, connectionRetries)) { return enroll(connection); } } - public EnrollmentId enroll(AtSecondaryConnection connection) throws Exception { - String command = lookupCommandBuilder() - .keyName(AtKeyNames.PUBLIC_ENCRYPT) - .sharedBy(atSign) - .build(); - String publicKey = matchDataString(connection.executeCommand(command)); + public EnrollmentId enroll(AtClientConnection connection) throws Exception { File file = keysFile; if (!overwriteKeysFile) { checkNotExists(file); } - AtKeys keys = generateAtKeys(false).toBuilder() - .encryptPublicKey(publicKey) - .build(); - enrollmentId = enroll(connection, keys); - keys = keys.toBuilder().enrollmentId(enrollmentId).build(); + AtKeys keys = generateAtKeys(false); + keys = Enroll.enroll(connection, atSign, keys, otp, appName, deviceName, namespaces); KeysUtil.saveKeys(keys, keysFile); return keys.getEnrollmentId(); } - private EnrollmentId enroll(AtSecondaryConnection connection, AtKeys keys) throws Exception { - String command = enrollCommandBuilder() - .operation(VerbBuilders.EnrollOperation.request) - .appName(appName) - .deviceName(deviceName) - .apkamPublicKey(keys.getApkamPublicKey()) - .apkamSymmetricKey(rsaEncryptToBase64(keys.getApkamSymmetricKey(), keys.getEncryptPublicKey())) - .otp(otp) - .namespaces(namespaces) - .build(); - Map response = matchDataJsonMapOfStrings(connection.executeCommand(command)); - if ("pending".equals(response.get("status"))) { - return EnrollmentId.createEnrollmentId(response.get("enrollmentId")); - } else { - throw new RuntimeException("expected status pending : " + response); - } - } - public void complete() throws Exception { - try (AtSecondaryConnection connection = createAtSecondaryConnection(atSign, rootUrl, connectionRetries)) { + try (AtClientConnection connection = createConnection(rootUrl, atSign, connectionRetries)) { complete(connection); } } - public void complete(AtSecondaryConnection connection) throws Exception { + public void complete(AtClientConnection connection) throws Exception { AtKeys keys = KeysUtil.loadKeys(keysFile); - authenticate(connection); - String selfEncryptKey = keysGetDecrypted(connection, atSign, keys, AtKeyNames.SELF_ENCRYPTION_KEY); - String encryptPrivateKey = keysGetDecrypted(connection, atSign, keys, AtKeyNames.ENCRYPT_PRIVATE_KEY); - keys = keys.toBuilder() - .selfEncryptKey(selfEncryptKey) - .encryptPrivateKey(encryptPrivateKey) - .build(); + keys = Enroll.complete(connection, atSign, keys); KeysUtil.saveKeys(keys, keysFile); } public void complete(int retries, long sleepDuration, TimeUnit sleepUnit) throws Exception { - try (AtSecondaryConnection connection = createAtSecondaryConnection(atSign, rootUrl, connectionRetries)) { + try (AtClientConnection connection = createConnection(rootUrl, atSign, connectionRetries)) { complete(connection, retries, sleepDuration, sleepUnit); } } - public void complete(AtSecondaryConnection connection, int retries, long sleepDuration, TimeUnit sleepUnit) + public void complete(AtClientConnection connection, int retries, long sleepDuration, TimeUnit sleepUnit) throws Exception { Exception exception; int remainingRetries = retries; @@ -526,23 +328,15 @@ public void complete(AtSecondaryConnection connection, int retries, long sleepDu throw exception != null ? exception : new IllegalArgumentException(); } - private static String keysGetDecrypted(AtSecondaryConnection connection, - AtSign atSign, - AtKeys keys, - String keyConstant) - throws Exception { - String rawKeyName = keys.getEnrollmentId() + "." + keyConstant + ".__manage" + atSign; - String command = keysCommandBuilder() - .operation(VerbBuilders.KeysOperation.get) - .keyName(rawKeyName) - .build(); - return decryptEncryptedKey(connection.executeCommand(command), keys.getApkamSymmetricKey()); + protected static AtKeys generateAtKeys(boolean generateEncryptionKeyPair) throws AtEncryptionException { + AtKeys.AtKeysBuilder builder = AtKeys.builder() + .selfEncryptKey(generateAESKeyBase64()) + .apkamKeyPair(generateRSAKeyPair()) + .apkamSymmetricKey(generateAESKeyBase64()); + if (generateEncryptionKeyPair) { + builder.encryptKeyPair(generateRSAKeyPair()); + } + return builder.build(); } - protected static String decryptEncryptedKey(String json, String keyBase64) throws Exception { - Map map = matchDataJsonMapOfStrings(json); - String encryptedKey = map.get("value"); - String iv = map.get("iv"); - return aesDecryptFromBase64(encryptedKey, keyBase64, iv); - } } diff --git a/at_client/src/main/java/org/atsign/client/connection/api/AtClientConnection.java b/at_client/src/main/java/org/atsign/client/connection/api/AtClientConnection.java new file mode 100644 index 00000000..ce34ae4d --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/connection/api/AtClientConnection.java @@ -0,0 +1,84 @@ +package org.atsign.client.connection.api; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.function.Consumer; + +/** + * Represents a connection to an AtSign server command interface. + */ +public interface AtClientConnection extends AutoCloseable { + + /** + * Asynchronously executes an Atsign Protocol command that is expected to return a response. + * + * @param command the command to execute + * @param future the future which will be completed with the response + */ + void send(String command, CompletableFuture future); + + /** + * Asynchronously executes an Atsign Protocol command that is expected to return a response. + * + * @param command the command to execute + * @return the future which will be completed with the response + */ + default CompletableFuture send(String command) { + CompletableFuture future = new CompletableFuture<>(); + send(command, future); + return future; + } + + /** + * Synchronously executes an Atsign Protocol command that is expected to return a response. + * This method will return once the command has been sent to the AtSign server and the response + * has been received. + * + * @param command the command to execute + * @return the response + */ + String sendSync(String command) throws ExecutionException, InterruptedException; + + /** + * Asynchronously executes an Atsign Protocol command that is expected to return a stream of events. + * + * @param command the command to execute + * @param consumer the consumer which the connection will invoke with each event + * @param future the future which will be completed when the command is sent and acknowledged + */ + void send(String command, Consumer consumer, CompletableFuture future); + + /** + * Asynchronously executes an Atsign Protocol command that is expected to return a stream of events. + * + * @param command the command to execute + * @param consumer the consumer which the connection will invoke with each event + * @return a future which will be completed when the command is sent and acknowledged + */ + default CompletableFuture send(String command, Consumer consumer) { + CompletableFuture future = new CompletableFuture<>(); + send(command, consumer, future); + return future; + } + + /** + * Synchronously executes an Atsign Protocol command that is expected to return a stream of events. + * This method will return once the command has been sent to the AtSign server and some form of + * acknowledgement has been received. + * + * @param command the command to execute + * @param consumer the consumer which the connection will invoke with each event + */ + void sendSync(String command, Consumer consumer) throws ExecutionException, InterruptedException; + + /** + * Registers a consumer that will be invoked when the connection is ready for sending commands. + * This is intended to be used to execute commands that set up state, for example to ensure that + * a connection is authenticated or that a connection is registered for notifications. + * If this connection is already ready then the consumer will be invoked immediately. + * + * @param consumer a consumer that will perform commands + * @return this (to allow chaining / fluent style invocation) + */ + AtClientConnection onReady(Consumer consumer); +} diff --git a/at_client/src/main/java/org/atsign/client/connection/api/AtEndpointSupplier.java b/at_client/src/main/java/org/atsign/client/connection/api/AtEndpointSupplier.java new file mode 100644 index 00000000..ee9fa6af --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/connection/api/AtEndpointSupplier.java @@ -0,0 +1,17 @@ +package org.atsign.client.connection.api; + +import org.atsign.common.exceptions.AtSecondaryNotFoundException; + +/** + * Something that is capable of supplying an endpoint string + */ +public interface AtEndpointSupplier { + + /** + * + * @return host and port separated by a colon symbol + * @throws AtSecondaryNotFoundException if it is not possible to resolve the endpoint + */ + String get() throws AtSecondaryNotFoundException; + +} diff --git a/at_client/src/main/java/org/atsign/client/connection/api/ReconnectStrategy.java b/at_client/src/main/java/org/atsign/client/connection/api/ReconnectStrategy.java new file mode 100644 index 00000000..e19ff0e4 --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/connection/api/ReconnectStrategy.java @@ -0,0 +1,50 @@ +package org.atsign.client.connection.api; + +/** + * A strategy for controlling re-connection behavior + */ +public interface ReconnectStrategy { + + boolean isReconnectSupported(); + + void onConnectFailure(Throwable ex); + + void onConnect(); + + void onDisconnect(Throwable ex); + + long getReconnectPauseMillis(); + + boolean isReresolveEndpoint(); + + /** + * A strategy where no reconnection is required + */ + ReconnectStrategy NONE = new ReconnectStrategy() { + + @Override + public boolean isReconnectSupported() { + return false; + } + + @Override + public void onConnectFailure(Throwable ex) {} + + @Override + public void onConnect() {} + + @Override + public void onDisconnect(Throwable ex) {} + + @Override + public long getReconnectPauseMillis() { + return 0; + } + + @Override + public boolean isReresolveEndpoint() { + return false; + } + }; + +} diff --git a/at_client/src/main/java/org/atsign/client/connection/common/Command.java b/at_client/src/main/java/org/atsign/client/connection/common/Command.java new file mode 100644 index 00000000..f0b12c10 --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/connection/common/Command.java @@ -0,0 +1,95 @@ +package org.atsign.client.connection.common; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Consumer; + +/** + * An "element" / holder for commands + */ +public class Command { + + private final String text; + + private final CompletableFuture future; + + private final Consumer consumer; + + private final long timestampMillis; + + private final boolean isOnReady; + + public Command(String text, CompletableFuture future, long timestampMillis, boolean isOnReady) { + this.text = text; + this.future = future; + this.consumer = null; + this.timestampMillis = timestampMillis; + this.isOnReady = isOnReady; + } + + public Command(String text, CompletableFuture future, Consumer consumer, long timestampMillis, + boolean isOnReady) { + this.text = text; + this.future = future; + this.consumer = consumer; + this.timestampMillis = timestampMillis; + this.isOnReady = isOnReady; + } + + @Override + public String toString() { + return text; + } + + public boolean isTimedOut(long timeoutMillis) { + return timestampMillis < timeoutMillis; + } + + public boolean isOnReady() { + return isOnReady; + } + + public void completeExceptionally(Throwable ex) { + future.completeExceptionally(ex); + } + + public void complete(String s) { + if (consumer != null) { + future.complete(null); + if (s != null) { + consumer.accept(s); + } + } else { + //noinspection unchecked + ((CompletableFuture) future).complete(s); + } + } + + public boolean isMatch(String response) { + if (consumer != null) { + if (isErrorResponse(response)) { + return true; + } else { + return text.startsWith("monitor") && response.startsWith("notification:"); + } + } else { + // currently no way to match responses to commands + return true; + } + } + + public boolean isConsumerCommand() { + return consumer != null; + } + + public static boolean isConsumerCommand(Command command) { + return command != null && command.isConsumerCommand(); + } + + public static boolean isConsumerResponse(String s) { + return s.startsWith("notification:"); + } + + public static boolean isErrorResponse(String s) { + return s.startsWith("error:"); + } +} diff --git a/at_client/src/main/java/org/atsign/client/connection/common/CommandQueue.java b/at_client/src/main/java/org/atsign/client/connection/common/CommandQueue.java new file mode 100644 index 00000000..32ce7f06 --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/connection/common/CommandQueue.java @@ -0,0 +1,152 @@ +package org.atsign.client.connection.common; + +import static org.atsign.client.connection.common.Command.isConsumerCommand; +import static org.atsign.client.connection.netty.NettyAtClientConnection.isPrompt; + +import java.util.Collection; +import java.util.Deque; +import java.util.Iterator; +import java.util.Spliterator; +import java.util.concurrent.ConcurrentLinkedDeque; +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +/** + * A queue of {@link Command} elements. This is used to hold pending commands, those + * commands which have been sent but no response has been received (or event commands + * where there will be a stream of responses). It is also used to hold queued commands + * in the event that the pending {@link CommandQueue} is full. + */ +public class CommandQueue implements Iterable { + + private final int capacity; + + private final Deque commands = new ConcurrentLinkedDeque<>(); + + private final Deque consumers = new ConcurrentLinkedDeque<>(); + + public CommandQueue(int capacity) { + this.capacity = capacity; + } + + public void clear() { + commands.clear(); + consumers.clear(); + } + + public int drain(CommandQueue queue) { + Command command; + int count = 0; + while ((command = queue.poll()) != null) { + commands.addFirst(command); + count++; + } + return count; + } + + public void removeIf(Predicate predicate) { + commands.removeIf(predicate); + } + + public boolean offer(Command command) { + if (command.isConsumerCommand() && hasConsumerCommand()) { + // TODO: allow replacement of existing command where the verb matches + return false; + } + if (commands.size() + 1 <= capacity) { + commands.addLast(command); + return true; + } else { + return false; + } + } + + public Command pop(String response) { + Command command = null; + if (isPrompt(response)) { + if (isConsumerCommand(commands.peek())) { + command = commands.pop(); + consumers.add(command); + } + } else if (Command.isConsumerResponse(response)) { + if (isConsumerCommand(commands.peek())) { + consumers.add(pollFirst(response)); + } + command = consumers.peek(); + } else { + command = pollFirst(response); + } + return command; + } + + public Command poll() { + return commands.pollFirst(); + } + + public Command peek() { + return commands.peekFirst(); + } + + public int size() { + return commands.size(); + } + + public boolean isEmpty() { + return commands.isEmpty(); + } + + public int getQueueCapacity() { + return capacity; + } + + public Collection pollTimedOut(long timeoutMillis) { + Collection list = commands.stream() + .filter(command -> command.isTimedOut(timeoutMillis)) + .collect(Collectors.toList()); + commands.removeAll(list); + return list; + } + + public Collection pollIsOnReady() { + Collection list = commands.stream() + .filter(command -> command.isOnReady()) + .collect(Collectors.toList()); + commands.removeAll(list); + return list; + } + + @Override + public Iterator iterator() { + return commands.iterator(); + } + + @Override + public void forEach(Consumer action) { + commands.forEach(action); + } + + @Override + public Spliterator spliterator() { + return commands.spliterator(); + } + + public boolean hasConsumerCommand() { + if (!consumers.isEmpty()) { + return true; + } + return commands.stream().filter(c -> c.isConsumerCommand()).findAny().orElse(null) != null; + } + + private Command pollFirst(String response) { + Iterator it = commands.iterator(); + while (it.hasNext()) { + Command command = it.next(); + if (command.isMatch(response)) { + it.remove(); + return command; + } + } + return null; + } +} diff --git a/at_client/src/main/java/org/atsign/client/connection/common/SimpleReconnectStrategy.java b/at_client/src/main/java/org/atsign/client/connection/common/SimpleReconnectStrategy.java new file mode 100644 index 00000000..a97c7d37 --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/connection/common/SimpleReconnectStrategy.java @@ -0,0 +1,66 @@ +package org.atsign.client.connection.common; + +import lombok.Builder; +import org.atsign.client.connection.api.ReconnectStrategy; + +import java.util.concurrent.TimeUnit; + +/** + * A default implementation based on a number of reconnection retries with a simple + * back-off strategy and re-resolve strategy. + */ +public class SimpleReconnectStrategy implements ReconnectStrategy { + + public static final long RECONNECT_RETRY_FOREVER = 0; + + public static final long DEFAULT_RECONNECT_PAUSE_MILLIS = TimeUnit.SECONDS.toMillis(1); + + private final long maxReconnectRetries; + private final int resolveEndpointFrequency; + private final long reconnectPauseMillis; + + private long connectFailureCount; + + @Builder + public SimpleReconnectStrategy(long maxReconnectRetries, int resolveEndpointFrequency, long reconnectPauseMillis) { + this.maxReconnectRetries = maxReconnectRetries; + this.resolveEndpointFrequency = resolveEndpointFrequency; + this.reconnectPauseMillis = reconnectPauseMillis; + } + + @Override + public boolean isReconnectSupported() { + return maxReconnectRetries == RECONNECT_RETRY_FOREVER || connectFailureCount <= maxReconnectRetries; + } + + @Override + public void onConnectFailure(Throwable ex) { + connectFailureCount++; + } + + @Override + public void onConnect() { + connectFailureCount = 0; + } + + @Override + public void onDisconnect(Throwable ex) {} + + @Override + public long getReconnectPauseMillis() { + if (connectFailureCount == 0) { + return 0; + } else { + return reconnectPauseMillis > 0 ? reconnectPauseMillis : DEFAULT_RECONNECT_PAUSE_MILLIS; + } + } + + @Override + public boolean isReresolveEndpoint() { + if (connectFailureCount == 0) { + return false; + } else { + return resolveEndpointFrequency > 0 && (connectFailureCount % resolveEndpointFrequency) == 0; + } + } +} diff --git a/at_client/src/main/java/org/atsign/client/connection/netty/NettyAtClientConnection.java b/at_client/src/main/java/org/atsign/client/connection/netty/NettyAtClientConnection.java new file mode 100644 index 00000000..5412a795 --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/connection/netty/NettyAtClientConnection.java @@ -0,0 +1,570 @@ +package org.atsign.client.connection.netty; + +import static java.util.concurrent.CompletableFuture.failedFuture; +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.atsign.client.util.Preconditions.checkNotNull; + +import java.io.IOException; +import java.time.Clock; +import java.util.Collection; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLException; + +import org.atsign.client.connection.api.AtClientConnection; +import org.atsign.client.connection.api.AtEndpointSupplier; +import org.atsign.client.connection.api.ReconnectStrategy; +import org.atsign.client.connection.common.Command; +import org.atsign.client.connection.common.CommandQueue; +import org.atsign.client.util.Preconditions; +import org.atsign.common.AtException; +import org.atsign.common.exceptions.AtOnReadyException; +import org.atsign.common.exceptions.AtSecondaryConnectException; +import org.atsign.common.exceptions.AtSecondaryNotFoundException; +import org.atsign.common.exceptions.AtTimeoutException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.netty.bootstrap.Bootstrap; +import io.netty.channel.*; +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.channel.socket.SocketChannel; +import io.netty.channel.socket.nio.NioSocketChannel; +import io.netty.handler.ssl.ClientAuth; +import io.netty.handler.ssl.JdkSslContext; +import io.netty.handler.ssl.SslContext; +import io.netty.handler.ssl.SslContextBuilder; +import io.netty.util.concurrent.DefaultThreadFactory; +import lombok.Builder; + +/** + * An implementation that uses Netty + */ +public class NettyAtClientConnection implements AtClientConnection { + + public static final long DEFAULT_COMMAND_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(2); + public static final long DEFAULT_HEARTBEAT_MILLIS = TimeUnit.SECONDS.toMillis(30); + public static final int DEFAULT_MAX_FRAME_LENGTH = 10230000; + public static final long DEFAULT_AWAIT_READY_MILLIS = TimeUnit.SECONDS.toMillis(1); + private final long heartbeatMillis; + + private enum Status { + + Unconnected, Connected, Readying, Ready, Disconnected, Closing, Closed; + + boolean isClosedOrClosing() { + return this == Closed || this == Closing; + } + + String toLowerCase() { + return name().toLowerCase(); + } + } + + private final AtomicReference status = new AtomicReference<>(Status.Unconnected); + + private final Logger log; + + private final AtEndpointSupplier endpointSupplier; + + private final Clock clock; + + private final AtomicReference endpoint = new AtomicReference<>(); + + private final int maxFrameLength; + private final SslContext sslContext; + private final EventLoopGroup group; + private final Bootstrap bootstrap; + private final NettyAtThreadFactory threadFactory; + private volatile Channel channel; + + private final ReconnectStrategy reconnectStrategy; + + private final CommandQueue pending; + private final CommandQueue queue; + + private final AtomicReference> onReadyConsumer = new AtomicReference<>(); + + private final long timeoutMillis; + + private final boolean isVerbose; + + private final AtomicReference closeException = new AtomicReference<>(); + + private final AtomicBoolean isForceReconnect = new AtomicBoolean(); + + private final ReentrantLock readyingLock = new ReentrantLock(); + + private volatile long lastReadMillis; + + /** + * Builder method for instantiating instances of a Netty based implementation of + * {@link AtClientConnection} + * + * @param endpoint A supplier that will be invoked prior to connection attempts, need to return + * host:port + * @param maxFrameLength Optional, defaults to {@link #DEFAULT_MAX_FRAME_LENGTH} + * @param sslContext Optional {@link SSLContext}, defaults to Netty default client context + * @param reconnect Optional implementation of {@link ReconnectStrategy}, defaults to + * {@link ReconnectStrategy#NONE} + * @param onReady Optional {@link Consumer} which will be invoked each time connection becomes + * ready. Useful for connection setup e.g. authentication + * @param threadFactory Optional {@link ThreadFactory}, defaults to {@link DefaultThreadFactory} + * @param timeoutMillis Optional timeout (in milliseconds) for each command that is sent. Defaults + * to {@link #DEFAULT_COMMAND_TIMEOUT_MILLIS} + * @param heartbeatMillis Optional frequency which controls if and when the connection sends noop + * commands when there has been no other activity (command responses, notifications) + * @param queueLimit Optional, defaults to 0. Defaults to {@link #DEFAULT_HEARTBEAT_MILLIS}. + * @param clock Optional implementation of {@link Clock}. Defaults to system clock. + * @param log Optional implementation of {@link Logger}. Defaults to classname. + */ + @Builder + protected NettyAtClientConnection(AtEndpointSupplier endpoint, + Integer maxFrameLength, + SSLContext sslContext, + ReconnectStrategy reconnect, + Consumer onReady, + ThreadFactory threadFactory, + Long timeoutMillis, + Long heartbeatMillis, + Integer queueLimit, + Long awaitReadyMillis, + Boolean isVerbose, + Clock clock, + Logger log) + throws AtException { + this.endpointSupplier = Preconditions.checkNotNull(endpoint, "endpoint is not set"); + this.maxFrameLength = defaultIfUnset(maxFrameLength, DEFAULT_MAX_FRAME_LENGTH); + this.reconnectStrategy = defaultIfNull(reconnect, ReconnectStrategy.NONE); + this.onReadyConsumer.set(defaultIfNull(onReady, c -> { + })); + this.pending = new CommandQueue(1); + this.queue = new CommandQueue(defaultIfUnset(queueLimit, pending.getQueueCapacity())); + this.isVerbose = isVerbose != null && isVerbose; + this.clock = defaultIfNull(clock, Clock.systemUTC()); + this.log = defaultIfNull(log, LoggerFactory.getLogger(getClass())); + this.threadFactory = new NettyAtThreadFactory(threadFactory); + // DO NOT increase the thread count, this is by design and is crucial to the thread safety + this.group = new NioEventLoopGroup(1, this.threadFactory); + this.sslContext = sslContext != null ? wrapSslContext(sslContext) : createDefaultSslContext(); + this.bootstrap = new Bootstrap() + .group(group) + .channel(NioSocketChannel.class) + .option(ChannelOption.SO_KEEPALIVE, true) + .handler(new SocketChannelChannelInitializer()); + + this.timeoutMillis = defaultIfUnset(timeoutMillis, DEFAULT_COMMAND_TIMEOUT_MILLIS); + group.scheduleAtFixedRate(this::checkForTimeouts, this.timeoutMillis, this.timeoutMillis, MILLISECONDS); + + this.heartbeatMillis = defaultIfUnset(heartbeatMillis, DEFAULT_HEARTBEAT_MILLIS); + group.scheduleAtFixedRate(this::sendHeartbeat, this.heartbeatMillis, this.heartbeatMillis, MILLISECONDS); + + try { + connect().get(this.timeoutMillis, MILLISECONDS); + awaitStatus(Status.Ready, defaultIfUnset(awaitReadyMillis, DEFAULT_AWAIT_READY_MILLIS)); + } catch (InterruptedException | ExecutionException | TimeoutException e) { + if (!reconnectStrategy.isReconnectSupported()) { + throw new AtSecondaryConnectException("connect failed (and retry is false) : " + e.getMessage(), e); + } + } + if (closeException.get() != null) { + throw closeException.get(); + } + } + + /** + * A builder for instantiating {@link NettyAtClientConnection} instances. + */ + public static class NettyAtClientConnectionBuilder { + // required for javadoc + }; + + @Override + public void send(String command, CompletableFuture future) { + if (threadFactory.isCurrentThreadOnReadyThread()) { + throw new AtOnReadyException("onReady is prohibited from invoking send, use sendSync"); + } else if (threadFactory.isCurrentThreadMyThread()) { + throw new RuntimeException("send on netty event thread is prohibited"); + } else { + group.execute(() -> send(new Command(command, future, clock.millis(), false))); + } + } + + @Override + public String sendSync(String command) throws ExecutionException, InterruptedException { + CompletableFuture future = new CompletableFuture<>(); + if (threadFactory.isCurrentThreadOnReadyThread()) { + send(new Command(command, future, clock.millis(), true)); + } else if (threadFactory.isCurrentThreadMyThread()) { + throw new ExecutionException("send on netty event thread is prohibited", null); + } else { + group.execute(() -> send(new Command(command, future, clock.millis(), false))); + } + return future.get(); + } + + @Override + public void send(String command, Consumer consumer, CompletableFuture future) { + if (threadFactory.isCurrentThreadOnReadyThread()) { + throw new AtOnReadyException("onReady is prohibited from invoking send, use sendSync"); + } else if (threadFactory.isCurrentThreadMyThread()) { + throw new RuntimeException("send on netty event thread is prohibited"); + } else { + group.execute(() -> send(new Command(command, future, consumer, clock.millis(), false))); + } + } + + @Override + public void sendSync(String command, Consumer consumer) throws ExecutionException, InterruptedException { + CompletableFuture future = new CompletableFuture<>(); + if (threadFactory.isCurrentThreadOnReadyThread()) { + send(new Command(command, future, consumer, clock.millis(), true)); + } else if (threadFactory.isCurrentThreadMyThread()) { + throw new ExecutionException("send on netty event thread is prohibited", null); + } else { + group.execute(() -> send(new Command(command, future, consumer, clock.millis(), true))); + } + future.get(); + } + + @Override + public AtClientConnection onReady(Consumer consumer) { + checkNotNull(consumer, "null"); + try { + readyingLock.lock(); + onReadyConsumer.set(consumer); + forceReconnect(); + } finally { + readyingLock.unlock(); + } + return this; + } + + @Override + public void close() { + if (!status.get().isClosedOrClosing()) { + close(new AtSecondaryConnectException("connection closed")); + } + } + + public int getPendingSize() { + return pending.size(); + } + + public int getQueuedSize() { + return queue.size(); + } + + public boolean isReady() { + return status.get() == Status.Ready; + } + + public void forceReconnect() { + if (channel != null) { + isForceReconnect.set(true); + channel.close(); + } + } + + private void close(AtException closeException) { + this.closeException.set(closeException); + status.set(Status.Closing); + if (channel != null) { + channel.close(); + } + group.shutdownGracefully(); + if (!pending.isEmpty() || !queue.isEmpty()) { + pending.forEach(command -> command.completeExceptionally(closeException)); + queue.forEach(command -> command.completeExceptionally(closeException)); + } + status.set(Status.Closed); + } + + private Future connect() { + try { + resolveEndpoint(); + } catch (Exception e) { + log.error("unabled to resolve endpoint"); + scheduleReconnect(); + return failedFuture(new RuntimeException("unable to resolve endpoint : " + e.getMessage(), e)); + } + String endpoint = this.endpoint.get(); + log.debug("bootstrap.connect({}, {})", toHost(endpoint), toPort(endpoint)); + ChannelFuture channelFuture = bootstrap.connect(toHost(endpoint), toPort(endpoint)); + channelFuture.addListener((ChannelFutureListener) this::onConnect); + return channelFuture; + } + + private void onConnect(ChannelFuture future) { + if (future.isSuccess()) { + channel = future.channel(); + log.info("connected to {}", endpoint); + status.set(Status.Connected); + reconnectStrategy.onConnect(); + } else { + log.warn("connect to {} failed : {}", endpoint, future.cause().getMessage()); + reconnectStrategy.onConnectFailure(future.cause()); + scheduleReconnect(); + } + } + + private void checkForTimeouts() { + checkForTimeout(queue, this.timeoutMillis, "timed out (in queue)"); + checkForTimeout(pending, this.timeoutMillis, "timed out (no response from server)"); + } + + private void checkForTimeout(CommandQueue commands, long timeoutMillis, String message) { + Collection expired = commands.pollTimedOut(clock.millis() - timeoutMillis); + if (!expired.isEmpty()) { + AtTimeoutException ex = new AtTimeoutException(message); + expired.forEach(x -> x.completeExceptionally(ex)); + if (commands == pending && sendNext()) { + log.info("sent queued command"); + } + } + } + + private void sendHeartbeat() { + if (isReady() && (clock.millis() - lastReadMillis) > heartbeatMillis) { + writeAndFlushCommand(new Command("noop:0", null, 0, false)); + } + } + + private void completeIsOnReadyExceptionally(CommandQueue commands, String message) { + Collection isOnReadyCommands = commands.pollIsOnReady(); + if (!isOnReadyCommands.isEmpty()) { + AtTimeoutException ex = new AtTimeoutException(message); + isOnReadyCommands.forEach(x -> x.completeExceptionally(ex)); + } + } + + private void resolveEndpoint() throws AtSecondaryNotFoundException { + if (endpoint.get() == null || reconnectStrategy.isReresolveEndpoint()) { + endpoint.set(endpointSupplier.get()); + } + } + + private void scheduleReconnect() { + if (status.get() == Status.Closed) { + return; + } + boolean isForceReconnect = this.isForceReconnect.getAndSet(false); + if (isForceReconnect || reconnectStrategy.isReconnectSupported()) { + pending.removeIf(Command::isOnReady); + log.debug("re-queued {} pending commands", queue.drain(pending)); + pending.clear(); + long delayMillis = isForceReconnect ? 0 : reconnectStrategy.getReconnectPauseMillis(); + log.debug("scheduling connect"); + group.schedule(this::connect, delayMillis, MILLISECONDS); + } else { + log.warn("reconnect retries exceeded, closing connection"); + close(new AtSecondaryConnectException("reconnect retries exceeded")); + } + } + + private void send(Command command) { + if (status.get().isClosedOrClosing()) { + command.completeExceptionally(new IllegalStateException("connection " + status.get().toLowerCase())); + } else if ((isReady() || threadFactory.isCurrentThreadOnReadyThread()) && pending.offer(command)) { + writeAndFlushCommand(command); + } else if (queue.offer(command)) { + log.info("{} command{} queued ({})", queue.size(), queue.size() > 1 ? "s" : "", getPendingStatus()); + } else { + command.completeExceptionally(new AtTimeoutException( + queue.getQueueCapacity() > 0 ? "queue is full" : "queue not enabled")); + } + } + + private String getPendingStatus() { + if (!pending.isEmpty()) { + return pending.size() == 1 ? "pending command" : pending.size() + " pending commands"; + } else { + return status.get().toLowerCase(); + } + } + + private void writeAndFlushCommand(Command command) { + if (command != null) { + ChannelFuture future = channel.writeAndFlush(command.toString()); + future.addListener((ChannelFutureListener) f -> onCommandWrite(command, f)); + } + } + + private void onCommandWrite(Command command, ChannelFuture future) { + if (future.isSuccess()) { + if (isVerbose) { + log.info("SENT: {}", command); + } else { + log.debug("SENT: {}", command); + } + if (command.isConsumerCommand()) { + // hack, until change which sends prompt after monitor + group.schedule(this::onPrompt, 100, MILLISECONDS); + } + } + } + + private boolean sendNext() { + Command command = queue.poll(); + if (command != null) { + send(command); + return true; + } else { + return false; + } + } + + private void onPrompt() { + if (status.get() == Status.Connected) { + log.debug("received prompt, readying..."); + status.set(Status.Readying); + threadFactory.newThread(createOnReadyRunnable()).start(); + } + Command command = pending.pop("@"); + if (command != null) { + command.complete(null); + } + } + + private Runnable createOnReadyRunnable() { + return () -> { + threadFactory.markCurrentThreadOnReadyThread(); + try { + readyingLock.lock(); + onReadyConsumer.get().accept(this); + } catch (AtOnReadyException e) { + log.error("onReady exception", e); + close(new AtSecondaryConnectException(e.getMessage())); + return; + } finally { + readyingLock.unlock(); + } + log.debug("ready"); + status.set(Status.Ready); + checkForTimeouts(); + if (sendNext()) { + log.info("sent queued command"); + } + threadFactory.clearCurrentThreadOnReadyThread(); + }; + } + + private void onResponse(String msg) { + if (isVerbose) { + log.info("RCVD: {}", msg); + } else { + log.debug("RCVD: {}", msg); + } + Command command = pending.pop(msg); + if (command != null) { + command.complete(msg); + } else { + log.error("no pending command : {}", msg); + } + if (sendNext()) { + log.info("sent queued command"); + } + } + + private class ResponseHandler extends SimpleChannelInboundHandler { + + @Override + protected void channelRead0(ChannelHandlerContext ctx, String msg) { + lastReadMillis = clock.millis(); + if (isPrompt(msg)) { + onPrompt(); + } else { + onResponse(msg); + } + } + + @Override + public void exceptionCaught(ChannelHandlerContext context, Throwable cause) { + log.error("exception in handler", cause); + } + + @Override + public void channelInactive(ChannelHandlerContext context) { + channel = null; + if (status.get().isClosedOrClosing()) { + log.debug("connection closed"); + } else { + completeIsOnReadyExceptionally(pending, "connection closed"); + if (isForceReconnect.get()) { + log.info("connection force disconnected"); + } else { + log.warn("connection unexpectedly unconnected"); + reconnectStrategy.onDisconnect(new IOException("connection closed")); + } + status.set(Status.Unconnected); + scheduleReconnect(); + } + } + } + + private static int toPort(String hostAndPort) { + return Integer.parseInt(hostAndPort.split(":")[1]); + } + + private static String toHost(String hostAndPort) { + return hostAndPort.split(":")[0]; + } + + private class SocketChannelChannelInitializer extends ChannelInitializer { + + @Override + protected void initChannel(SocketChannel ch) { + String endpoint = NettyAtClientConnection.this.endpoint.get(); + ChannelPipeline pipeline = ch.pipeline(); + pipeline.addLast(sslContext.newHandler(ch.alloc(), toHost(endpoint), toPort(endpoint))); + pipeline.addLast(new NettyAtResponseDecoder(maxFrameLength)); + pipeline.addLast(new NettyAtCommandEncoder()); + pipeline.addLast(new ResponseHandler()); + } + } + + private static SslContext createDefaultSslContext() { + try { + return SslContextBuilder.forClient().build(); + } catch (SSLException e) { + throw new RuntimeException("failed to create SSL context", e); + } + } + + private static SslContext wrapSslContext(SSLContext context) { + return new JdkSslContext(context, true, ClientAuth.NONE); + } + + private void awaitStatus(Status status, long timeoutMillis) throws InterruptedException { + long startMillis = clock.millis(); + while (clock.millis() < (startMillis + timeoutMillis)) { + // TODO: fix this + Thread.sleep(50); + if (this.status.get() == status) { + break; + } + } + } + + public static boolean isPrompt(String s) { + return s.startsWith("@") && s.endsWith("@"); + } + + public static T defaultIfNull(T o, T defaultValue) { + return o != null ? o : defaultValue; + } + + public static long defaultIfUnset(Long l, long defaultValue) { + return l != null ? l : defaultValue; + } + + public static int defaultIfUnset(Integer i, int defaultValue) { + return i != null ? i : defaultValue; + } + +} diff --git a/at_client/src/main/java/org/atsign/client/connection/netty/NettyAtCommandEncoder.java b/at_client/src/main/java/org/atsign/client/connection/netty/NettyAtCommandEncoder.java new file mode 100644 index 00000000..7a445b19 --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/connection/netty/NettyAtCommandEncoder.java @@ -0,0 +1,23 @@ +package org.atsign.client.connection.netty; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufUtil; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.MessageToByteEncoder; + +/** + * A Netty encoder which ensures that commands are terminated by a newline + */ +public class NettyAtCommandEncoder extends MessageToByteEncoder { + + private static final byte LF = (byte) '\n'; + + @Override + protected void encode(ChannelHandlerContext ctx, CharSequence msg, ByteBuf out) { + ByteBufUtil.writeUtf8(out, msg); + int len = msg.length(); + if (len == 0 || msg.charAt(len - 1) != '\n') { + out.writeByte(LF); + } + } +} diff --git a/at_client/src/main/java/org/atsign/client/connection/netty/NettyAtEndpointSupplier.java b/at_client/src/main/java/org/atsign/client/connection/netty/NettyAtEndpointSupplier.java new file mode 100644 index 00000000..d8ae2cc3 --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/connection/netty/NettyAtEndpointSupplier.java @@ -0,0 +1,60 @@ +package org.atsign.client.connection.netty; + +import org.atsign.client.connection.api.AtEndpointSupplier; +import org.atsign.common.AtSign; +import org.atsign.common.exceptions.AtSecondaryNotFoundException; + +import lombok.Builder; + +import javax.net.ssl.SSLContext; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.atsign.client.util.Preconditions.checkNotNull; + +/** + * An implementation that uses a {@link NettyAtClientConnection} to connect to the + * root / directory server and resolve the endpoint for a specific {@link AtSign} + */ +public class NettyAtEndpointSupplier implements AtEndpointSupplier { + + private final AtSign atsign; + + private final NettyAtClientConnection.NettyAtClientConnectionBuilder connectionBuilder; + + @Builder + public NettyAtEndpointSupplier(String rootUrl, AtSign atsign, Long timeoutMillis, SSLContext sslContext) { + this.atsign = checkNotNull(atsign, "atSign not set"); + String hostAndPort = defaultPort(checkNotNull(rootUrl, "rootUrl not set")); + this.connectionBuilder = NettyAtClientConnection.builder() + .endpoint(() -> hostAndPort) + .timeoutMillis(timeoutMillis) + .sslContext(sslContext); + } + + @Override + public String get() throws AtSecondaryNotFoundException { + try (NettyAtClientConnection connection = connectionBuilder.build()) { + return checkNotBlank(connection.sendSync(atsign.withoutPrefix())); + } catch (Exception e) { + throw new AtSecondaryNotFoundException("unable to resolve the endpoint for " + atsign, e); + } + } + + private static String checkNotBlank(String s) throws AtSecondaryNotFoundException { + if (s == null || s.isBlank() || "null".equals(s)) { + throw new AtSecondaryNotFoundException("unable to resolve the endpoint"); + } + return s; + } + + private static String defaultPort(String hostAndPort) { + Matcher matcher = Pattern.compile("([^:]+):(\\d+)").matcher(hostAndPort); + if (matcher.matches()) { + return hostAndPort; + } else { + return hostAndPort + ":" + 64; + } + } +} diff --git a/at_client/src/main/java/org/atsign/client/connection/netty/NettyAtResponseDecoder.java b/at_client/src/main/java/org/atsign/client/connection/netty/NettyAtResponseDecoder.java new file mode 100644 index 00000000..3df0f960 --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/connection/netty/NettyAtResponseDecoder.java @@ -0,0 +1,84 @@ +package org.atsign.client.connection.netty; + +import java.util.List; + +import io.netty.buffer.ByteBuf; +import io.netty.channel.ChannelHandlerContext; +import io.netty.handler.codec.ByteToMessageDecoder; +import io.netty.handler.codec.TooLongFrameException; +import io.netty.util.CharsetUtil; + +/** + * + */ +public final class NettyAtResponseDecoder extends ByteToMessageDecoder { + + private static final byte LF = (byte) '\n'; + private static final byte CR = (byte) '\r'; + private static final byte PROMPT = (byte) '@'; + private static final byte COLON = (byte) ':'; + + private final long maxFrameSize; + + public NettyAtResponseDecoder(long maxFrameSize) { + this.maxFrameSize = maxFrameSize; + } + + @Override + protected void decode(ChannelHandlerContext ctx, + ByteBuf in, + List out) { + + while (true) { + + if (in.readableBytes() > maxFrameSize) { + throw new TooLongFrameException("buffer exceeds maxFrameSize [" + maxFrameSize + "]"); + } + + int i; + if ((i = findPromptIndex(in, 0)) == 0) { + int j = findPromptIndex(in, i + 1); + if (j > i) { + out.add(in.readCharSequence(j - i + 1, CharsetUtil.UTF_8).toString()); + } else if (in.readableBytes() == 1 || findLinefeedIndex(in) > i) { + out.add(in.readCharSequence(1, CharsetUtil.UTF_8).toString()); + } else { + // this is possibly an authenticated prompt which is fragmented + return; + } + } else if ((i = findLinefeedIndex(in)) > -1) { + int skip = 1; + if (i > 0 && in.getByte(i - 1) == CR) { + i--; + skip++; + } + out.add(in.readCharSequence(i, CharsetUtil.UTF_8).toString()); + in.skipBytes(skip); + } else { + return; + } + } + } + + private static int findLinefeedIndex(ByteBuf in) { + for (int i = in.readerIndex(); i < in.writerIndex(); i++) { + if (in.getByte(i) == LF) { + return i - in.readerIndex(); + } + } + return -1; + } + + private static int findPromptIndex(ByteBuf in, int offset) { + for (int i = in.readerIndex() + offset; i < in.writerIndex(); i++) { + if (in.getByte(i) == LF || in.getByte(i) == COLON) { + return -1; + } + if (in.getByte(i) == PROMPT) { + return i - in.readerIndex(); + } + } + return -1; + } + +} diff --git a/at_client/src/main/java/org/atsign/client/connection/netty/NettyAtThreadFactory.java b/at_client/src/main/java/org/atsign/client/connection/netty/NettyAtThreadFactory.java new file mode 100644 index 00000000..b1414925 --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/connection/netty/NettyAtThreadFactory.java @@ -0,0 +1,49 @@ +package org.atsign.client.connection.netty; + +import io.netty.util.concurrent.DefaultThreadFactory; + +import java.util.concurrent.ThreadFactory; + +/** + * Wraps a thread factory (or the default netty thread factory) with one that sets thread locals. + * This way we can detect when a command is invoked as part of OnReady and prevent + * netty event thread from sending a command and then blocking on itself + */ +class NettyAtThreadFactory implements ThreadFactory { + + private final ThreadLocal isMyThread = ThreadLocal.withInitial(() -> false); + + private final ThreadLocal isOnReadyThread = ThreadLocal.withInitial(() -> false); + + private final ThreadFactory delegate; + + public NettyAtThreadFactory(ThreadFactory delegate) { + this.delegate = delegate != null ? delegate : new DefaultThreadFactory("netty"); + } + + @Override + public Thread newThread(Runnable r) { + return delegate.newThread(() -> { + isMyThread.set(true); + r.run(); + }); + } + + boolean isCurrentThreadMyThread() { + return isMyThread.get(); + } + + public boolean isCurrentThreadOnReadyThread() { + return isOnReadyThread.get(); + } + + public void markCurrentThreadOnReadyThread() { + isOnReadyThread.set(true); + Thread.currentThread().setName(Thread.currentThread().getName() + "(ready)"); + } + + public void clearCurrentThreadOnReadyThread() { + isOnReadyThread.set(null); + Thread.currentThread().setName(Thread.currentThread().getName().replace("(ready)", "")); + } +} diff --git a/at_client/src/main/java/org/atsign/client/connection/protocol/AtExceptions.java b/at_client/src/main/java/org/atsign/client/connection/protocol/AtExceptions.java new file mode 100644 index 00000000..2c0da9e9 --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/connection/protocol/AtExceptions.java @@ -0,0 +1,90 @@ +package org.atsign.client.connection.protocol; + +import org.atsign.client.connection.api.AtClientConnection; +import org.atsign.common.AtException; +import org.atsign.common.exceptions.*; +import org.atsign.common.exceptions.AtExceptions.AtInvalidSyntaxException; +import org.atsign.common.exceptions.AtExceptions.AtServerRuntimeException; + +import java.util.concurrent.ExecutionException; +import java.util.function.Consumer; + +/** + * Utilities for throwing and handling typed {@link AtException} instances + */ +public class AtExceptions { + + public static AtException toTypedException(String code, String text) { + if (AtServerRuntimeException.CODE.equals(code)) { + return new AtServerRuntimeException(text); + } else if (AtInvalidSyntaxException.CODE.equals(code)) { + return new AtInvalidSyntaxException(text); + } else if (AtBufferOverFlowException.CODE.equals(code)) { + return new AtBufferOverFlowException(text); + } else if (AtOutboundConnectionLimitException.CODE.equals(code)) { + return new AtOutboundConnectionLimitException(text); + } else if (AtSecondaryNotFoundException.CODE.equals(code)) { + return new AtSecondaryNotFoundException(text); + } else if (AtHandShakeException.CODE.equals(code)) { + return new AtHandShakeException(text); + } else if (AtUnauthorizedException.CODE.equals(code)) { + return new AtUnauthorizedException(text); + } else if (AtInternalServerError.CODE.equals(code)) { + return new AtInternalServerError(text); + } else if (AtInternalServerException.CODE.equals(code)) { + return new AtInternalServerException(text); + } else if (AtInboundConnectionLimitException.CODE.equals(code)) { + return new AtInboundConnectionLimitException(text); + } else if (AtBlockedConnectionException.CODE.equals(code)) { + return new AtBlockedConnectionException(text); + } else if (AtKeyNotFoundException.CODE.equals(code)) { + return new AtKeyNotFoundException(text); + } else if (AtInvalidAtKeyException.CODE.equals(code)) { + return new AtInvalidAtKeyException(text); + } else if (AtSecondaryConnectException.CODE.equals(code)) { + return new AtSecondaryConnectException(text); + } else if (AtIllegalArgumentException.CODE.equals(code)) { + return new AtIllegalArgumentException(text); + } else if (AtTimeoutException.CODE.equals(code)) { + return new AtTimeoutException(text); + } else if (AtServerIsPausedException.CODE.equals(code)) { + return new AtServerIsPausedException(text); + } else if (AtUnauthenticatedException.CODE.equals(code)) { + return new AtUnauthenticatedException(text); + } + + return new AtNewErrorCodeWhoDisException(code, text); + } + + /** + * A Command / Runnable that may throw exceptions. + */ + public interface AtClientConnectionCommand { + void run(AtClientConnection connection) throws AtException, ExecutionException, InterruptedException; + } + + /** + * Wraps an {@link AtClientConnectionCommand}, typically an + * {@link AtClientConnection#onReady(Consumer)} + * command, such that AtExceptions are caught and rethrown as {@link AtOnReadyException}. + * NOTE: {@link AtOnReadyException}s are regarded as fatal, i.e. the {@link AtClientConnection} is + * unusable. + * + * @param command A command/runnable thatmay throw exceptions. + * @return a consumer that is designed to be passed as an argument to + * {@link AtClientConnection#onReady(Consumer)} + */ + public static Consumer throwOnReadyException(AtClientConnectionCommand command) { + return connection -> { + try { + command.run(connection); + } catch (AtException e) { + // AtOnReadyExceptions are fatal for the connection + throw new AtOnReadyException(e.getMessage(), e); + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + }; + } + +} diff --git a/at_client/src/main/java/org/atsign/client/connection/protocol/Authentication.java b/at_client/src/main/java/org/atsign/client/connection/protocol/Authentication.java new file mode 100644 index 00000000..44dace2d --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/connection/protocol/Authentication.java @@ -0,0 +1,104 @@ +package org.atsign.client.connection.protocol; + +import static org.atsign.client.connection.protocol.AtExceptions.throwOnReadyException; +import static org.atsign.client.connection.protocol.Data.matchDataStringNoWhitespace; +import static org.atsign.client.connection.protocol.Data.matchDataSuccess; +import static org.atsign.client.connection.protocol.Error.throwExceptionIfError; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.concurrent.ExecutionException; +import java.util.function.Consumer; + +import org.atsign.client.api.AtKeys; +import org.atsign.client.connection.api.AtClientConnection; +import org.atsign.client.util.EncryptionUtil; +import org.atsign.common.AtException; +import org.atsign.common.AtSign; +import org.atsign.common.VerbBuilders; +import org.atsign.common.exceptions.AtEncryptionException; +import org.atsign.common.exceptions.AtUnauthenticatedException; + +/** + * Utility methods for handling authentication within the AtSign protocol + */ +public class Authentication { + + public static Consumer pkamAuthenticator(AtSign atSign, AtKeys keys) { + return throwOnReadyException(connection -> authenticateWithPkam(connection, atSign, keys)); + } + + public static void authenticateWithPkam(AtClientConnection connection, AtSign atSign, AtKeys keys) + throws AtException { + try { + + // send a from command and expect to receive a challenge + String fromCommand = VerbBuilders.fromCommandBuilder().atSign(atSign).build(); + String fromResponse = connection.sendSync(fromCommand); + String challenge = matchDataStringNoWhitespace(throwExceptionIfError(fromResponse)); + + // send a pkam command with the signed challenge + String signature = EncryptionUtil.signSHA256RSA(challenge, keys.getApkamPrivateKey()); + VerbBuilders.PkamCommandBuilder pkamCommandBuilder = VerbBuilders.pkamCommandBuilder(); + if (keys.hasEnrollmentId()) { + pkamCommandBuilder.signingAlgo(EncryptionUtil.SIGNING_ALGO_RSA); + pkamCommandBuilder.hashingAlgo(EncryptionUtil.HASHING_ALGO_SHA256); + pkamCommandBuilder.enrollmentId(keys.getEnrollmentId()); + } + pkamCommandBuilder.digest(signature); + String pkamCommand = pkamCommandBuilder.build(); + String pkamResponse = connection.sendSync(pkamCommand); + + // verify that pkam has succeeded + matchDataSuccess(throwExceptionIfError(pkamResponse)); + } catch (RuntimeException | ExecutionException | InterruptedException e) { + throw new AtUnauthenticatedException("PKAM command failed : " + e.getMessage()); + } + } + + public static void authenticateWithCram(AtClientConnection connection, AtSign atSign, String cramSecret) + throws AtException { + try { + + // send a from command and expect to receive a challenge + String fromCommand = VerbBuilders.fromCommandBuilder().atSign(atSign).build(); + String fromResponse = connection.sendSync(fromCommand); + String challenge = matchDataStringNoWhitespace(throwExceptionIfError(fromResponse)); + + // send a cram command + String cramDigest = createDigest(cramSecret, challenge); + String cramCommand = VerbBuilders.cramCommandBuilder().digest(cramDigest).build(); + String cramResponse = connection.sendSync(cramCommand); + + // verify that cram succeeded + matchDataSuccess(throwExceptionIfError(cramResponse)); + + } catch (RuntimeException | ExecutionException | InterruptedException e) { + throw new AtUnauthenticatedException("CRAM command failed : " + e.getMessage()); + } + } + + private static String createDigest(String cramSecret, String challenge) throws AtEncryptionException { + try { + String digestInput = cramSecret + challenge; + byte[] digestInputBytes = digestInput.getBytes(StandardCharsets.UTF_8); + byte[] digest = MessageDigest.getInstance("SHA-512").digest(digestInputBytes); + return bytesToHex(digest); + } catch (RuntimeException | NoSuchAlgorithmException e) { + throw new AtEncryptionException("failed to generate cramDigest", e); + } + } + + private static final char[] HEX_ARRAY = "0123456789abcdef".toCharArray(); + + public static String bytesToHex(byte[] bytes) { + char[] hexChars = new char[bytes.length * 2]; + for (int j = 0; j < bytes.length; j++) { + int v = bytes[j] & 0xFF; + hexChars[j * 2] = HEX_ARRAY[v >>> 4]; + hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F]; + } + return new String(hexChars); + } +} diff --git a/at_client/src/main/java/org/atsign/client/connection/protocol/Data.java b/at_client/src/main/java/org/atsign/client/connection/protocol/Data.java new file mode 100644 index 00000000..ccb84969 --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/connection/protocol/Data.java @@ -0,0 +1,117 @@ +package org.atsign.client.connection.protocol; + +import com.fasterxml.jackson.core.JsonProcessingException; +import org.atsign.common.Json; +import org.atsign.common.Metadata; +import org.atsign.common.response_models.LookupResponse; + +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * Utilities for handling data responses in the AtSign protocol + */ +public class Data { + + /** + * models server response string which is non-empty JSON map + */ + protected static final Pattern DATA_JSON_NON_EMPTY_MAP = Pattern.compile("data:(\\{.+})"); + + /** + * models server response string which is JSON map + */ + protected static final Pattern DATA_JSON_MAP = Pattern.compile("data:(\\{.*})"); + + /** + * models server response string which is non-empty JSON list + */ + protected static final Pattern DATA_JSON_LIST = Pattern.compile("data:(\\[.*])"); + + /** + * models server response string which is integer + */ + protected static final Pattern DATA_INT = Pattern.compile("data:(-?\\d+)"); + + /** + * models server response string containing no whitespace + */ + public static final Pattern DATA_NON_WHITESPACE = Pattern.compile("data:(\\S+)"); + + /** + * models server data response + */ + public static final Pattern DATA = Pattern.compile("data:(.+)"); + + /** + * models server response string containing no whitespace + */ + public static final Pattern DATA_SUCCESS = Pattern.compile("data:(success)"); + + public static String matchDataSuccess(String input) { + return Responses.match(input, DATA_SUCCESS); + } + + public static String matchDataStringNoWhitespace(String input) { + return Responses.match(input, DATA_NON_WHITESPACE); + } + + public static String matchData(String input) { + return Responses.match(input, DATA); + } + + public static int matchDataInt(String input) { + return Responses.match(input, DATA_INT, Integer::parseInt); + } + + public static List matchDataJsonList(String input) { + return Responses.match(input, DATA_JSON_LIST, Responses::decodeJsonList); + } + + public static List matchDataJsonListOfStrings(String input) { + return Responses.match(input, DATA_JSON_LIST, Responses::decodeJsonListOfStrings); + } + + public static Map matchDataJsonMapOfStrings(String input, boolean allowEmpty) { + return Responses.match(input, allowEmpty ? DATA_JSON_MAP : DATA_JSON_NON_EMPTY_MAP, + Responses::decodeJsonMapOfStrings); + } + + public static Map matchDataJsonMapOfObjects(String input, boolean allowEmpty) { + return Responses.match(input, allowEmpty ? DATA_JSON_MAP : DATA_JSON_NON_EMPTY_MAP, + Responses::decodeJsonMapOfObjects); + } + + public static Map matchDataJsonMapOfStrings(String input) { + return matchDataJsonMapOfStrings(input, false); + } + + public static Map matchDataJsonMapOfObjects(String input) { + return matchDataJsonMapOfObjects(input, false); + } + + public static LookupResponse matchLookupResponse(String s) { + return Responses.match(s, DATA_JSON_NON_EMPTY_MAP, Data::toLookupResponse); + } + + public static LookupResponse toLookupResponse(String s) { + try { + return Json.MAPPER.readValue(s, LookupResponse.class); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + public static Metadata matchMetadata(String s) { + return Responses.match(s, DATA_JSON_NON_EMPTY_MAP, Data::toMetaData); + } + + public static Metadata toMetaData(String s) { + try { + return Json.MAPPER.readValue(s, Metadata.class); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } +} diff --git a/at_client/src/main/java/org/atsign/client/connection/protocol/Enroll.java b/at_client/src/main/java/org/atsign/client/connection/protocol/Enroll.java new file mode 100644 index 00000000..3b015ac6 --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/connection/protocol/Enroll.java @@ -0,0 +1,322 @@ +package org.atsign.client.connection.protocol; + +import static org.atsign.client.connection.protocol.Data.*; +import static org.atsign.client.connection.protocol.Error.throwExceptionIfError; +import static org.atsign.client.util.EncryptionUtil.*; +import static org.atsign.client.util.EnrollmentId.createEnrollmentId; +import static org.atsign.client.util.Preconditions.checkNotNull; +import static org.atsign.common.VerbBuilders.EnrollParameters.ENCRYPTED_APKAM_SYMMETRIC_KEY; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.stream.Collectors; + +import org.atsign.client.api.AtKeyNames; +import org.atsign.client.api.AtKeys; +import org.atsign.client.connection.api.AtClientConnection; +import org.atsign.client.util.EnrollmentId; +import org.atsign.common.AtException; +import org.atsign.common.AtSign; +import org.atsign.common.VerbBuilders; + +/** + * Atsign Protocol utilitiy code that relates to onboarding and enrolling atsigns. + */ +public class Enroll { + + /** + * Performs the onboarding workflow which sets up the manage keys for an atserver. + * + * @param connection A connection to an atserver command interface + * @param atSign The AtSign that corresponds to the connection + * @param keys The {@link AtKeys} + * @param cramSecret + * @param appName + * @param deviceName + * @param deleteCramKey + * @return a new {@link AtKeys} instance that has the enrollment id set + * @throws AtException + */ + public static AtKeys onboard(AtClientConnection connection, + AtSign atSign, + AtKeys keys, + String cramSecret, + String appName, + String deviceName, + boolean deleteCramKey) + throws AtException { + try { + + // verify that the connection is connected to the atsigns atserver + String scanCommand = VerbBuilders.scanCommandBuilder().build(); + String scanResponse = throwExceptionIfError(connection.sendSync(scanCommand)); + List rawKeys = matchDataJsonListOfStrings(scanResponse); + if (!rawKeys.contains("signing_publickey" + atSign)) { + throw new IllegalStateException("not connected to the atsign's at server"); + } + + // authenticate with CRAM + Authentication.authenticateWithCram(connection, atSign, cramSecret); + + // send an enroll request which should automatically be approved after CRAM authentication + String requestCommand = VerbBuilders.enrollCommandBuilder() + .operation(VerbBuilders.EnrollOperation.request) + .appName(appName) + .deviceName(deviceName) + .apkamPublicKey(keys.getApkamPublicKey()) + .build(); + String requestResponse = connection.sendSync(requestCommand); + Map response = matchDataJsonMapOfStrings(throwExceptionIfError(requestResponse)); + checkStatus(response, "approved"); + + // update the AtKeys with the enrollment id + keys = keys.toBuilder() + .enrollmentId(createEnrollmentId(response.get("enrollmentId"))) + .build(); + + // authenticate with PKAM + Authentication.authenticateWithPkam(connection, atSign, keys); + + // explicitly store the public encryption key in the atserver + String updateCommand = VerbBuilders.updateCommandBuilder() + .sharedBy(atSign) + .keyName(AtKeyNames.PUBLIC_ENCRYPT) + .isPublic(true) + .value(keys.getEncryptPublicKey()) + .build(); + String updateResponse = connection.sendSync(updateCommand); + matchDataInt(throwExceptionIfError(updateResponse)); + + if (deleteCramKey) { + deleteCramSecret(connection); + } + + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + return keys; + } + + public static void deleteCramSecret(AtClientConnection connection) throws AtException { + Keys.deleteKey(connection, AtKeyNames.PRIVATE_AT_SECRET); + } + + public static String otp(AtClientConnection connection) throws AtException { + try { + + // send otp command + String otpCommand = VerbBuilders.otpCommandBuilder().build(); + String otpResponse = connection.sendSync(otpCommand); + + // verify that response is an OTP + return Data.matchDataStringNoWhitespace(throwExceptionIfError(otpResponse)); + + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + } + + public static AtKeys enroll(AtClientConnection connection, + AtSign atSign, + AtKeys keys, + String otp, + String appName, + String deviceName, + Map namespaces) + throws Exception { + + checkNotNull(keys.getApkamPublicKey(), "apkam public key not set"); + checkNotNull(keys.getApkamSymmetricKey(), "apkam symmetric key not set"); + + // lookup the public encryption key for the atserver's atsign, this will have been set during onboarding + String lookupCommand = VerbBuilders.lookupCommandBuilder() + .keyName(AtKeyNames.PUBLIC_ENCRYPT) + .sharedBy(atSign) + .build(); + String lookupResponse = connection.sendSync(lookupCommand); + String publicKey = matchDataStringNoWhitespace(throwExceptionIfError(lookupResponse)); + + // send an enroll request and verify that the status is pending + String requestCommand = VerbBuilders.enrollCommandBuilder() + .operation(VerbBuilders.EnrollOperation.request) + .appName(appName) + .deviceName(deviceName) + .apkamPublicKey(keys.getApkamPublicKey()) + .apkamSymmetricKey(rsaEncryptToBase64(keys.getApkamSymmetricKey(), publicKey)) + .otp(otp) + .namespaces(namespaces) + .build(); + String requestResponse = connection.sendSync(requestCommand); + Map map = matchDataJsonMapOfStrings(throwExceptionIfError(requestResponse)); + checkStatus(map, "pending"); + + // return a copy of the provided AtKeys with the public encryption key and enrollment id set + return keys.toBuilder() + .encryptPublicKey(publicKey) + .enrollmentId(EnrollmentId.createEnrollmentId(map.get("enrollmentId"))) + .build(); + } + + public static AtKeys complete(AtClientConnection connection, AtSign atSign, AtKeys keys) throws AtException { + + // attempt to authenticate with PKAM, this will succeed once the enroll request is approved + Authentication.authenticateWithPkam(connection, atSign, keys); + + // Use the keys:get command to obtain the private encryption key and self encryption key + String selfEncryptKey = keysGetSelfEncryptKey(connection, atSign, keys); + String encryptPrivateKey = keysGetEncryptPrivateKey(connection, atSign, keys); + + // return a copy of the provided AtKeys with the private encryption key and self encryption key set + return keys.toBuilder() + .selfEncryptKey(selfEncryptKey) + .encryptPrivateKey(encryptPrivateKey) + .build(); + + } + + public static List list(AtClientConnection connection, String status) throws AtException { + try { + + // get the list of enrollment requests that have the status + String listCommand = VerbBuilders.enrollCommandBuilder() + .operation(VerbBuilders.EnrollOperation.list) + .status(status) + .build(); + String listResponse = connection.sendSync(listCommand); + Map map = matchDataJsonMapOfObjects(throwExceptionIfError(listResponse), true); + + // return the list enrollment ids, extracted from the keys + return map.keySet().stream() + .map(Enroll::inferEnrollmentId) + .collect(Collectors.toList()); + + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + } + + public static void approve(AtClientConnection connection, AtKeys keys, EnrollmentId enrollmentId) throws AtException { + try { + + // fetch the request and decrypt the apkam symmetric key that will be used encrypt the shared keys + String fetchCommand = VerbBuilders.enrollCommandBuilder() + .operation(VerbBuilders.EnrollOperation.fetch) + .enrollmentId(enrollmentId) + .build(); + String fetchResponse = connection.sendSync(fetchCommand); + Map request = matchDataJsonMapOfObjects(throwExceptionIfError(fetchResponse)); + checkStatus(request, "pending"); + String encryptedApkamSymmetricKey = (String) request.get(ENCRYPTED_APKAM_SYMMETRIC_KEY); + String key = rsaDecryptFromBase64(encryptedApkamSymmetricKey, keys.getEncryptPrivateKey()); + + // encrypt the private encryption key and the self encryption key with the apkam symmetric key + String privateKeyIv = generateRandomIvBase64(16); + String encryptPrivateKey = aesEncryptToBase64(keys.getEncryptPrivateKey(), key, privateKeyIv); + String selfEncryptKeyIv = generateRandomIvBase64(16); + String selfEncryptKey = aesEncryptToBase64(keys.getSelfEncryptKey(), key, selfEncryptKeyIv); + + // approve the request + String approveCommand = VerbBuilders.enrollCommandBuilder() + .operation(VerbBuilders.EnrollOperation.approve) + .enrollmentId(enrollmentId) + .encryptPrivateKey(encryptPrivateKey) + .encryptPrivateKeyIv(privateKeyIv) + .selfEncryptKey(selfEncryptKey) + .selfEncryptKeyIv(selfEncryptKeyIv) + .build(); + String approveResponse = connection.sendSync(approveCommand); + + // check the response + Map response = matchDataJsonMapOfStrings(throwExceptionIfError(approveResponse)); + checkStatus(response, "approved"); + + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + + } + + public static void deny(AtClientConnection connection, EnrollmentId enrollmentId) throws Exception { + singleArgEnrollAction(connection, "deny", enrollmentId, "denied"); + } + + public static void revoke(AtClientConnection connection, EnrollmentId enrollmentId) throws Exception { + singleArgEnrollAction(connection, "revoke", enrollmentId, "revoked"); + } + + public static void unrevoke(AtClientConnection connection, EnrollmentId enrollmentId) throws Exception { + singleArgEnrollAction(connection, "unrevoke", enrollmentId, "approved"); + } + + public static void delete(AtClientConnection connection, EnrollmentId enrollmentId) throws Exception { + singleArgEnrollAction(connection, "delete", enrollmentId, "deleted"); + } + + private static void singleArgEnrollAction(AtClientConnection connection, + String action, + EnrollmentId enrollmentId, + String expectedStatus) + throws Exception { + String actionCommand = VerbBuilders.enrollCommandBuilder() + .operation(VerbBuilders.EnrollOperation.valueOf(action)) + .enrollmentId(enrollmentId) + .build(); + String actionResponse = connection.sendSync(actionCommand); + Map map = matchDataJsonMapOfStrings(actionResponse); + checkStatus(map, expectedStatus); + } + + private static EnrollmentId inferEnrollmentId(String key) { + int endIndex = key.indexOf('.'); + if (key.contains("__manage") && endIndex > 0) { + return EnrollmentId.createEnrollmentId(key.substring(0, endIndex)); + } else { + throw new RuntimeException(key + " doesn't match expected enrollment key pattern"); + } + } + + private static void checkStatus(Map map, String expectedStatus) { + String actualStatus = (String) map.get("status"); + if (!Objects.equals(actualStatus, expectedStatus)) { + throw new RuntimeException("status is " + actualStatus); + } + } + + private static String keysGetSelfEncryptKey(AtClientConnection connection, AtSign atSign, AtKeys keys) + throws AtException { + String keyName = keys.getEnrollmentId() + "." + "default_self_enc_key" + ".__manage" + atSign; + return keysGet(connection, keyName, keys.getApkamSymmetricKey()); + } + + private static String keysGetEncryptPrivateKey(AtClientConnection connection, AtSign atSign, AtKeys keys) + throws AtException { + String keyName = keys.getEnrollmentId() + "." + "default_enc_private_key" + ".__manage" + atSign; + return keysGet(connection, keyName, keys.getApkamSymmetricKey()); + } + + private static String keysGet(AtClientConnection connection, String keyName, String keyBase64) throws AtException { + try { + + // send a key:get command + String keysGetCommand = VerbBuilders.keysCommandBuilder() + .operation(VerbBuilders.KeysOperation.get) + .keyName(keyName) + .build(); + String keyGetResponse = connection.sendSync(keysGetCommand); + + // unmarshall the encrypted value and the encryption iv that was used + Map map = matchDataJsonMapOfStrings(Error.throwExceptionIfError(keyGetResponse)); + String encryptedKey = map.get("value"); + String iv = map.get("iv"); + + // return the decrypted value + return aesDecryptFromBase64(encryptedKey, keyBase64, iv); + + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/at_client/src/main/java/org/atsign/client/connection/protocol/Error.java b/at_client/src/main/java/org/atsign/client/connection/protocol/Error.java new file mode 100644 index 00000000..f7b227ab --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/connection/protocol/Error.java @@ -0,0 +1,50 @@ +package org.atsign.client.connection.protocol; + +import static org.atsign.client.connection.protocol.AtExceptions.toTypedException; +import static org.atsign.client.util.Preconditions.checkNotNull; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.atsign.common.AtException; + +/** + * Utility methods for handling error responses in the AtSign protocol + */ +public class Error { + + /** + * models server data response + */ + public static final Pattern ERROR = Pattern.compile("error:(.+)"); + + public static String matchError(String input) { + return Responses.match(input, ERROR); + } + + /** + * Utility method that can be used to wrap a response and throw a typed + * {@link AtException} is the response represents an error. + * + * @param response a response string from an AtSign command interface + * @return the response parameter if the response is NOT an error + * @throws AtException a typed exception if the response is an error + */ + public static String throwExceptionIfError(String response) throws AtException { + AtException ex = getAtExceptionIfError(response); + if (ex != null) { + throw ex; + } + return response; + } + + public static AtException getAtExceptionIfError(String response) throws AtException { + checkNotNull(response); + Matcher matcher = Pattern.compile("error:(AT\\d+)([^:]*):\\s*(.+)").matcher(response); + if (matcher.matches()) { + return toTypedException(matcher.group(1), matcher.group(3)); + } + return null; + } + +} diff --git a/at_client/src/main/java/org/atsign/client/connection/protocol/Keys.java b/at_client/src/main/java/org/atsign/client/connection/protocol/Keys.java new file mode 100644 index 00000000..b77f1867 --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/connection/protocol/Keys.java @@ -0,0 +1,82 @@ +package org.atsign.client.connection.protocol; + +import static org.atsign.client.connection.protocol.Data.*; +import static org.atsign.client.connection.protocol.Error.throwExceptionIfError; +import static org.atsign.common.VerbBuilders.LookupOperation.meta; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutionException; + +import org.atsign.client.api.AtKeyNames; +import org.atsign.client.connection.api.AtClientConnection; +import org.atsign.common.AtException; +import org.atsign.common.Keys.AtKey; +import org.atsign.common.Metadata; +import org.atsign.common.VerbBuilders; + +import lombok.extern.slf4j.Slf4j; + +/** + * Atsign Protocol utility code that relates to keys (the records) that are managed by an + * atserver. + * + */ +@Slf4j +public class Keys { + + public static void deleteKey(AtClientConnection connection, String rawKey) throws AtException { + try { + + // send a delete command + String deleteCommand = VerbBuilders.deleteCommandBuilder().rawKey(rawKey).build(); + String deleteResponse = connection.sendSync(deleteCommand); + + // verify command succeeded + Data.matchDataInt(throwExceptionIfError(deleteResponse)); + + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + } + + public static void deleteKey(AtClientConnection conn, AtKey key) throws AtException { + deleteKey(conn, key.rawKey()); + } + + public static List getKeys(AtClientConnection conn, String regex, boolean fetchMetadata) throws AtException { + + try { + + // send scan command and decode response + String scanCommand = VerbBuilders.scanCommandBuilder().regex(regex).showHidden(true).build(); + String scanResponse = conn.sendSync(scanCommand); + List keyNames = matchDataJsonListOfStrings(throwExceptionIfError(scanResponse)); + + // build typed key instances from each key name (optionally looking up metadata) + List atKeys = new ArrayList<>(); + for (String keyName : keyNames) { + if (!AtKeyNames.isManagementKeyName(keyName)) { + Metadata metadata = fetchMetadata ? fetchMetadata(conn, keyName) : null; + AtKey atKey = org.atsign.common.Keys.keyBuilder() + .rawKey(keyName) + .metadata(metadata) + .build(); + atKeys.add(atKey); + } + } + + return atKeys; + + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + } + + private static Metadata fetchMetadata(AtClientConnection conn, String keyName) + throws ExecutionException, InterruptedException, AtException { + String llookupCommand = VerbBuilders.llookupCommandBuilder().operation(meta).rawKey(keyName).build(); + String llookupResponse = conn.sendSync(llookupCommand); + return matchMetadata(throwExceptionIfError(llookupResponse)); + } +} diff --git a/at_client/src/main/java/org/atsign/client/connection/protocol/Notifications.java b/at_client/src/main/java/org/atsign/client/connection/protocol/Notifications.java new file mode 100644 index 00000000..d64fa8d2 --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/connection/protocol/Notifications.java @@ -0,0 +1,104 @@ +package org.atsign.client.connection.protocol; + +import static org.atsign.client.api.AtEvents.AtEventType.*; +import static org.atsign.client.connection.protocol.AtExceptions.throwOnReadyException; +import static org.atsign.client.connection.protocol.Authentication.authenticateWithPkam; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.function.Consumer; +import java.util.regex.Pattern; + +import lombok.extern.slf4j.Slf4j; +import org.atsign.client.api.AtEvents; +import org.atsign.client.api.AtKeys; +import org.atsign.client.connection.api.AtClientConnection; +import org.atsign.common.AtException; +import org.atsign.common.AtSign; + +/** + * Utility methods for managing notifications within the AtSign protocol + */ +public class Notifications { + + /** + * models server response string which is non-empty JSON map + */ + protected static final Pattern NOTIFICATION_JSON_NON_EMPTY_MAP = Pattern.compile("notification:\\s*(\\{.+})"); + + public static Consumer monitor(AtSign atSign, AtKeys keys, Consumer consumer) { + return throwOnReadyException(connection -> monitor(connection, atSign, keys, consumer)); + } + + public static void monitor(AtClientConnection connection, AtSign atSign, AtKeys keys, Consumer consumer) + throws AtException { + try { + + // authenticate + authenticateWithPkam(connection, atSign, keys); + + // send monitor command + connection.sendSync("monitor", consumer); + + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + } + + public static Map matchNotification(String s) { + return Responses.match(s, NOTIFICATION_JSON_NON_EMPTY_MAP, Responses::decodeJsonMapOfObjects); + } + + /** + * A {@link Consumer} that "bridges" to the {@link AtEvents.AtEventBus} api. This is intended + * to be used for invoking {@link AtClientConnection#send(String, Consumer, CompletableFuture)} + * + */ + @Slf4j + public static class EventBusBridge implements Consumer { + + private final AtEvents.AtEventBus eventBus; + + private final AtSign atSign; + + public EventBusBridge(AtEvents.AtEventBus eventBus, AtSign atSign) { + this.eventBus = eventBus; + this.atSign = atSign; + } + + @Override + public void accept(String s) { + try { + Map eventData = matchNotification(s); + AtEvents.AtEventType eventType = toEventType(eventData); + if (eventType == monitorException) { + eventData.put("key", "__monitorException__"); + eventData.put("value", s); + eventData.put("exception", "unknown notification operation '" + eventData.get("operation") + "'"); + } + eventBus.publishEvent(eventType, eventData); + } catch (Exception e) { + log.error("unexpected exception processing : {}", s, e); + } + } + + private AtEvents.AtEventType toEventType(Map eventData) { + String id = (String) eventData.get("id"); + String operation = (String) eventData.get("operation"); + if ("-1".equals(id)) { + return statsNotification; + } else if ("update".equals(operation)) { + String key = (String) eventData.get("key"); + if (key.startsWith(atSign + ":shared_key@")) { + return sharedKeyNotification; + } else { + return updateNotification; + } + } else if ("delete".equals(operation)) { + return deleteNotification; + } + return monitorException; + } + } +} diff --git a/at_client/src/main/java/org/atsign/client/connection/protocol/PublicKeys.java b/at_client/src/main/java/org/atsign/client/connection/protocol/PublicKeys.java new file mode 100644 index 00000000..68000c28 --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/connection/protocol/PublicKeys.java @@ -0,0 +1,109 @@ +package org.atsign.client.connection.protocol; + +import static org.atsign.client.connection.protocol.Data.matchDataInt; +import static org.atsign.client.connection.protocol.Data.matchLookupResponse; +import static org.atsign.client.connection.protocol.Error.throwExceptionIfError; +import static org.atsign.client.util.EncryptionUtil.signSHA256RSA; +import static org.atsign.common.VerbBuilders.LookupOperation.all; + +import java.util.concurrent.ExecutionException; + +import org.atsign.client.api.AtKeys; +import org.atsign.client.connection.api.AtClientConnection; +import org.atsign.common.AtException; +import org.atsign.common.AtSign; +import org.atsign.common.Keys.PublicKey; +import org.atsign.common.Metadata; +import org.atsign.common.VerbBuilders; +import org.atsign.common.options.GetRequestOptions; +import org.atsign.common.response_models.LookupResponse; + +/** + * Atsign protocol utility code that relates to "public keys" + * + */ +public class PublicKeys { + + public static String get(AtClientConnection conn, AtSign atSign, PublicKey key, GetRequestOptions options) + throws AtException { + if (atSign.equals(key.sharedBy())) { + return getSharedByMe(conn, key); + } else { + return getSharedByOther(conn, key, options); + } + } + + public static String getSharedByMe(AtClientConnection conn, PublicKey publicKey) throws AtException { + try { + + // send a local lookup command and decode the response + String llookupCommand = VerbBuilders.llookupCommandBuilder() + .key(publicKey) + .operation(all) + .build(); + String llookupResponse = conn.sendSync(llookupCommand); + LookupResponse response = matchLookupResponse(throwExceptionIfError(llookupResponse)); + + // set isCached in metadata + if (response.key.contains("cached:")) { + Metadata metadata = response.metaData.toBuilder().isCached(true).build(); + publicKey.overwriteMetadata(metadata); + } + + // return value + return response.data; + + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + } + + public static String getSharedByOther(AtClientConnection conn, PublicKey publicKey, GetRequestOptions options) + throws AtException { + try { + + // send public lookup command and decode the response + String plookupCommand = VerbBuilders.plookupCommandBuilder() + .key(publicKey) + .bypassCache(options != null ? options.isBypassCache() : null) + .operation(all) + .build(); + String plookupResponse = conn.sendSync(plookupCommand); + LookupResponse response = matchLookupResponse(throwExceptionIfError(plookupResponse)); + + // set isCached in metadata + if (response.key.contains("cached:")) { + Metadata metadata = response.metaData.toBuilder().isCached(true).build(); + publicKey.overwriteMetadata(metadata); + } + + // return value + return response.data; + + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + } + + public static void put(AtClientConnection conn, AtKeys keys, PublicKey publicKey, String value) throws AtException { + try { + + // add a signature to the metadata + Metadata metadata = Metadata.builder() + .dataSignature(signSHA256RSA(value, keys.getEncryptPrivateKey())) + .build(); + publicKey.updateMissingMetadata(metadata); + + // send and update command + String updateCommand = VerbBuilders.updateCommandBuilder().key(publicKey).value(value).build(); + String updateResponse = conn.sendSync(updateCommand); + + // verify the response + matchDataInt(throwExceptionIfError(updateResponse)); + + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/at_client/src/main/java/org/atsign/client/connection/protocol/Responses.java b/at_client/src/main/java/org/atsign/client/connection/protocol/Responses.java new file mode 100644 index 00000000..8d6a3416 --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/connection/protocol/Responses.java @@ -0,0 +1,70 @@ +package org.atsign.client.connection.protocol; + +import com.fasterxml.jackson.core.type.TypeReference; +import org.atsign.common.Json; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Atsign protocol utility code that relates to processing responses from an atserver + * + */ + +public class Responses { + + public static Map decodeJsonMapOfStrings(String json) { + try { + return Json.MAPPER.readValue(json, new TypeReference<>() {}); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static Map decodeJsonMapOfObjects(String json) { + try { + return Json.MAPPER.readValue(json, new TypeReference<>() {}); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static List decodeJsonList(String json) { + try { + return Json.MAPPER.readValue(json, new TypeReference<>() {}); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static List decodeJsonListOfStrings(String json) { + try { + return Json.MAPPER.readValue(json, new TypeReference<>() {}); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + public static String match(String input, Pattern pattern) { + Matcher matcher = pattern.matcher(input); + if (!matcher.matches()) { + throw new RuntimeException("expected [" + pattern + "] but input was : " + input); + } + StringBuilder builder = new StringBuilder(); + if (matcher.groupCount() == 0) { + builder.append(input); + } else { + for (int i = 1; i <= matcher.groupCount(); i++) { + builder.append(matcher.group(i)); + } + } + return builder.toString(); + } + + public static T match(String input, Pattern pattern, Function transformer) { + return transformer.apply(match(input, pattern)); + } +} diff --git a/at_client/src/main/java/org/atsign/client/connection/protocol/Scan.java b/at_client/src/main/java/org/atsign/client/connection/protocol/Scan.java new file mode 100644 index 00000000..ac1697bf --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/connection/protocol/Scan.java @@ -0,0 +1,36 @@ +package org.atsign.client.connection.protocol; + +import static org.atsign.client.connection.protocol.Error.throwExceptionIfError; + +import java.util.List; +import java.util.concurrent.ExecutionException; + +import org.atsign.client.connection.api.AtClientConnection; +import org.atsign.common.AtException; +import org.atsign.common.VerbBuilders; + +/** + * Atsign protocol utility code that relates querying the keys in an atserver. + * + */ + +public class Scan { + + public static List scan(AtClientConnection connection, boolean showHidden, String regex) throws AtException { + try { + + // send scan command + String scanCommand = VerbBuilders.scanCommandBuilder() + .showHidden(showHidden) + .regex(regex) + .build(); + String scanResponse = connection.sendSync(scanCommand); + + // return unmarshalled key names + return Data.matchDataJsonListOfStrings(throwExceptionIfError(scanResponse)); + + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + } +} diff --git a/at_client/src/main/java/org/atsign/client/connection/protocol/SelfKeys.java b/at_client/src/main/java/org/atsign/client/connection/protocol/SelfKeys.java new file mode 100644 index 00000000..121f89f1 --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/connection/protocol/SelfKeys.java @@ -0,0 +1,77 @@ +package org.atsign.client.connection.protocol; + +import org.atsign.client.api.AtKeys; +import org.atsign.client.connection.api.AtClientConnection; +import org.atsign.common.AtException; +import org.atsign.common.Keys.SelfKey; +import org.atsign.common.Metadata; +import org.atsign.common.VerbBuilders; +import org.atsign.common.response_models.LookupResponse; + +import java.util.concurrent.ExecutionException; + +import static org.atsign.client.connection.protocol.Data.matchDataInt; +import static org.atsign.client.connection.protocol.Data.matchLookupResponse; +import static org.atsign.client.connection.protocol.Error.throwExceptionIfError; +import static org.atsign.client.util.EncryptionUtil.*; +import static org.atsign.client.util.EncryptionUtil.aesEncryptToBase64; +import static org.atsign.client.util.Preconditions.checkNotNull; +import static org.atsign.common.VerbBuilders.LookupOperation.all; + +/** + * Atsign protocol utility code that relates to "self keys" + * + */ +public class SelfKeys { + + public static String get(AtClientConnection conn, AtKeys keys, SelfKey key) throws AtException { + try { + + // send local lookup command and decode response + String llookupCommand = VerbBuilders.llookupCommandBuilder().key(key).operation(all).build(); + String llookupResponse = conn.sendSync(llookupCommand); + LookupResponse response = matchLookupResponse(throwExceptionIfError(llookupResponse)); + + // decrypt with my self encrypt key + String selfEncryptionKey = keys.getSelfEncryptKey(); + String iv = checkNotNull(response.metaData.ivNonce(), "ivNonce is null"); + String decrypted = aesDecryptFromBase64(response.data, selfEncryptionKey, iv); + + // overwrite metadata + key.overwriteMetadata(response.metaData); + + return decrypted; + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + } + + public static void put(AtClientConnection conn, AtKeys keys, SelfKey key, String value) throws AtException { + try { + + // add signature and iv + Metadata metadata = Metadata.builder() + .dataSignature(signSHA256RSA(value, keys.getEncryptPrivateKey())) + .ivNonce(generateRandomIvBase64(16)) + .build(); + key.updateMissingMetadata(metadata); + + // encrypt with my self encrypt key + String encrypted = aesEncryptToBase64(value, keys.getSelfEncryptKey(), metadata.ivNonce()); + + // send update command to store encrypted value + String updateCommand = VerbBuilders.updateCommandBuilder() + .key(key) + .value(encrypted) + .build(); + String updateResponse = conn.sendSync(updateCommand); + + // verify response + matchDataInt(throwExceptionIfError(updateResponse)); + + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/at_client/src/main/java/org/atsign/client/connection/protocol/SharedKeys.java b/at_client/src/main/java/org/atsign/client/connection/protocol/SharedKeys.java new file mode 100644 index 00000000..1006f4a1 --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/connection/protocol/SharedKeys.java @@ -0,0 +1,232 @@ +package org.atsign.client.connection.protocol; + +import static org.atsign.client.api.AtKeyNames.toSharedByMeKeyName; +import static org.atsign.client.connection.protocol.Data.*; +import static org.atsign.client.connection.protocol.Error.throwExceptionIfError; +import static org.atsign.client.util.EncryptionUtil.*; +import static org.atsign.client.util.Preconditions.checkNotNull; +import static org.atsign.client.util.Preconditions.checkTrue; +import static org.atsign.common.VerbBuilders.LookupOperation.all; + +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import org.atsign.client.api.AtKeyNames; +import org.atsign.client.api.AtKeys; +import org.atsign.client.connection.api.AtClientConnection; +import org.atsign.client.util.EncryptionUtil; +import org.atsign.common.AtException; +import org.atsign.common.AtSign; +import org.atsign.common.Keys.SharedKey; +import org.atsign.common.Metadata; +import org.atsign.common.VerbBuilders; +import org.atsign.common.exceptions.AtKeyNotFoundException; +import org.atsign.common.response_models.LookupResponse; + +/** + * Atsign protocol utility code that relates to "shared keys" + * + */ + +public class SharedKeys { + + public static String get(AtClientConnection conn, AtSign atSign, AtKeys keys, SharedKey key) + throws AtException { + if (key.sharedBy().equals(atSign)) { + return getSharedByMe(conn, keys, key); + } else if (key.sharedWith().equals(atSign)) { + return getSharedByOther(conn, keys, key); + } else { + throw new IllegalArgumentException("the client atsign is neither the sharedBy or sharedWith"); + } + } + + public static void put(AtClientConnection conn, AtSign atSign, AtKeys keys, SharedKey key, String value) + throws AtException { + checkTrue(key.sharedBy().equals(atSign), "sharedBy does not match this client's atsign"); + try { + + // get or create key for sharedBy - sharedWith + String aesKey = getEncryptKeySharedByMe(conn, keys, key); + if (aesKey == null) { + aesKey = createEncryptKey(conn, keys, key); + } + + // encrypt the value + String iv = EncryptionUtil.generateRandomIvBase64(16); + key.updateMissingMetadata(Metadata.builder().ivNonce(iv).build()); + String encrypted = aesEncryptToBase64(value, aesKey, iv); + + // send an update command + String updateCommand = VerbBuilders.updateCommandBuilder().key(key).value(encrypted).build(); + String updateResponse = conn.sendSync(updateCommand); + + // verify the response + matchDataInt(throwExceptionIfError(updateResponse)); + + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + } + + public static String getSharedByMe(AtClientConnection conn, AtKeys keys, SharedKey key) throws AtException { + try { + + // send local lookup command and decode + String llookupCommand = VerbBuilders.llookupCommandBuilder().key(key).operation(all).build(); + LookupResponse llookupResponse = matchLookupResponse(throwExceptionIfError(conn.sendSync(llookupCommand))); + + // get my encrypt key for sharedBy sharedWith + String aesKey = checkNotNull(getEncryptKeySharedByMe(conn, keys, key), key + " not found"); + + // return decrypted value + return aesDecryptFromBase64(llookupResponse.data, aesKey, llookupResponse.metaData.ivNonce()); + + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + } + + public static String getSharedByOther(AtClientConnection conn, AtKeys keys, SharedKey key) throws AtException { + try { + + // send lookup command and decode + String lookupCommand = VerbBuilders.lookupCommandBuilder().key(key).operation(all).build(); + LookupResponse lookupResponse = matchLookupResponse(throwExceptionIfError(conn.sendSync(lookupCommand))); + + // get my encrypt key for sharedBy sharedWith + String shareEncryptionKey = getEncryptKeySharedByOther(conn, keys, key); + + // return decrypted value + return aesDecryptFromBase64(lookupResponse.data, shareEncryptionKey, lookupResponse.metaData.ivNonce()); + + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + } + + public static String getEncryptKeySharedByMe(AtClientConnection conn, AtKeys keys, SharedKey key) throws AtException { + try { + + String keyName = AtKeyNames.toSharedByMeKeyName(key.sharedWith()); + String aesKey = keys.get(keyName); + if (aesKey != null) { + return aesKey; + } + + // send local lookup command for sharedKey + String llookupCommand = VerbBuilders.llookupCommandBuilder() + .keyName(toSharedByMeKeyName(key.sharedWith())) + .sharedBy(key.sharedBy()) + .build(); + String llookupResponse = conn.sendSync(llookupCommand); + + try { + // decrypt the key + String encrypted = Data.matchDataStringNoWhitespace(throwExceptionIfError(llookupResponse)); + aesKey = rsaDecryptFromBase64(encrypted, keys.getEncryptPrivateKey()); + + // store in keys + keys.put(keyName, aesKey); + + return aesKey; + } catch (AtKeyNotFoundException e) { + return null; + } + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + } + + public static String getEncryptKeySharedByOther(AtClientConnection conn, AtKeys keys, SharedKey key) + throws AtException { + try { + + // check in Keys cache + String keyName = AtKeyNames.toSharedWithMeKeyName(key.sharedBy(), key.sharedWith()); + String aesKey = keys.get(keyName); + if (aesKey != null) { + return aesKey; + } + + // otherwise send lookup + String lookupCommand = VerbBuilders.lookupCommandBuilder() + .keyName(AtKeyNames.SHARED_KEY) + .sharedBy(key.sharedBy()) + .build(); + String lookupResponse = conn.sendSync(lookupCommand); + + // decrypt with my private key + String encrypted = matchDataStringNoWhitespace(throwExceptionIfError(lookupResponse)); + aesKey = rsaDecryptFromBase64(encrypted, keys.getEncryptPrivateKey()); + + // store in keys + keys.put(keyName, aesKey); + + return aesKey; + + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + } + + public static String createEncryptKey(AtClientConnection connection, AtKeys keys, SharedKey key) throws AtException { + try { + + // generate a new encrypt key + String aesKey = EncryptionUtil.generateAESKeyBase64(); + + // compose an update command to store this key encrypted with this (sharedBy) atsign's public key + String encryptedForMe = rsaEncryptToBase64(aesKey, keys.getEncryptPublicKey()); + String updateForUsCommand = VerbBuilders.updateCommandBuilder() + .keyName(toSharedByMeKeyName(key.sharedWith())) + .sharedBy(key.sharedBy()) + .value(encryptedForMe) + .build(); + + // get the other (sharedWith) atsign's public key + String otherPublicKey = getEncryptKey(connection, key.sharedWith()); + + // compose an update command to store this key encrypted with the other (sharedWith) atsign's public key + String encryptedForOther = rsaEncryptToBase64(aesKey, otherPublicKey); + String updateForOtherCommand = VerbBuilders.updateCommandBuilder() + .keyName(AtKeyNames.SHARED_KEY) + .sharedBy(key.sharedBy()) + .sharedWith(key.sharedWith()) + .ttr(TimeUnit.HOURS.toMillis(24)) + .value(encryptedForOther) + .build(); + + // send the update commands + connection.sendSync(updateForUsCommand); + connection.sendSync(updateForOtherCommand); + + keys.put(AtKeyNames.toSharedByMeKeyName(key.sharedWith()), aesKey); + + // return the new + return aesKey; + + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + } + + public static String getEncryptKey(AtClientConnection connection, AtSign sharedBy) throws AtException { + try { + + // send plookup for atsign's public encryption key + String plookupCommand = VerbBuilders.plookupCommandBuilder() + .keyName(AtKeyNames.PUBLIC_ENCRYPT) + .sharedBy(sharedBy) + .build(); + String plookupResponse = connection.sendSync(plookupCommand); + + // return key + return matchDataStringNoWhitespace(throwExceptionIfError(plookupResponse)); + + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + } + +} diff --git a/at_client/src/main/java/org/atsign/client/connection/protocol/package-info.java b/at_client/src/main/java/org/atsign/client/connection/protocol/package-info.java new file mode 100644 index 00000000..295e5094 --- /dev/null +++ b/at_client/src/main/java/org/atsign/client/connection/protocol/package-info.java @@ -0,0 +1,8 @@ +/** + * Utilities which given an {@link org.atsign.client.connection.api.AtClientConnection} + * can perform common functions such as authentication, atsign onboarding and + * enrollment. In some case these are simply sending the appropriate At Protocol command + * and verifying the response. Other scenarios such as those relating shared keys + * require some workflow. + */ +package org.atsign.client.connection.protocol; diff --git a/at_client/src/main/java/org/atsign/client/util/AuthUtil.java b/at_client/src/main/java/org/atsign/client/util/AuthUtil.java index 7f1de8d0..166ed6ed 100644 --- a/at_client/src/main/java/org/atsign/client/util/AuthUtil.java +++ b/at_client/src/main/java/org/atsign/client/util/AuthUtil.java @@ -20,6 +20,7 @@ /** * Encapsulates Atsign Platform authentication command response workflows. */ +@Deprecated public class AuthUtil { /** diff --git a/at_client/src/main/java/org/atsign/client/util/EncryptionUtil.java b/at_client/src/main/java/org/atsign/client/util/EncryptionUtil.java index 68a7e8f2..fa41f68a 100644 --- a/at_client/src/main/java/org/atsign/client/util/EncryptionUtil.java +++ b/at_client/src/main/java/org/atsign/client/util/EncryptionUtil.java @@ -61,17 +61,25 @@ public static String aesDecryptFromBase64(String text, String key, String iv) th } } - public static KeyPair generateRSAKeyPair() throws NoSuchAlgorithmException { - KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); - generator.initialize(2048); - return generator.generateKeyPair(); + public static KeyPair generateRSAKeyPair() throws AtEncryptionException { + try { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + return generator.generateKeyPair(); + } catch (NoSuchAlgorithmException e) { + throw new AtEncryptionException(e.getMessage()); + } } - public static String generateAESKeyBase64() throws NoSuchAlgorithmException { - KeyGenerator keyGenerator = KeyGenerator.getInstance("AES"); - keyGenerator.init(256); - byte[] key = keyGenerator.generateKey().getEncoded(); - return Base64.getEncoder().encodeToString(key); + public static String generateAESKeyBase64() throws AtEncryptionException { + try { + KeyGenerator keyGenerator = KeyGenerator.getInstance("AES"); + keyGenerator.init(256); + byte[] key = keyGenerator.generateKey().getEncoded(); + return Base64.getEncoder().encodeToString(key); + } catch (NoSuchAlgorithmException e) { + throw new AtEncryptionException(e.getMessage()); + } } public static String rsaDecryptFromBase64(String cipherTextBase64, String privateKeyBase64) @@ -89,8 +97,7 @@ public static String rsaDecryptFromBase64(String cipherTextBase64, String privat } } - public static String rsaEncryptToBase64(String clearText, String publicKeyBase64) - throws AtEncryptionException { + public static String rsaEncryptToBase64(String clearText, String publicKeyBase64) throws AtEncryptionException { try { PublicKey publicKey = _publicKeyFromBase64(publicKeyBase64); Cipher encryptCipher = Cipher.getInstance("RSA"); @@ -104,10 +111,12 @@ public static String rsaEncryptToBase64(String clearText, String publicKeyBase64 } } - public static String signSHA256RSA(String value, String privateKeyBase64) - throws NoSuchAlgorithmException, InvalidKeySpecException, SignatureException, InvalidKeyException { - PrivateKey privateKey = _privateKeyFromBase64(privateKeyBase64); - return _signSHA256RSA(value, privateKey); + public static String signSHA256RSA(String value, String privateKeyBase64) throws AtEncryptionException { + try { + return _signSHA256RSA(value, _privateKeyFromBase64(privateKeyBase64)); + } catch (NoSuchAlgorithmException | InvalidKeySpecException | InvalidKeyException | SignatureException e) { + throw new AtEncryptionException("SHA256 sign failed", e); + } } // non-public methods diff --git a/at_client/src/main/java/org/atsign/common/Metadata.java b/at_client/src/main/java/org/atsign/common/Metadata.java index 6895fe6a..98f3f4f0 100644 --- a/at_client/src/main/java/org/atsign/common/Metadata.java +++ b/at_client/src/main/java/org/atsign/common/Metadata.java @@ -44,14 +44,13 @@ public class Metadata { String encoding; String ivNonce; - // required for successful javadoc - /** * A builder for instantiating {@link Metadata} instances. Note: Metadata is immutable so if you * want create a modified instance then use the toBuilder() method, override the fields and invoke * build(). */ public static class MetadataBuilder { + // required for javadoc }; public static Metadata fromJson(String json) throws JsonProcessingException { diff --git a/at_client/src/main/java/org/atsign/common/exceptions/AtInvalidSyntaxException.java b/at_client/src/main/java/org/atsign/common/exceptions/AtExceptions/AtInvalidSyntaxException.java similarity index 85% rename from at_client/src/main/java/org/atsign/common/exceptions/AtInvalidSyntaxException.java rename to at_client/src/main/java/org/atsign/common/exceptions/AtExceptions/AtInvalidSyntaxException.java index 2092b317..f318e3c6 100644 --- a/at_client/src/main/java/org/atsign/common/exceptions/AtInvalidSyntaxException.java +++ b/at_client/src/main/java/org/atsign/common/exceptions/AtExceptions/AtInvalidSyntaxException.java @@ -1,4 +1,4 @@ -package org.atsign.common.exceptions; +package org.atsign.common.exceptions.AtExceptions; import org.atsign.common.AtException; diff --git a/at_client/src/main/java/org/atsign/common/exceptions/AtServerRuntimeException.java b/at_client/src/main/java/org/atsign/common/exceptions/AtExceptions/AtServerRuntimeException.java similarity index 85% rename from at_client/src/main/java/org/atsign/common/exceptions/AtServerRuntimeException.java rename to at_client/src/main/java/org/atsign/common/exceptions/AtExceptions/AtServerRuntimeException.java index c190512f..e9e81861 100644 --- a/at_client/src/main/java/org/atsign/common/exceptions/AtServerRuntimeException.java +++ b/at_client/src/main/java/org/atsign/common/exceptions/AtExceptions/AtServerRuntimeException.java @@ -1,4 +1,4 @@ -package org.atsign.common.exceptions; +package org.atsign.common.exceptions.AtExceptions; import org.atsign.common.AtException; diff --git a/at_client/src/main/java/org/atsign/common/exceptions/AtOnReadyException.java b/at_client/src/main/java/org/atsign/common/exceptions/AtOnReadyException.java new file mode 100644 index 00000000..cb470be3 --- /dev/null +++ b/at_client/src/main/java/org/atsign/common/exceptions/AtOnReadyException.java @@ -0,0 +1,19 @@ +package org.atsign.common.exceptions; + +import java.util.function.Consumer; + +/** + * A {@link RuntimeException} that is used to communicate fatal exception + * in the execution of an OnReady consumer. + * See {@link org.atsign.client.connection.api.AtClientConnection#onReady(Consumer)}. + */ +public class AtOnReadyException extends RuntimeException { + + public AtOnReadyException(String message) { + super(message); + } + + public AtOnReadyException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/at_client/src/main/java/org/atsign/common/options/GetRequestOptions.java b/at_client/src/main/java/org/atsign/common/options/GetRequestOptions.java index 7e21adef..f77016d4 100644 --- a/at_client/src/main/java/org/atsign/common/options/GetRequestOptions.java +++ b/at_client/src/main/java/org/atsign/common/options/GetRequestOptions.java @@ -1,30 +1,14 @@ package org.atsign.common.options; +import lombok.Builder; +import lombok.Value; + /** * Data class used to model options to the {@link org.atsign.client.api.AtClient} get methods */ -public class GetRequestOptions extends RequestOptions { - - private boolean bypassCache; - - public GetRequestOptions() { - - } - - public GetRequestOptions bypassCache(boolean bypassCache) { - this.bypassCache = bypassCache; - return this; - } - - public boolean getBypassCache() { - return bypassCache; - } - - @Override - public RequestOptions build() { - return (GetRequestOptions) this; - } - - +@Value +@Builder +public class GetRequestOptions { + boolean bypassCache; } diff --git a/at_client/src/main/java/org/atsign/common/options/RequestOptions.java b/at_client/src/main/java/org/atsign/common/options/RequestOptions.java deleted file mode 100644 index 890b967d..00000000 --- a/at_client/src/main/java/org/atsign/common/options/RequestOptions.java +++ /dev/null @@ -1,8 +0,0 @@ -package org.atsign.common.options; - -/** - * Base class for options used in {@link org.atsign.client.api.AtClient} methods - */ -public abstract class RequestOptions { - public abstract RequestOptions build(); -} diff --git a/at_client/src/test/java/org/atsign/client/cli/ActivateIT.java b/at_client/src/test/java/org/atsign/client/cli/ActivateIT.java index a46148e8..2954a0bb 100644 --- a/at_client/src/test/java/org/atsign/client/cli/ActivateIT.java +++ b/at_client/src/test/java/org/atsign/client/cli/ActivateIT.java @@ -45,7 +45,6 @@ public static void classSetup() { } } - @BeforeEach public void setup() { executor = Executors.newSingleThreadExecutor(); diff --git a/at_client/src/test/java/org/atsign/client/connection/common/CommandQueueTest.java b/at_client/src/test/java/org/atsign/client/connection/common/CommandQueueTest.java new file mode 100644 index 00000000..927f00e8 --- /dev/null +++ b/at_client/src/test/java/org/atsign/client/connection/common/CommandQueueTest.java @@ -0,0 +1,365 @@ +package org.atsign.client.connection.common; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class CommandQueueTest { + + private static final int DEFAULT_CAPACITY = 10; + + private CommandQueue commandQueue; + + @BeforeEach + void setUp() { + commandQueue = new CommandQueue(DEFAULT_CAPACITY); + } + + @Test + void testNewQueueIsEmpty() { + assertThat(commandQueue.isEmpty(), is(true)); + } + + @Test + void testGetQueueCapacityReturnsConstructorArg() { + assertThat(new CommandQueue(2).getQueueCapacity(), equalTo(2)); + } + + @Test + void testNewQueueHasSizeZero() { + assertThat(commandQueue.size(), is(0)); + } + + @Test + void testOfferNonConsumerCommandWithinCapacityReturnsTrue() { + Command command = new Command("request", new CompletableFuture<>(), 0L, false); + + boolean result = commandQueue.offer(command); + + assertThat(result, is(true)); + } + + @Test + void testOfferNonConsumerCommandIncreasesSize() { + Command command = new Command("request", new CompletableFuture<>(), 0L, false); + + commandQueue.offer(command); + + assertThat(commandQueue.size(), is(1)); + } + + @Test + void testOfferMultipleCommandsIncreasesSize() { + for (int i = 0; i < 5; i++) { + Command command = new Command("request", new CompletableFuture<>(), 0L, false); + commandQueue.offer(command); + } + + assertThat(commandQueue.size(), is(5)); + } + + @Test + void testOfferExceedingCapacityReturnsFalse() { + CommandQueue queue = new CommandQueue(2); + + Command command1 = new Command("request1", new CompletableFuture<>(), 0L, false); + Command command2 = new Command("request2", new CompletableFuture<>(), 1L, false); + Command command3 = new Command("request3", new CompletableFuture<>(), 2L, false); + + queue.offer(command1); + queue.offer(command2); + boolean result = queue.offer(command3); + + assertThat(result, is(false)); + } + + @Test + void testOfferExceedingCapacityDoesNotIncreaseSize() { + CommandQueue smallQueue = new CommandQueue(1); + + Command command1 = new Command("request1", new CompletableFuture<>(), 0L, false); + Command command2 = new Command("request2", new CompletableFuture<>(), 1L, false); + + smallQueue.offer(command1); + smallQueue.offer(command2); + + assertThat(smallQueue.size(), is(1)); + } + + @Test + void testOfferAtExactCapacityReturnsTrue() { + CommandQueue queue = new CommandQueue(1); + + Command command = new Command("request", new CompletableFuture<>(), 0L, false); + + assertThat(queue.offer(command), is(true)); + } + + @Test + void testOfferConsumerCommandWhenNoExistingConsumerReturnsTrue() { + Command command = new Command("request", new CompletableFuture<>(), 0L, false); + + boolean result = commandQueue.offer(command); + + assertThat(result, is(true)); + } + + @Test + void testOfferSecondConsumerCommandReturnsFalse() { + Command command1 = new Command("request1", new CompletableFuture<>(), s -> { + }, 0L, false); + commandQueue.offer(command1); + + Command command2 = new Command("request1", new CompletableFuture<>(), s -> { + }, 0L, false); + + commandQueue.offer(command2); + assertThat(commandQueue.hasConsumerCommand(), is(true)); + assertThat(commandQueue.offer(command2), is(false)); + + commandQueue.pop("@"); + assertThat(commandQueue.size(), equalTo(0)); + assertThat(commandQueue.hasConsumerCommand(), is(true)); + assertThat(commandQueue.offer(command2), is(false)); + } + + @Test + void testPopEmptyQueueReturnsNull() { + assertThat(commandQueue.pop("response"), nullValue()); + + Command command = new Command("request1", new CompletableFuture<>(), s -> { + }, 0L, false); + commandQueue.offer(command); + commandQueue.pop("response1"); + + assertThat(commandQueue.pop("response"), nullValue()); + } + + @Test + void testPopReturnsNullForEventIfNoConsumerCommand() { + assertThat(commandQueue.pop("notification:xyz"), nullValue()); + } + + @Test + void testPopReturnsExpectedCommandForResponse() { + Command command = new Command("request1", new CompletableFuture<>(), 0L, false); + commandQueue.offer(command); + commandQueue.pop("@"); + + assertThat(commandQueue.pop("response"), equalTo(command)); + } + + @Test + void testPopReturnsNullForPromptIfNoConsumerCommand() { + Command command = new Command("request1", new CompletableFuture<>(), 0L, false); + commandQueue.offer(command); + assertThat(commandQueue.pop("@"), nullValue()); + } + + @Test + void testPopReturnsConsumerCommandForPrompt() { + Command command = new Command("request1", new CompletableFuture<>(), s -> { + }, 0L, false); + commandQueue.offer(command); + assertThat(commandQueue.pop("@"), equalTo(command)); + } + + @Test + void testPopWithEventReturnsConsumerCommand() { + Command command = new Command("monitor", new CompletableFuture<>(), s -> { + }, 0L, false); + commandQueue.offer(command); + assertThat(commandQueue.size(), equalTo(1)); + assertThat(commandQueue.isEmpty(), is(false)); + + assertThat(commandQueue.pop("@"), equalTo(command)); + + assertThat(commandQueue.pop("notification:xyz"), equalTo(command)); + assertThat(commandQueue.size(), equalTo(0)); + assertThat(commandQueue.isEmpty(), is(true)); + } + + @Test + void testPopWithEventReturnsPeekedConsumerCommand() { + Command command = new Command("monitor", new CompletableFuture<>(), s -> { + }, 0L, false); + commandQueue.offer(command); + assertThat(commandQueue.size(), equalTo(1)); + assertThat(commandQueue.isEmpty(), is(false)); + + assertThat(commandQueue.pop("notification:xyz"), equalTo(command)); + assertThat(commandQueue.size(), equalTo(0)); + assertThat(commandQueue.isEmpty(), is(true)); + } + + @Test + void testPollReturnsFirstIn() { + Command command1 = new Command("request1", new CompletableFuture<>(), 0L, false); + Command command2 = new Command("request2", new CompletableFuture<>(), 0L, false); + + commandQueue.offer(command1); + commandQueue.offer(command2); + assertThat(commandQueue.size(), equalTo(2)); + + assertThat(commandQueue.poll(), equalTo(command1)); + assertThat(commandQueue.size(), equalTo(1)); + assertThat(commandQueue.poll(), equalTo(command2)); + assertThat(commandQueue.size(), equalTo(0)); + assertThat(commandQueue.poll(), nullValue()); + } + + @Test + void testPeekReturnsFirstIn() { + Command command1 = new Command("request1", new CompletableFuture<>(), 0L, false); + Command command2 = new Command("request2", new CompletableFuture<>(), 0L, false); + + commandQueue.offer(command1); + commandQueue.offer(command2); + assertThat(commandQueue.size(), equalTo(2)); + + assertThat(commandQueue.peek(), equalTo(command1)); + assertThat(commandQueue.size(), equalTo(2)); + assertThat(commandQueue.peek(), equalTo(command1)); + commandQueue.poll(); + assertThat(commandQueue.peek(), equalTo(command2)); + commandQueue.poll(); + assertThat(commandQueue.peek(), nullValue()); + } + + @Test + void testIsEmptyReturnsTrueWhenQueueHasNoCommands() { + assertThat(commandQueue.isEmpty(), is(true)); + } + + @Test + void testIsEmptyReturnsFalseAfterCommandOffered() { + Command command = new Command("request", new CompletableFuture<>(), 0L, false); + commandQueue.offer(command); + + assertThat(commandQueue.isEmpty(), is(false)); + } + + @Test + void testSizeReflectsNumberOfCommandsInQueue() { + int count = 4; + for (int i = 0; i < count; i++) { + Command command = new Command("request", new CompletableFuture<>(), i, false); + commandQueue.offer(command); + assertThat(commandQueue.size(), is(i + 1)); + } + } + + @Test + void testIteratorReturnsAllOfferedCommands() { + Command command1 = new Command("request1", new CompletableFuture<>(), 0L, false); + Command command2 = new Command("request2", new CompletableFuture<>(), 1L, false); + + commandQueue.offer(command1); + commandQueue.offer(command2); + + List iterated = new ArrayList<>(); + commandQueue.iterator().forEachRemaining(iterated::add); + + assertThat(iterated, hasSize(2)); + assertThat(iterated, containsInAnyOrder(command1, command2)); + } + + @Test + void testIteratorOnEmptyQueueHasNoElements() { + assertThat(commandQueue.iterator().hasNext(), is(false)); + } + + @Test + void testForEachVisitsAllCommands() { + Command command1 = new Command("request1", new CompletableFuture<>(), 0L, false); + Command command2 = new Command("request2", new CompletableFuture<>(), 1L, false); + + commandQueue.offer(command1); + commandQueue.offer(command2); + + List visited = new ArrayList<>(); + commandQueue.forEach(visited::add); + + assertThat(visited, hasSize(2)); + assertThat(visited, containsInAnyOrder(command1, command2)); + } + + @Test + void testSpliteratorCoversAllCommands() { + Command command1 = new Command("monitor", new CompletableFuture<>(), s -> { + }, 0L, false); + Command command2 = new Command("request1", new CompletableFuture<>(), 0L, false); + Command command3 = new Command("request2", new CompletableFuture<>(), 1L, false); + + commandQueue.offer(command1); + commandQueue.offer(command2); + commandQueue.offer(command3); + + List collected = new ArrayList<>(); + commandQueue.spliterator().forEachRemaining(collected::add); + + assertThat(collected, hasSize(3)); + assertThat(collected, containsInAnyOrder(command1, command2, command3)); + + commandQueue.pop("notification:xyz"); + collected = new ArrayList<>(); + commandQueue.spliterator().forEachRemaining(collected::add); + + assertThat(collected, hasSize(2)); + assertThat(collected, containsInAnyOrder(command2, command3)); + + commandQueue.pop("response1"); + collected = new ArrayList<>(); + commandQueue.spliterator().forEachRemaining(collected::add); + + assertThat(collected, hasSize(1)); + assertThat(collected, containsInAnyOrder(command3)); + } + + @Test + void testOfferToZeroCapacityQueueReturnsFalse() { + CommandQueue zeroQueue = new CommandQueue(0); + Command command = new Command("request", new CompletableFuture<>(), 0L, false); + + boolean result = zeroQueue.offer(command); + + assertThat(result, is(false)); + } + + @Test + void testZeroCapacityQueueRemainsEmpty() { + CommandQueue zeroQueue = new CommandQueue(0); + Command command = new Command("request", new CompletableFuture<>(), 0L, false); + zeroQueue.offer(command); + + assertThat(zeroQueue.isEmpty(), is(true)); + } + + @Test + void testPollTimedOut() { + Command command1 = new Command("request1", new CompletableFuture<>(), 1L, false); + Command command2 = new Command("request2", new CompletableFuture<>(), 1L, false); + Command command3 = new Command("request3", new CompletableFuture<>(), 2L, false); + + commandQueue.offer(command1); + commandQueue.offer(command2); + commandQueue.offer(command3); + + assertThat(commandQueue.pollTimedOut(0L), is(empty())); + assertThat(commandQueue.size(), equalTo(3)); + assertThat(commandQueue.pollTimedOut(1L), is(empty())); + assertThat(commandQueue.size(), equalTo(3)); + assertThat(commandQueue.pollTimedOut(2L), containsInAnyOrder(command1, command2)); + assertThat(commandQueue.size(), equalTo(1)); + assertThat(commandQueue.pollTimedOut(3L), containsInAnyOrder(command3)); + assertThat(commandQueue.size(), equalTo(0)); + } + + +} diff --git a/at_client/src/test/java/org/atsign/client/connection/common/CommandTest.java b/at_client/src/test/java/org/atsign/client/connection/common/CommandTest.java new file mode 100644 index 00000000..0c9c2d72 --- /dev/null +++ b/at_client/src/test/java/org/atsign/client/connection/common/CommandTest.java @@ -0,0 +1,144 @@ +package org.atsign.client.connection.common; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; + +class CommandTest { + + @Test + void testToStringReturnsText() { + Command command = new Command("scan", new CompletableFuture<>(), 100, false); + + assertThat(command.toString(), equalTo("scan")); + } + + @Test + void testIsTimedOutTrueWhenTimestampBeforeTimeout() { + Command command = new Command("cmd", new CompletableFuture<>(), 100, false); + + assertThat(command.isTimedOut(101L), is(true)); + } + + @Test + void testIsTimedOutFalseWhenTimestampAfterTimeout() { + Command command = new Command("cmd", new CompletableFuture<>(), 300, false); + + assertThat(command.isTimedOut(200L), is(false)); + } + + @Test + void testIsTimedOutFalseWhenTimestampEqualsTimeout() { + Command command = new Command("cmd", new CompletableFuture<>(), 300, false); + + assertThat(command.isTimedOut(300L), is(false)); + } + + @Test + void testCompleteSetsFutureValue() throws Exception { + CompletableFuture future = new CompletableFuture<>(); + Command command = new Command("cmd", future, 0, false); + + command.complete("result"); + + assertThat(future.get(), is("result")); + } + + @Test + void testCompleteAllowsNullValue() throws Exception { + CompletableFuture future = new CompletableFuture<>(); + Command command = new Command("cmd", future, 0, false); + + command.complete(null); + + assertThat(future.get(), nullValue()); + } + + @Test + void testCompleteConsumerCommandCompletesFutureAndCallsConsumer() { + CompletableFuture future = new CompletableFuture<>(); + Consumer consumer = mock(Consumer.class); + + Command command = new Command("monitor", future, consumer, 0, false); + + command.complete("notification:xyz"); + + assertThat(future.isDone(), is(true)); + verify(consumer).accept("notification:xyz"); + } + + @Test + void testCompleteConsumerCommandDoesNotCallConsumerWhenNullValue() { + CompletableFuture future = new CompletableFuture<>(); + Consumer consumer = mock(Consumer.class); + + Command command = new Command("cmd", future, consumer, 0, false); + + command.complete(null); + + assertThat(future.isDone(), is(true)); + verify(consumer, never()).accept("notification:xyz"); + } + + @Test + void testCompleteExceptionallyCompletesFutureExceptionally() { + CompletableFuture future = new CompletableFuture<>(); + Command command = new Command("cmd", future, 0, false); + + command.completeExceptionally(new RuntimeException("deliberate")); + + assertThat(future.isCompletedExceptionally(), is(true)); + ExecutionException ex = assertThrows(ExecutionException.class, future::get); + assertThat(ex.getMessage(), containsString("deliberate")); + } + + @Test + void testIsMatchReturnsTrueForCommand() { + Command command = new Command("scan", new CompletableFuture<>(), 0, false); + + assertThat(command.isMatch("xyz"), is(true)); + } + + @Test + void testIsMatchReturnsExpectedResultForConsumerCommand() { + Command command = new Command("monitor", new CompletableFuture<>(), s -> { + }, 0, false); + + assertThat(command.isMatch("notification:xyz"), is(true)); + assertThat(command.isMatch("ok"), is(false)); + } + + @Test + void testIsConsumerCommandReturnsExpectedResults() { + Command command = new Command("scan", new CompletableFuture<>(), 0, false); + Command consumerCommand = new Command("monitor", new CompletableFuture<>(), s -> { + }, 0, false); + + assertThat(command.isConsumerCommand(), is(false)); + assertThat(consumerCommand.isConsumerCommand(), is(true)); + } + + @Test + void testIsConsumerCommandStaticMethodReturnsExpectedResults() { + Command command = new Command("scan", new CompletableFuture<>(), 0, false); + Command consumerCommand = new Command("monitor", new CompletableFuture<>(), s -> { + }, 0, false); + + assertThat(Command.isConsumerCommand(consumerCommand), is(true)); + assertThat(Command.isConsumerCommand(command), is(false)); + assertThat(Command.isConsumerCommand(null), is(false)); + } + + @Test + void testIsConsumerResponseReturnsExpectedResults() { + assertThat(Command.isConsumerResponse("notification:test"), is(true)); + assertThat(Command.isConsumerResponse("ok"), is(false)); + } +} diff --git a/at_client/src/test/java/org/atsign/client/connection/common/SimpleReconnectStrategyTest.java b/at_client/src/test/java/org/atsign/client/connection/common/SimpleReconnectStrategyTest.java new file mode 100644 index 00000000..9ea37521 --- /dev/null +++ b/at_client/src/test/java/org/atsign/client/connection/common/SimpleReconnectStrategyTest.java @@ -0,0 +1,157 @@ +package org.atsign.client.connection.common; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +class SimpleReconnectStrategyTest { + + @Test + void testIsReconnectSupportedWhenMaxRetriesIsForever() { + SimpleReconnectStrategy strategy = SimpleReconnectStrategy.builder() + .maxReconnectRetries(SimpleReconnectStrategy.RECONNECT_RETRY_FOREVER) + .build(); + + assertThat(strategy.isReconnectSupported(), is(true)); + RuntimeException ex = new RuntimeException("deliberate"); + for (int i = 0; i < 1000; i++) { + strategy.onConnectFailure(ex); + assertThat(strategy.isReconnectSupported(), is(true)); + } + } + + @Test + void testIsReconnectSupportedWhenMaxRetriesIsNotForever() { + SimpleReconnectStrategy strategy = SimpleReconnectStrategy.builder() + .maxReconnectRetries(3) + .build(); + + // connect failure count < max + strategy.onConnectFailure(new RuntimeException()); + strategy.onConnectFailure(new RuntimeException()); + assertThat(strategy.isReconnectSupported(), is(true)); + + // connect failure count = max + strategy.onConnectFailure(new RuntimeException()); + assertThat(strategy.isReconnectSupported(), is(true)); + + // connect failure count > max + strategy.onConnectFailure(new RuntimeException()); + assertThat(strategy.isReconnectSupported(), is(false)); + } + + @Test + void testIsReconnectSupportedAfterConnect() { + SimpleReconnectStrategy strategy = SimpleReconnectStrategy.builder() + .maxReconnectRetries(3) + .build(); + + strategy.onConnectFailure(new RuntimeException()); + strategy.onConnectFailure(new RuntimeException()); + strategy.onConnectFailure(new RuntimeException()); + strategy.onConnect(); + strategy.onDisconnect(new RuntimeException()); + + strategy.onConnectFailure(new RuntimeException()); + assertThat(strategy.isReconnectSupported(), is(true)); + } + + @Test + void testGetReconnectPauseMillis() { + SimpleReconnectStrategy strategy = SimpleReconnectStrategy.builder() + .maxReconnectRetries(5) + .reconnectPauseMillis(2000L) + .build(); + + strategy.onConnectFailure(new RuntimeException()); + strategy.onConnect(); + + // first disconnect after connect + assertThat(strategy.getReconnectPauseMillis(), is(0L)); + + // subsequent connection failures + strategy.onConnectFailure(new RuntimeException()); + assertThat(strategy.getReconnectPauseMillis(), is(2000L)); + strategy.onConnectFailure(new RuntimeException()); + assertThat(strategy.getReconnectPauseMillis(), is(2000L)); + + strategy.onConnect(); + + // first disconnect after connect + assertThat(strategy.getReconnectPauseMillis(), is(0L)); + } + + @Test + void testGetReconnectPauseMillisDefault() { + SimpleReconnectStrategy strategy = SimpleReconnectStrategy.builder() + .maxReconnectRetries(5) + .build(); + + strategy.onConnectFailure(new RuntimeException()); + strategy.onConnect(); + + // first disconnect after connect + assertThat(strategy.getReconnectPauseMillis(), is(0L)); + + // subsequent connection failures + strategy.onConnectFailure(new RuntimeException()); + assertThat(strategy.getReconnectPauseMillis(), is(1000L)); + } + + @Test + void testIsReresolveEndpointsEveryTime() { + SimpleReconnectStrategy strategy = SimpleReconnectStrategy.builder() + .maxReconnectRetries(5) + .resolveEndpointFrequency(1) + .build(); + + // first disconnect + strategy.onDisconnect(new RuntimeException()); + assertThat(strategy.isReresolveEndpoint(), is(false)); + + // subsequent connection failures + strategy.onConnectFailure(new RuntimeException()); + assertThat(strategy.isReresolveEndpoint(), is(true)); + strategy.onConnectFailure(new RuntimeException()); + assertThat(strategy.isReresolveEndpoint(), is(true)); + } + + + @Test + void testIsReresolveEndpointsEverOtherTime() { + SimpleReconnectStrategy strategy = SimpleReconnectStrategy.builder() + .maxReconnectRetries(5) + .resolveEndpointFrequency(2) + .build(); + + // first disconnect + strategy.onDisconnect(new RuntimeException()); + assertThat(strategy.isReresolveEndpoint(), is(false)); + + // subsequent connection failures + strategy.onConnectFailure(new RuntimeException()); + assertThat(strategy.isReresolveEndpoint(), is(false)); + strategy.onConnectFailure(new RuntimeException()); + assertThat(strategy.isReresolveEndpoint(), is(true)); + strategy.onConnectFailure(new RuntimeException()); + assertThat(strategy.isReresolveEndpoint(), is(false)); + } + + + @Test + void testIsReresolveEndpointsDefaultNeverReresolves() { + SimpleReconnectStrategy strategy = SimpleReconnectStrategy.builder() + .maxReconnectRetries(5) + .build(); + strategy.onDisconnect(new RuntimeException()); + assertThat(strategy.isReresolveEndpoint(), is(false)); + strategy.onConnectFailure(new RuntimeException()); + assertThat(strategy.isReresolveEndpoint(), is(false)); + strategy.onConnectFailure(new RuntimeException()); + assertThat(strategy.isReresolveEndpoint(), is(false)); + strategy.onConnectFailure(new RuntimeException()); + assertThat(strategy.isReresolveEndpoint(), is(false)); + } + +} diff --git a/at_client/src/test/java/org/atsign/client/connection/netty/NettyAtClientConnectionIT.java b/at_client/src/test/java/org/atsign/client/connection/netty/NettyAtClientConnectionIT.java new file mode 100644 index 00000000..be2841ce --- /dev/null +++ b/at_client/src/test/java/org/atsign/client/connection/netty/NettyAtClientConnectionIT.java @@ -0,0 +1,72 @@ +package org.atsign.client.connection.netty; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.atsign.common.AtSign.createAtSign; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import org.atsign.client.connection.netty.NettyAtClientConnection.NettyAtClientConnectionBuilder; +import org.atsign.common.AtSign; +import org.atsign.cucumber.helpers.Helpers; +import org.atsign.virtualenv.VirtualEnv; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +class NettyAtClientConnectionIT { + + @BeforeAll + public static void classSetup() { + if (!Helpers.isHostPortReachable("vip.ve.atsign.zone:64", SECONDS.toMillis(2))) { + VirtualEnv.setUp(); + } + } + + @Test + void testResolveAtServer() throws Exception { + NettyAtEndpointSupplier provider = NettyAtEndpointSupplier.builder() + .rootUrl("vip.ve.atsign.zone:64") + .atsign(createAtSign("colin")) + .build(); + assertThat(provider.get().matches("\\S+:\\d+"), is(true)); + } + + + @Test + void testScan() throws Exception { + NettyAtEndpointSupplier provider = NettyAtEndpointSupplier.builder() + .rootUrl("vip.ve.atsign.zone:64") + .atsign(createAtSign("colin")) + .build(); + try (NettyAtClientConnection connection = NettyAtClientConnection.builder().endpoint(provider).build()) { + assertThat(connection.sendSync("scan"), Matchers.startsWith("data:")); + } + } + + @Test + void testUnauthenticatedMonitorAttempt() throws Exception { + AtSign atSign = createAtSign("colin"); + + NettyAtEndpointSupplier provider = NettyAtEndpointSupplier.builder() + .rootUrl("vip.ve.atsign.zone:64") + .atsign(atSign) + .build(); + + NettyAtClientConnectionBuilder builder = NettyAtClientConnection.builder() + .endpoint(provider); + + try (NettyAtClientConnection connection = builder.build()) { + List notifications = new CopyOnWriteArrayList<>(); + connection.sendSync("monitor", notifications::add); + await().until(() -> !notifications.isEmpty()); + assertThat(notifications.get(0), Matchers.startsWith("error:")); + } + } +} diff --git a/at_client/src/test/java/org/atsign/client/connection/netty/NettyAtClientConnectionTest.java b/at_client/src/test/java/org/atsign/client/connection/netty/NettyAtClientConnectionTest.java new file mode 100644 index 00000000..131be841 --- /dev/null +++ b/at_client/src/test/java/org/atsign/client/connection/netty/NettyAtClientConnectionTest.java @@ -0,0 +1,769 @@ +package org.atsign.client.connection.netty; + +import static java.util.concurrent.Executors.newSingleThreadScheduledExecutor; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.atsign.client.connection.protocol.AtExceptions.throwOnReadyException; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.hamcrest.Matchers.contains; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.List; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import org.atsign.client.connection.api.AtClientConnection; +import org.atsign.client.connection.api.AtEndpointSupplier; +import org.atsign.client.connection.common.SimpleReconnectStrategy; +import org.atsign.client.connection.netty.NettyAtClientConnection.NettyAtClientConnectionBuilder; +import org.atsign.client.connection.protocol.AtExceptions; +import org.atsign.common.exceptions.AtSecondaryConnectException; +import org.atsign.common.exceptions.AtSecondaryNotFoundException; +import org.atsign.common.exceptions.AtTimeoutException; +import org.atsign.common.exceptions.AtUnauthenticatedException; +import org.junit.jupiter.api.*; +import org.mockito.ArgumentCaptor; +import org.slf4j.Logger; + +import lombok.Builder; +import lombok.extern.slf4j.Slf4j; + +@SuppressWarnings("unchecked") +@Slf4j +class NettyAtClientConnectionTest { + + private TestServer server; + private TestEndPointSupplier endPointSupplier; + private NettyAtClientConnectionBuilder connectionBuilder; + private TestReconnectStrategy reconnectStrategy; + + @BeforeEach + public void setup() throws Exception { + server = new TestServer(); + stubTestServerConnectAndResponseBehaviour(server); + endPointSupplier = new TestEndPointSupplier(); + connectionBuilder = NettyAtClientConnection.builder() + .endpoint(endPointSupplier) + .sslContext(server.getClientSslContext()); + reconnectStrategy = TestReconnectStrategy.testBuilder() + .maxReconnectRetries(2) + .resolveEndpointFrequency(1) + .reconnectPauseMillis(100) + .build(); + } + + @AfterEach + public void teardown() throws Exception { + server.close(); + } + + @Test + void testConnectSucceed() throws Exception { + try (NettyAtClientConnection connection = connectionBuilder.build()) { + assertThat(endPointSupplier.invocationCount, equalTo(1)); + } + } + + @Test + void testBuilderOnReadyIsInvokedOnConnection() throws Exception { + Consumer consumer = mock(Consumer.class); + connectionBuilder.onReady(consumer); + try (NettyAtClientConnection connection = connectionBuilder.build()) { + verify(consumer).accept(eq(connection)); + } + } + + @Test + void testBuilderOnReadyIsInvokedOnReconnect() throws Exception { + Consumer consumer = mock(Consumer.class); + connectionBuilder.onReady(consumer).reconnect(reconnectStrategy); + try (NettyAtClientConnection connection = connectionBuilder.build()) { + await().until(connection::isReady); + server.closeClientSocket(); + await().until(connection::isReady); + server.closeClientSocket(); + await().until(connection::isReady); + + verify(consumer, times(3)).accept(eq(connection)); + } + } + + @Test + void testOnReadyIsInvokedIfReady() throws Exception { + Consumer consumer = mock(Consumer.class); + try (NettyAtClientConnection connection = connectionBuilder.build()) { + await().until(connection::isReady); + connection.onReady(consumer); + + verify(consumer, timeout(SECONDS.toMillis(1))).accept(eq(connection)); + } + } + + @Test + void testOnReadyWhenConnectionIsEstablishedButBeforeConnectionIsReady() throws Exception { + Consumer consumer = mock(Consumer.class); + CountDownLatch latch = new CountDownLatch(1); + stubTestServerConnectAndResponseBehaviourWithConnectLatch(server, latch); + try (NettyAtClientConnection connection = connectionBuilder.build()) { + connection.onReady(consumer); + latch.countDown(); + + verify(consumer, timeout(SECONDS.toMillis(1))).accept(eq(connection)); + } + } + + @Test + void testOnReadyBeforeConnectionIsBound() throws Exception { + Consumer consumer = mock(Consumer.class); + server.closeServerSocket(); + connectionBuilder.reconnect(reconnectStrategy); + try (NettyAtClientConnection connection = connectionBuilder.build()) { + connection.onReady(consumer); + server.newServerSocket(); + + verify(consumer, timeout(SECONDS.toMillis(1))).accept(eq(connection)); + } + } + + @Test + void testOnReadyBeforeConnectionIsAccepted() throws Exception { + Consumer consumer = mock(Consumer.class); + server.closeServerSocket(); + server.newServerSocketWithoutAccept(); + connectionBuilder.reconnect(reconnectStrategy); + try (NettyAtClientConnection connection = connectionBuilder.build()) { + connection.onReady(consumer); + server.accept(); + + verify(consumer, timeout(SECONDS.toMillis(1))).accept(eq(connection)); + } + } + + @Test + void testOnReadyWhilstReadying() throws Exception { + Consumer consumer = mock(Consumer.class); + CountDownLatch latch = new CountDownLatch(1); + stubTestServerConnectAndResponseBehaviourWithRequestLatch(server, latch); + connectionBuilder.onReady(AtExceptions.throwOnReadyException(c -> c.sendSync("request"))); + try (NettyAtClientConnection connection = connectionBuilder.build()) { + connection.onReady(consumer); + latch.countDown(); + + verify(consumer, timeout(SECONDS.toMillis(1))).accept(eq(connection)); + } + } + + @Test + void testOnReadyNetworkExceptionsShouldNotBeFatalForTheConnection() throws Exception { + AtomicInteger requestCount = new AtomicInteger(); + server.setRequestHandler(s -> { + if (s == null || requestCount.incrementAndGet() > 1) { + testServerResponse(server, s); + } else { + // disconnect on first request + server.closeClientSocket(); + } + }); + connectionBuilder.reconnect(reconnectStrategy).onReady(throwOnReadyException(c -> c.sendSync("request"))); + try (NettyAtClientConnection connection = connectionBuilder.build()) { + assertThat(connection.sendSync("request2"), equalTo("response2")); + } + } + + @Test + void testOnReadyAtExceptionsShouldBeFatalForTheConnection() throws Exception { + connectionBuilder.reconnect(reconnectStrategy).onReady( + throwOnReadyException(c -> { + throw new AtUnauthenticatedException("deliberate"); + })); + Exception ex = assertThrows(Exception.class, () -> connectionBuilder.build()); + assertThat(ex.getMessage(), containsString("deliberate")); + } + + @Test + void testSendSync() throws Exception { + try (AtClientConnection connection = connectionBuilder.build()) { + assertThat(connection.sendSync("request"), equalTo("response")); + } + } + + @Test + void testSendSyncAfterClosesThrowsException() throws Exception { + try (AtClientConnection connection = connectionBuilder.build()) { + connection.close(); + Exception ex = assertThrows(Exception.class, () -> connection.sendSync("request")); + assertThat(ex.getMessage(), containsString("connection closed")); + } + } + + @Test + void testSendSyncFromOnReady() throws Exception { + List responses = new CopyOnWriteArrayList<>(); + connectionBuilder.onReady(c -> { + try { + responses.add(c.sendSync("request1")); + responses.add(c.sendSync("request2")); + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + }); + try (NettyAtClientConnection connection = connectionBuilder.build()) { + await().until(connection::isReady); + assertThat(responses, contains("response1", "response2")); + } + } + + @Test + void testSendSyncFromConsumerTriggersException() throws Exception { + stubTestServerConnectAndAndAutomaticNotification(server); + try (AtClientConnection connection = connectionBuilder.build()) { + AtomicReference ex = new AtomicReference<>(); + connection.sendSync("monitor", s -> { + try { + connection.sendSync("request"); + } catch (Exception e) { + ex.set(e); + } + }); + await().until(() -> ex.get() != null); + assertThat(ex.get().getMessage(), containsString("send on netty event thread is prohibited")); + } + } + + @Test + void testSend() throws Exception { + try (AtClientConnection connection = connectionBuilder.build()) { + assertThat(connection.send("request").get(), equalTo("response")); + } + } + + @Test + void testSendFromConsumerTriggersException() throws Exception { + stubTestServerConnectAndAndAutomaticNotification(server); + try (AtClientConnection connection = connectionBuilder.build()) { + AtomicReference ex = new AtomicReference<>(); + connection.sendSync("monitor", s -> { + try { + connection.send("request"); + } catch (Exception e) { + ex.set(e); + } + }); + await().until(() -> ex.get() != null); + assertThat(ex.get().getMessage(), containsString("send on netty event thread is prohibited")); + } + } + + @Test + void testSendFromOnReadyTriggersException() throws Exception { + connectionBuilder.onReady(c -> c.send("request")); + Exception ex = assertThrows(Exception.class, () -> connectionBuilder.build()); + assertThat(ex.getMessage(), containsString("onReady is prohibited from invoking send, use sendSync")); + } + + @Test + void testSendMultipleTimesWorksWhenNumberOfCommandsIsLessThanOrEqualToTheQueueLimit() throws Exception { + connectionBuilder.queueLimit(3); + try (AtClientConnection connection = connectionBuilder.build()) { + CompletableFuture future1 = connection.send("request1"); + CompletableFuture future2 = connection.send("request2"); + CompletableFuture future3 = connection.send("request3"); + assertThat(future1.get(), equalTo("response1")); + assertThat(future2.get(), equalTo("response2")); + assertThat(future3.get(), equalTo("response3")); + } + } + + @Test + void testSendMultipleTimesWillCompleteExceptionallyWhenQueueLimitIsBreached() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + stubTestServerConnectAndResponseBehaviourWithRequestLatch(server, latch); + connectionBuilder.queueLimit(1); + try (AtClientConnection connection = connectionBuilder.build()) { + CompletableFuture future1 = connection.send("request1"); + CompletableFuture future2 = connection.send("request2"); + CompletableFuture future3 = connection.send("request3"); + latch.countDown(); + assertThat(future1.get(), equalTo("response1")); + assertThat(future2.get(), equalTo("response2")); + ExecutionException ex = assertThrows(ExecutionException.class, future3::get); + assertThat(ex.getCause(), instanceOf(AtTimeoutException.class)); + assertThat(ex.getMessage(), containsString("queue is full")); + } + } + + @Test + void testSendWithFuture() throws Exception { + try (AtClientConnection connection = connectionBuilder.build()) { + CompletableFuture future = new CompletableFuture<>(); + connection.send("request", future); + assertThat(future.get(), equalTo("response")); + } + } + + @Test + void testSendSyncWithConsumer() throws Exception { + try (AtClientConnection connection = connectionBuilder.build()) { + List list = new CopyOnWriteArrayList<>(); + Consumer consumer = list::add; + connection.sendSync("monitor", consumer); + await().until(() -> "monitor".equals(server.poll())); + server.writeAndFlush("notification:one\n", "notification:two\n", "notification:three\n"); + await().until(() -> list.size() == 3); + assertThat(list, contains("notification:one", "notification:two", "notification:three")); + } + } + + @Test + void testSendSyncWithConsumerFromConsumerTriggersException() throws Exception { + stubTestServerConnectAndAndAutomaticNotification(server); + try (AtClientConnection connection = connectionBuilder.build()) { + AtomicReference ex = new AtomicReference<>(); + connection.sendSync("monitor", s -> { + try { + connection.sendSync("request", response -> { + }); + } catch (Exception e) { + ex.set(e); + } + }); + await().until(() -> ex.get() != null); + assertThat(ex.get().getMessage(), containsString("send on netty event thread is prohibited")); + } + } + + @Test + void testSendWithConsumerFromConsumerTriggersException() throws Exception { + stubTestServerConnectAndAndAutomaticNotification(server); + try (AtClientConnection connection = connectionBuilder.build()) { + AtomicReference ex = new AtomicReference<>(); + connection.sendSync("monitor", s -> { + try { + connection.send("request", response -> { + }, new CompletableFuture<>()); + } catch (Exception e) { + ex.set(e); + } + }); + await().until(() -> ex.get() != null); + assertThat(ex.get().getMessage(), containsString("send on netty event thread is prohibited")); + } + } + + @Test + void testSendSyncWithConsumerFromOnReady() throws Exception { + Consumer consumer = mock(Consumer.class); + stubTestServerConnectAndAndAutomaticNotification(server); + connectionBuilder.onReady(c -> { + try { + c.sendSync("monitor", consumer); + } catch (ExecutionException | InterruptedException e) { + throw new RuntimeException(e); + } + }); + try (NettyAtClientConnection connection = connectionBuilder.build()) { + await().until(connection::isReady); + verify(consumer).accept("notification:xyz"); + } + } + + @Test + void testSendWithConsumerFromOnReadyTriggersException() throws Exception { + connectionBuilder.onReady(c -> c.send("monitor", s -> { + }, new CompletableFuture<>())); + Exception ex = assertThrows(Exception.class, () -> connectionBuilder.build()); + assertThat(ex.getMessage(), containsString("onReady is prohibited from invoking send, use sendSync")); + } + + @Test + void testSendSyncWithConsumerAndFuture() throws Exception { + try (AtClientConnection connection = connectionBuilder.build()) { + CompletableFuture future = new CompletableFuture<>(); + List list = new CopyOnWriteArrayList<>(); + Consumer consumer = list::add; + connection.send("monitor", consumer, future); + future.get(); + await().until(() -> "monitor".equals(server.poll())); + server.writeAndFlush("notification:one\n", "notification:two\n", "notification:three\n"); + await().until(() -> list.size() == 3); + assertThat(list, contains("notification:one", "notification:two", "notification:three")); + } + } + + @Test + void testConnectFail() { + server.closeServerSocket(); + Exception ex = assertThrows(Exception.class, () -> connectionBuilder.build()); + assertThat(ex.getMessage(), containsString("Connection refused")); + } + + @Test + void testConnectEndpointProviderException() { + connectionBuilder.endpoint(() -> { + throw new AtSecondaryNotFoundException("deliberate"); + }); + Exception ex = assertThrows(Exception.class, () -> connectionBuilder.build()); + assertThat(ex.getMessage(), containsString("deliberate")); + } + + @Test + void testConnectRetryFail() throws Exception { + connectionBuilder.reconnect(reconnectStrategy).timeoutMillis(500L); + server.closeServerSocket(); + try (NettyAtClientConnection connection = connectionBuilder.build()) { + ExecutionException ex = assertThrows(ExecutionException.class, () -> connection.sendSync("request")); + assertThat(ex.getMessage(), containsString("reconnect retries exceeded")); + assertThat(reconnectStrategy.connectFailCount, equalTo(3)); + assertThat(reconnectStrategy.connectException.getMessage(), containsString("Connection refused")); + } + } + + @Test + void testConnectRetryFailTimeout() throws Exception { + reconnectStrategy.reconnectNoLimit(); + connectionBuilder.reconnect(reconnectStrategy).timeoutMillis(500L); + server.closeServerSocket(); + try (AtClientConnection connection = connectionBuilder.build()) { + ExecutionException ex = assertThrows(ExecutionException.class, () -> connection.sendSync("request")); + assertThat(ex.getMessage(), containsString("timed out (in queue)")); + } + } + + @Test + void testConnectRetrySucceed() throws Exception { + connectionBuilder.reconnect(reconnectStrategy).timeoutMillis(500L); + server.closeServerSocket(); + try (AtClientConnection connection = connectionBuilder.build()) { + server.newServerSocket(); + assertThat(connection.sendSync("request"), equalTo("response")); + assertThat(reconnectStrategy.connectFailCount, greaterThanOrEqualTo(1)); + assertThat(reconnectStrategy.connectException.getMessage(), containsString("Connection refused")); + } + } + + @Test + void testReconnectAfterSocketDisconnect() throws Exception { + connectionBuilder.reconnect(reconnectStrategy).timeoutMillis(500L); + try (AtClientConnection connection = connectionBuilder.build()) { + server.closeClientSocket(); + assertThat(connection.sendSync("request"), equalTo("response")); + assertThat(reconnectStrategy.disconnectCount, greaterThanOrEqualTo(1)); + assertThat(reconnectStrategy.disconnectException.getMessage(), containsString("connection closed")); + assertThat(endPointSupplier.invocationCount, equalTo(1)); + } + } + + @Test + void testReconnectAndResendPendingAfterSocketDisconnect() throws Exception { + connectionBuilder.reconnect(reconnectStrategy).timeoutMillis(500L); + stubTestServerToCloseClientSocketOnRequest(server); + try (AtClientConnection connection = connectionBuilder.build()) { + assertThat(connection.sendSync("request"), equalTo("response")); + } + } + + @Test + void testReconnectAndSendQueueAfterSocketDisconnectTriggersPendingRequestToTimeout() throws Exception { + TestClock clock = new TestClock(); + connectionBuilder.reconnect(reconnectStrategy).timeoutMillis(500L).clock(clock).queueLimit(2); + CountDownLatch latch = new CountDownLatch(1); + stubTestServerToCloseClientSocketOnRequest(server, latch, clock, 500L); + try (NettyAtClientConnection connection = connectionBuilder.build()) { + CompletableFuture future = connection.send("request"); + await().until(() -> connection.getPendingSize() == 1); + clock.advance(1); + CompletableFuture future2 = connection.send("request2"); + await().until(() -> connection.getQueuedSize() == 1); + log.info("setting latch"); + latch.countDown(); + ExecutionException ex = assertThrows(ExecutionException.class, future::get); + assertThat(ex.getCause(), instanceOf(AtTimeoutException.class)); + assertThat(future2.get(), equalTo("response2")); + } + } + + @Test + void testReconnectAfterSocketAndSocketServerDisconnect() throws Exception { + connectionBuilder.reconnect(reconnectStrategy); + try (AtClientConnection connection = connectionBuilder.build()) { + server.closeServerSocket(); + CompletableFuture future = connection.send("request"); + server.newServerSocket(); + await().until(future::isDone); + assertThat(future.get(), equalTo("response")); + assertThat(reconnectStrategy.disconnectCount, greaterThanOrEqualTo(1)); + assertThat(reconnectStrategy.disconnectException.getMessage(), containsString("connection closed")); + } + } + + @Test + void testReconnectTimeout() throws Exception { + connectionBuilder.reconnect(reconnectStrategy).timeoutMillis(500L); + ExecutionException ex = assertThrows(ExecutionException.class, () -> { + try (AtClientConnection connection = connectionBuilder.build()) { + server.closeServerSocket(); + newSingleThreadScheduledExecutor().schedule(() -> server.newServerSocket(), 5, SECONDS); + connection.sendSync("request"); + } + }); + assertThat(ex.getCause(), instanceOf(AtSecondaryConnectException.class)); + assertThat(ex.getMessage(), containsString("reconnect retries exceeded")); + } + + @Test + void testReconnectAfterSocketAndSocketServerDisconnectAndAcceptPause() throws Exception { + connectionBuilder.reconnect(reconnectStrategy).timeoutMillis(5000L); + try (AtClientConnection connection = connectionBuilder.build()) { + server.closeServerSocket(); + server.newServerSocketWithoutAccept(); + sleep(SECONDS.toMillis(1)); + server.accept(); + assertThat(connection.sendSync("request"), equalTo("response")); + assertThat(reconnectStrategy.disconnectCount, greaterThanOrEqualTo(1)); + assertThat(reconnectStrategy.disconnectException.getMessage(), containsString("connection closed")); + assertThat(endPointSupplier.invocationCount, equalTo(1)); + } + } + + @Test + void testReconnectAfterSocketAndSocketServerDisconnectAndMove() throws Exception { + connectionBuilder.reconnect(reconnectStrategy); + try (AtClientConnection connection = connectionBuilder.build()) { + server.closeServerSocket(); + CompletableFuture future = connection.send("request"); + server.newServerSocketNewPort(); + await().until(future::isDone); + assertThat(future.get(), equalTo("response")); + assertThat(reconnectStrategy.disconnectCount, greaterThanOrEqualTo(1)); + assertThat(reconnectStrategy.disconnectException.getMessage(), containsString("connection closed")); + assertThat(endPointSupplier.invocationCount, equalTo(2)); + } + } + + @Test + void testCloseCompletesPendingCommands() throws Exception { + connectionBuilder.queueLimit(2); + CountDownLatch latch = new CountDownLatch(1); + stubTestServerConnectAndResponseBehaviourWithRequestLatch(server, latch); + try (NettyAtClientConnection connection = connectionBuilder.build()) { + CompletableFuture future1 = connection.send("request1"); + CompletableFuture future2 = connection.send("request2"); + await().until(() -> connection.getPendingSize() == 1 && connection.getQueuedSize() == 1); + connection.close(); + Exception ex = assertThrows(Exception.class, future1::get); + assertThat(ex.getMessage(), containsString("connection clos")); + ex = assertThrows(Exception.class, future2::get); + assertThat(ex.getMessage(), containsString("connection clos")); + } finally { + latch.countDown(); + } + } + + @Test + void testConsumerExceptionsAreLogged() throws Exception { + stubTestServerConnectAndAndAutomaticNotification(server); + Logger logger = mock(Logger.class); + connectionBuilder.log(logger); + try (AtClientConnection connection = connectionBuilder.build()) { + connection.sendSync("monitor", s -> { + throw new RuntimeException("deliberate"); + }); + ArgumentCaptor captor = ArgumentCaptor.forClass(Throwable.class); + verify(logger).error(eq("exception in handler"), captor.capture()); + assertThat(captor.getValue().getMessage(), containsString("deliberate")); + } + } + + @Test + void testHeartbeating() throws Exception { + connectionBuilder.heartbeatMillis(250L); + try (AtClientConnection connection = connectionBuilder.build()) { + await().until(() -> server.peek() != null); + assertThat(server.poll(), equalTo("noop:0")); + await().until(() -> server.peek() != null); + assertThat(server.poll(), equalTo("noop:0")); + } + } + + private class TestEndPointSupplier implements AtEndpointSupplier { + + int invocationCount; + + @Override + public String get() { + invocationCount++; + return "localhost:" + server.getPort(); + } + } + + private static final class TestReconnectStrategy extends SimpleReconnectStrategy { + + boolean reconnectNoLimit; + Throwable connectException; + int connectFailCount; + Throwable disconnectException; + int disconnectCount; + + @Builder(builderMethodName = "testBuilder") + public TestReconnectStrategy(long maxReconnectRetries, int resolveEndpointFrequency, long reconnectPauseMillis) { + super(maxReconnectRetries, resolveEndpointFrequency, reconnectPauseMillis); + } + + @Override + public void onConnectFailure(Throwable connectException) { + connectFailCount++; + this.connectException = connectException; + super.onConnectFailure(connectException); + } + + @Override + public void onDisconnect(Throwable disconnectException) { + disconnectCount++; + this.disconnectException = disconnectException; + super.onDisconnect(disconnectException); + } + + @Override + public boolean isReconnectSupported() { + return reconnectNoLimit || super.isReconnectSupported(); + } + + public void reconnectNoLimit() { + reconnectNoLimit = true; + } + } + + private static void stubTestServerConnectAndResponseBehaviour(TestServer server) { + server.setRequestHandler(s -> testServerResponse(server, s)); + } + + private static void stubTestServerConnectAndAndAutomaticNotification(TestServer server) { + server.setRequestHandler(s -> testServerResponseWithAutomaticNotification(server, s)); + } + + private static void stubTestServerConnectAndResponseBehaviourWithRequestLatch(TestServer server, + CountDownLatch latch) { + server.setRequestHandler(request -> { + if (request != null) { + awaitLatch(latch); + } + testServerResponse(server, request); + }); + } + + private static void stubTestServerConnectAndResponseBehaviourWithConnectLatch(TestServer server, + CountDownLatch latch) { + server.setRequestHandler(request -> { + if (request == null) { + awaitLatch(latch); + } + testServerResponse(server, request); + }); + } + + private static void stubTestServerToCloseClientSocketOnRequest(TestServer server) { + stubTestServerToCloseClientSocketOnRequest(server, null, null, 0); + } + + private static void stubTestServerToCloseClientSocketOnRequest(TestServer server, + CountDownLatch latch, + TestClock clock, + long elapsedMillis) { + AtomicInteger invocationCount = new AtomicInteger(0); + server.setRequestHandler(request -> { + if (request != null && invocationCount.incrementAndGet() == 1) { + awaitLatch(latch); + log.info("deliberately closing client socket as part of test"); + server.closeClientSocket(); + if (clock != null) { + clock.advance(elapsedMillis); + } + } else { + testServerResponse(server, request); + } + }); + } + + private static void awaitLatch(CountDownLatch latch) { + if (latch != null) { + try { + latch.await(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + } + + private static void testServerResponse(TestServer server, String command) { + if (command == null) { + // invoked on connect + server.writeAndFlush("@"); + } else if (command.equals("request")) { + server.writeAndFlush("response\n@"); + } else if (command.startsWith("request")) { + String suffix = command.replace("request", ""); + server.writeAndFlush("response" + suffix + "\n@"); + } + } + + private static void testServerResponseWithAutomaticNotification(TestServer server, String command) { + if (command == null) { + // invoked on connect + server.writeAndFlush("@"); + } else if (command.equals("request")) { + server.writeAndFlush("response\n@"); + } else if (command.startsWith("request")) { + String suffix = command.replace("request", ""); + server.writeAndFlush("response" + suffix + "\n@"); + } else if (command.equals("monitor")) { + server.writeAndFlush("notification:xyz\n"); + } + } + + static class TestClock extends Clock { + + private final AtomicReference instant = new AtomicReference<>(); + + private final ZoneId zone; + + TestClock() { + this.instant.set(Instant.now()); + this.zone = ZoneId.systemDefault(); + } + + void advance(long millis) { + instant.set(instant.get().plus(Duration.ofMillis(millis))); + } + + @Override + public Instant instant() { + return instant.get(); + } + + @Override + public ZoneId getZone() { + return zone; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + } + + private static void sleep(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } +} diff --git a/at_client/src/test/java/org/atsign/client/connection/netty/NettyAtCommandEncoderTest.java b/at_client/src/test/java/org/atsign/client/connection/netty/NettyAtCommandEncoderTest.java new file mode 100644 index 00000000..56fc22b8 --- /dev/null +++ b/at_client/src/test/java/org/atsign/client/connection/netty/NettyAtCommandEncoderTest.java @@ -0,0 +1,49 @@ +package org.atsign.client.connection.netty; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.netty.buffer.ByteBuf; +import io.netty.channel.embedded.EmbeddedChannel; + +class NettyAtCommandEncoderTest { + + private EmbeddedChannel channel; + + @BeforeEach + public void setup() { + channel = new EmbeddedChannel(new NettyAtCommandEncoder()); + } + + @Test + public void testEncode() { + channel.writeOutbound("scan"); + byte[] expected = new byte[] {'s', 'c', 'a', 'n', '\n'}; + assertMatch(channel.readOutbound(), expected); + } + + @Test + public void testEncodeEmptyString() { + channel.writeOutbound(""); + byte[] expected = new byte[] {'\n'}; + assertMatch(channel.readOutbound(), expected); + } + + @Test + public void testEncodeWithNewline() { + channel.writeOutbound("scan\n"); + byte[] expected = new byte[] {'s', 'c', 'a', 'n', '\n'}; + assertMatch(channel.readOutbound(), expected); + } + + private static void assertMatch(ByteBuf buf, byte[] expected) { + assertThat(buf.readableBytes(), equalTo(expected.length)); + for (int i = 0; i < expected.length; i++) { + assertThat(buf.getByte(i), equalTo(expected[i])); + } + } + +} diff --git a/at_client/src/test/java/org/atsign/client/connection/netty/NettyAtEndpointSupplierTest.java b/at_client/src/test/java/org/atsign/client/connection/netty/NettyAtEndpointSupplierTest.java new file mode 100644 index 00000000..f996ac9b --- /dev/null +++ b/at_client/src/test/java/org/atsign/client/connection/netty/NettyAtEndpointSupplierTest.java @@ -0,0 +1,107 @@ +package org.atsign.client.connection.netty; + +import static org.atsign.common.AtSign.createAtSign; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; + +import org.atsign.common.exceptions.AtSecondaryNotFoundException; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class NettyAtEndpointSupplierTest { + + private static TestServer TEST_SERVER; + + @BeforeAll + public static void setupAll() throws Exception { + TEST_SERVER = new TestServer(); + } + + @AfterAll + public static void teardownAll() throws Exception { + TEST_SERVER.close(); + } + + @BeforeEach + public void setup() throws IOException { + TEST_SERVER.setRequestHandler(s -> { + if (s == null) { + // invoked on connect + TEST_SERVER.writeAndFlush("@"); + } else if (s.equals("colin")) { + TEST_SERVER.writeAndFlush("host:60001\r\n@"); + } else { + TEST_SERVER.writeAndFlush("null\r\n@"); + } + }); + TEST_SERVER.reset(); + } + + @Test + void testMandatoryFields() throws Exception { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> NettyAtEndpointSupplier.builder().build()); + assertThat(ex.getMessage(), containsString("atSign not set")); + ex = assertThrows(IllegalArgumentException.class, + () -> NettyAtEndpointSupplier.builder().atsign(createAtSign("colin")).build()); + assertThat(ex.getMessage(), containsString("rootUrl not set")); + } + + @Test + void testConnectAndResolve() throws Exception { + NettyAtEndpointSupplier provider = NettyAtEndpointSupplier.builder() + .rootUrl("localhost:" + TEST_SERVER.getPort()) + .atsign(createAtSign("colin")) + .sslContext(TEST_SERVER.getClientSslContext()) + .build(); + assertThat(provider.get(), equalTo("host:60001")); + } + + @Test + void testConnectAndResolveFail() throws Exception { + NettyAtEndpointSupplier provider = NettyAtEndpointSupplier.builder() + .rootUrl("localhost:" + TEST_SERVER.getPort()) + .atsign(createAtSign("gary")) + .sslContext(TEST_SERVER.getClientSslContext()) + .build(); + AtSecondaryNotFoundException ex = assertThrows(AtSecondaryNotFoundException.class, () -> provider.get()); + assertThat(ex.getMessage(), containsString("unable to resolve the endpoint for @gary")); + } + + @Test + void testResolveFailIfUnableToConnect() throws Exception { + TEST_SERVER.closeServerSocket(); + NettyAtEndpointSupplier provider = NettyAtEndpointSupplier.builder() + .rootUrl("localhost:" + TEST_SERVER.getPort()) + .atsign(createAtSign("colin")) + .sslContext(TEST_SERVER.getClientSslContext()) + .build(); + AtSecondaryNotFoundException ex = assertThrows(AtSecondaryNotFoundException.class, () -> provider.get()); + assertThat(ex.getMessage(), containsString("unable to resolve the endpoint for @colin")); + } + + @Test + void testResolveFailIfNoResponse() throws Exception { + TEST_SERVER.setRequestHandler(s -> { + if (s == null) { + // invoked on connect + TEST_SERVER.writeAndFlush("@"); + } + // but no response for anything else + }); + NettyAtEndpointSupplier provider = NettyAtEndpointSupplier.builder() + .rootUrl("localhost:" + TEST_SERVER.getPort()) + .atsign(createAtSign("colin")) + .sslContext(TEST_SERVER.getClientSslContext()) + .build(); + AtSecondaryNotFoundException ex = assertThrows(AtSecondaryNotFoundException.class, () -> provider.get()); + assertThat(ex.getMessage(), containsString("unable to resolve the endpoint for @colin")); + } + +} diff --git a/at_client/src/test/java/org/atsign/client/connection/netty/NettyAtServerResponseDecoderTest.java b/at_client/src/test/java/org/atsign/client/connection/netty/NettyAtServerResponseDecoderTest.java new file mode 100644 index 00000000..dcd981ec --- /dev/null +++ b/at_client/src/test/java/org/atsign/client/connection/netty/NettyAtServerResponseDecoderTest.java @@ -0,0 +1,140 @@ +package org.atsign.client.connection.netty; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.hamcrest.Matcher; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.netty.buffer.Unpooled; +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.util.CharsetUtil; + +class NettyAtServerResponseDecoderTest { + + private EmbeddedChannel channel; + + @BeforeEach + public void setup() { + channel = new EmbeddedChannel(new NettyAtResponseDecoder(1024)); + } + + @Test + void testDecodeInitialPrompt() { + writeInbound(channel, "@"); + assertReadInbound(channel, equalTo("@")); + } + + @Test + void testDecodeAuthenticatedPrompt() { + writeInbound(channel, "@gary@"); + assertReadInbound(channel, equalTo("@gary@")); + } + + @Test + void testDecodeResponse() { + writeInbound(channel, "data:success\n"); + assertReadInbound(channel, equalTo("data:success")); + } + + @Test + void testDecodeResponseWithCrlf() { + writeInbound(channel, "data:success\r\n"); + assertReadInbound(channel, equalTo("data:success")); + } + + @Test + void testDecodeResponseAndPrompt() { + writeInbound(channel, "data:success\n@"); + assertReadInbound(channel, equalTo("data:success"), equalTo("@")); + } + + @Test + void testDecodeResponseAndAuthenticatedPrompt() { + writeInbound(channel, "data:success\n@gary@"); + assertReadInbound(channel, equalTo("data:success"), equalTo("@gary@")); + } + + @Test + void testDecodeSequence() { + writeInbound(channel, "@data:_183dece2-5cb6\n@data:success\n@gary@"); + assertReadInbound(channel, equalTo("@"), + equalTo("data:_183dece2-5cb6"), + equalTo("@"), + equalTo("data:success"), + equalTo("@gary@")); + } + + @Test + void testDecodeSequenceWhereTheResponseIsFragmented() { + writeInbound(channel, "@data:_183dece2"); + writeInbound(channel, "-5cb6\n@data:success\n@gary@"); + assertReadInbound(channel, equalTo("@"), + equalTo("data:_183dece2-5cb6"), + equalTo("@"), + equalTo("data:success"), + equalTo("@gary@")); + } + + @Test + void testDecodeSequenceWhereTheAuthenticatedPromptIsFragmented() { + writeInbound(channel, "@data:_183dece2-5cb6\n@data:success\n@gar"); + writeInbound(channel, "y@"); + assertReadInbound(channel, equalTo("@"), + equalTo("data:_183dece2-5cb6"), + equalTo("@"), + equalTo("data:success"), + equalTo("@gary@")); + } + + @Test + void testDecodeSequenceWhereResponseContainsAnAtSymbol() { + writeInbound(channel, "@data:[\"@gary:signing_privatekey@gary\"" + + ",\"public:pkaminstalled@gary\"]\n@"); + assertReadInbound(channel, equalTo("@"), + equalTo("data:[\"@gary:signing_privatekey@gary\",\"public:pkaminstalled@gary\"]"), + equalTo("@")); + } + + @Test + void testDecodeSequenceWhereResponseContainsAnAtSymbolAndThePromptIsAuthenticated() { + writeInbound(channel, "@gary@data:[\"@gary:signing_privatekey@gary\"" + + ",\"public:pkaminstalled@gary\"]\n@gary@"); + assertReadInbound(channel, equalTo("@gary@"), + equalTo("data:[\"@gary:signing_privatekey@gary\",\"public:pkaminstalled@gary\"]"), + equalTo("@gary@")); + } + + + @Test + void testDecodeSequenceWhereResponseContainsAnAtSymbolAndThePromptIsAuthenticatedAndFragmented() { + writeInbound(channel, "@gar"); + writeInbound(channel, "y@data"); + writeInbound(channel, ":[\"@gary:signing_privatekey"); + writeInbound(channel, "@gary\",\"public:pkaminstalled@gary\"]\n@gary@"); + assertReadInbound(channel, equalTo("@gary@"), + equalTo("data:[\"@gary:signing_privatekey@gary\",\"public:pkaminstalled@gary\"]"), + equalTo("@gary@")); + } + + @Test + void testMaxFrameSize() { + EmbeddedChannel channel = new EmbeddedChannel(new NettyAtResponseDecoder(10)); + writeInbound(channel, "0123456789"); + Exception ex = assertThrows(Exception.class, () -> writeInbound(channel, "1")); + assertThat(ex.getMessage(), containsString("buffer exceeds maxFrameSize [10]")); + } + + private static void writeInbound(EmbeddedChannel channel, String s) { + channel.writeInbound(Unpooled.copiedBuffer(s, CharsetUtil.UTF_8)); + } + + private static void assertReadInbound(EmbeddedChannel channel, Matcher... matchers) { + for (Matcher matcher : matchers) { + assertThat(channel.readInbound(), matcher); + } + assertThat(channel.readInbound(), nullValue()); + } +} diff --git a/at_client/src/test/java/org/atsign/client/connection/netty/TestServer.java b/at_client/src/test/java/org/atsign/client/connection/netty/TestServer.java new file mode 100644 index 00000000..a605f273 --- /dev/null +++ b/at_client/src/test/java/org/atsign/client/connection/netty/TestServer.java @@ -0,0 +1,262 @@ +package org.atsign.client.connection.netty; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.atsign.client.util.Preconditions.checkNotNull; + +import java.io.*; +import java.math.BigInteger; +import java.net.ServerSocket; +import java.net.Socket; +import java.security.*; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLServerSocketFactory; +import javax.net.ssl.TrustManagerFactory; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; + +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class TestServer implements AutoCloseable { + + @Getter + private int port; + @Getter + private final SSLContext clientSslContext; + + private final SSLServerSocketFactory factory; + private volatile ServerSocket serverSocket; + private volatile Socket socket; + private volatile PrintWriter writer; + private volatile BufferedReader reader; + + private final BlockingQueue received = new LinkedBlockingQueue<>(); + + @Setter + private Consumer requestHandler = x -> { + }; + + private AtomicBoolean isAccept = new AtomicBoolean(true); + + private AtomicBoolean shutdown = new AtomicBoolean(); + + private final Thread workerThread; + + public TestServer() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048, new SecureRandom()); + KeyPair keyPair = kpg.generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(keyPair); + SSLContext serverCtx = buildServerSslContext(keyPair.getPrivate(), cert); + factory = serverCtx.getServerSocketFactory(); + clientSslContext = buildClientSslContext(cert); + newServerSocketNewPort(); + workerThread = new Thread(() -> { + while (!shutdown.get()) { + try { + if (serverSocket != null && socket == null && isAccept.get()) { + log.debug("accepting on {}", serverSocket.getLocalPort()); + socket = serverSocket.accept(); + writer = new PrintWriter(new BufferedWriter(new OutputStreamWriter(socket.getOutputStream())), false); + reader = new BufferedReader(new InputStreamReader(socket.getInputStream())); + requestHandler.accept(null); + } else if (socket != null) { + try { + String request = reader.readLine(); + if (request != null) { + received.add(request); + requestHandler.accept(request); + } else { + closeClientSocket(); + } + } catch (Exception e) { + closeClientSocket(); + } + } else { + if (serverSocket == null) { + log.debug("sleeping (no server socket)..."); + } else if (!isAccept.get()) { + log.debug("sleeping (server socket is not accepting)..."); + } else { + log.debug("sleeping..."); + } + sleepNoThrow(100, MILLISECONDS); + } + } catch (Exception e) { + log.debug("unexpected exception", e); + } + } + }); + workerThread.setDaemon(true); + workerThread.start(); + } + + private static void sleepNoThrow(long duration, TimeUnit unit) { + try { + Thread.sleep(unit.toMillis(duration)); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + public String poll() { + return received.poll(); + } + + public String peek() { + return received.peek(); + } + + public TestServer writeAndFlush(String... messages) { + for (String message : messages) { + try { + checkNotNull(writer).print(message); + writer.flush(); + } catch (Exception e) { + closeClientSocket(); + } + } + return this; + } + + public void closeClientSocket() { + try { + closeNoThrow(writer, reader, socket); + } finally { + socket = null; + } + } + + public void closeServerSocket() { + try { + closeNoThrow(serverSocket, socket, reader, writer); + } finally { + serverSocket = null; + socket = null; + } + } + + public void accept() { + boolean previous = isAccept.getAndSet(true); + if (!previous) { + log.info("accept is now unblocked"); + } + } + + public void newServerSocket() { + newServerSocket(true, true); + } + + public void newServerSocketWithoutAccept() { + newServerSocket(true, false); + } + + public void newServerSocketNewPort() { + newServerSocket(false, true); + } + + protected void newServerSocket(boolean reusePort, boolean autoAccept) { + if (serverSocket != null) { + throw new IllegalStateException("the server socket has not been closed"); + } + try { + isAccept.set(autoAccept); + serverSocket = factory.createServerSocket(reusePort ? port : 0); + port = serverSocket.getLocalPort(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public void reset() throws IOException { + closeNoThrow(reader, writer, socket); + socket = null; + if (serverSocket == null) { + newServerSocket(true, true); + } + received.clear(); + } + + + @Override + public void close() throws InterruptedException { + shutdown.set(true); + closeNoThrow(writer, reader, socket, serverSocket); + workerThread.join(); + } + + private static void closeNoThrow(Closeable... closeables) { + for (Closeable closeable : closeables) { + if (closeable != null) { + try { + closeable.close(); + } catch (IOException e) { + } + } + } + } + + private static X509Certificate generateSelfSignedCert(KeyPair keyPair) throws Exception { + Instant now = Instant.now(); + Instant expiry = now.plus(1, ChronoUnit.DAYS); + + X500Name subject = new X500Name("CN=MockSocketServer,O=Test,C=US"); + + X509v3CertificateBuilder builder = new JcaX509v3CertificateBuilder( + subject, + BigInteger.valueOf(System.currentTimeMillis()), + Date.from(now), + Date.from(expiry), + subject, + keyPair.getPublic()); + + ContentSigner signer = new JcaContentSignerBuilder("SHA256WithRSA") + .build(keyPair.getPrivate()); + + return new JcaX509CertificateConverter().getCertificate(builder.build(signer)); + } + + private static SSLContext buildServerSslContext(PrivateKey privateKey, + X509Certificate cert) + throws Exception { + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(null, null); + ks.setKeyEntry("mock", privateKey, new char[0], new X509Certificate[] {cert}); + + KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(ks, new char[0]); + + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(kmf.getKeyManagers(), null, new SecureRandom()); + return ctx; + } + + private static SSLContext buildClientSslContext(X509Certificate cert) throws Exception { + KeyStore ts = KeyStore.getInstance("PKCS12"); + ts.load(null, null); + ts.setCertificateEntry("mock-server", cert); + TrustManagerFactory tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + tmf.init(ts); + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(null, tmf.getTrustManagers(), new SecureRandom()); + return ctx; + } +} diff --git a/at_client/src/test/java/org/atsign/client/connection/protocol/AtExceptionsTest.java b/at_client/src/test/java/org/atsign/client/connection/protocol/AtExceptionsTest.java new file mode 100644 index 00000000..dc49556a --- /dev/null +++ b/at_client/src/test/java/org/atsign/client/connection/protocol/AtExceptionsTest.java @@ -0,0 +1,185 @@ +package org.atsign.client.connection.protocol; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import java.util.concurrent.ExecutionException; +import java.util.function.Consumer; + +import org.atsign.client.connection.api.AtClientConnection; +import org.atsign.client.connection.protocol.AtExceptions.AtClientConnectionCommand; +import org.atsign.common.AtException; +import org.atsign.common.exceptions.*; +import org.atsign.common.exceptions.AtExceptions.AtInvalidSyntaxException; +import org.atsign.common.exceptions.AtExceptions.AtServerRuntimeException; +import org.junit.jupiter.api.Test; + +public class AtExceptionsTest { + + @Test + public void testServerRuntimeExceptionMapping() { + AtException e = AtExceptions.toTypedException(AtServerRuntimeException.CODE, "msg"); + assertThat(e, instanceOf(AtServerRuntimeException.class)); + assertThat(e.getMessage(), is("msg")); + } + + @Test + public void testInvalidSyntaxExceptionMapping() { + AtException e = AtExceptions.toTypedException(AtInvalidSyntaxException.CODE, "msg"); + assertThat(e, instanceOf(AtInvalidSyntaxException.class)); + } + + @Test + public void testBufferOverflowExceptionMapping() { + AtException e = AtExceptions.toTypedException(AtBufferOverFlowException.CODE, "msg"); + assertThat(e, instanceOf(AtBufferOverFlowException.class)); + } + + @Test + public void testOutboundConnectionLimitExceptionMapping() { + AtException e = AtExceptions.toTypedException(AtOutboundConnectionLimitException.CODE, "msg"); + assertThat(e, instanceOf(AtOutboundConnectionLimitException.class)); + } + + @Test + public void testSecondaryNotFoundExceptionMapping() { + AtException e = AtExceptions.toTypedException(AtSecondaryNotFoundException.CODE, "msg"); + assertThat(e, instanceOf(AtSecondaryNotFoundException.class)); + } + + @Test + public void testHandshakeExceptionMapping() { + AtException e = AtExceptions.toTypedException(AtHandShakeException.CODE, "msg"); + assertThat(e, instanceOf(AtHandShakeException.class)); + } + + @Test + public void testUnauthorizedExceptionMapping() { + AtException e = AtExceptions.toTypedException(AtUnauthorizedException.CODE, "msg"); + assertThat(e, instanceOf(AtUnauthorizedException.class)); + } + + @Test + public void testInternalServerErrorMapping() { + AtException e = AtExceptions.toTypedException(AtInternalServerError.CODE, "msg"); + assertThat(e, instanceOf(AtInternalServerError.class)); + } + + @Test + public void testInternalServerExceptionMapping() { + AtException e = AtExceptions.toTypedException(AtInternalServerException.CODE, "msg"); + assertThat(e, instanceOf(AtInternalServerException.class)); + } + + @Test + public void testInboundConnectionLimitExceptionMapping() { + AtException e = AtExceptions.toTypedException(AtInboundConnectionLimitException.CODE, "msg"); + assertThat(e, instanceOf(AtInboundConnectionLimitException.class)); + } + + @Test + public void testBlockedConnectionExceptionMapping() { + AtException e = AtExceptions.toTypedException(AtBlockedConnectionException.CODE, "msg"); + assertThat(e, instanceOf(AtBlockedConnectionException.class)); + } + + @Test + public void testKeyNotFoundExceptionMapping() { + AtException e = AtExceptions.toTypedException(AtKeyNotFoundException.CODE, "msg"); + assertThat(e, instanceOf(AtKeyNotFoundException.class)); + } + + @Test + public void testInvalidAtKeyExceptionMapping() { + AtException e = AtExceptions.toTypedException(AtInvalidAtKeyException.CODE, "msg"); + assertThat(e, instanceOf(AtInvalidAtKeyException.class)); + } + + @Test + public void testSecondaryConnectExceptionMapping() { + AtException e = AtExceptions.toTypedException(AtSecondaryConnectException.CODE, "msg"); + assertThat(e, instanceOf(AtSecondaryConnectException.class)); + } + + @Test + public void testIllegalArgumentExceptionMapping() { + AtException e = AtExceptions.toTypedException(AtIllegalArgumentException.CODE, "msg"); + assertThat(e, instanceOf(AtIllegalArgumentException.class)); + } + + @Test + public void testTimeoutExceptionMapping() { + AtException e = AtExceptions.toTypedException(AtTimeoutException.CODE, "msg"); + assertThat(e, instanceOf(AtTimeoutException.class)); + } + + @Test + public void testServerIsPausedExceptionMapping() { + AtException e = AtExceptions.toTypedException(AtServerIsPausedException.CODE, "msg"); + assertThat(e, instanceOf(AtServerIsPausedException.class)); + } + + @Test + public void testUnauthenticatedExceptionMapping() { + AtException e = AtExceptions.toTypedException(AtUnauthenticatedException.CODE, "msg"); + assertThat(e, instanceOf(AtUnauthenticatedException.class)); + } + + @Test + public void testUnknownCodeReturnsNewErrorCodeException() { + AtException e = AtExceptions.toTypedException("UNKNOWN_CODE", "msg"); + assertThat(e, instanceOf(AtNewErrorCodeWhoDisException.class)); + } + + @Test + public void testThrowOnReadyExceptionWrapsAtException() { + AtClientConnectionCommand command = connection -> { + throw new AtUnauthorizedException("deliberate"); + }; + Consumer consumer = AtExceptions.throwOnReadyException(command); + AtClientConnection connection = mock(AtClientConnection.class); + + AtOnReadyException ex = assertThrows(AtOnReadyException.class, () -> consumer.accept(connection)); + assertThat(ex.getMessage(), containsString("deliberate")); + assertThat(ex.getCause(), instanceOf(AtUnauthorizedException.class)); + } + + @Test + public void testThrowOnReadyExceptionWrapsExecutionException() { + AtClientConnectionCommand command = connection -> { + throw new ExecutionException(new RuntimeException("deliberate")); + }; + Consumer consumer = AtExceptions.throwOnReadyException(command); + AtClientConnection connection = mock(AtClientConnection.class); + + RuntimeException ex = assertThrows(RuntimeException.class, () -> consumer.accept(connection)); + assertThat(ex.getCause(), instanceOf(ExecutionException.class)); + } + + @Test + public void testThrowOnReadyExceptionWrapsInterruptedException() { + AtClientConnectionCommand command = connection -> { + throw new InterruptedException("deliberate"); + }; + Consumer consumer = AtExceptions.throwOnReadyException(command); + AtClientConnection connection = mock(AtClientConnection.class); + + RuntimeException ex = assertThrows(RuntimeException.class, () -> consumer.accept(connection)); + assertThat(ex.getCause(), instanceOf(InterruptedException.class)); + } + + @Test + public void testThrowOnReadyExceptionInvokesWrappedCommand() + throws AtException, ExecutionException, InterruptedException { + AtClientConnectionCommand command = mock(AtClientConnectionCommand.class); + Consumer consumer = AtExceptions.throwOnReadyException(command); + AtClientConnection connection = mock(AtClientConnection.class); + + consumer.accept(connection); + + verify(command).run(connection); + } +} diff --git a/at_client/src/test/java/org/atsign/client/connection/protocol/AuthenticationTest.java b/at_client/src/test/java/org/atsign/client/connection/protocol/AuthenticationTest.java new file mode 100644 index 00000000..f0ebd1cf --- /dev/null +++ b/at_client/src/test/java/org/atsign/client/connection/protocol/AuthenticationTest.java @@ -0,0 +1,94 @@ +package org.atsign.client.connection.protocol; + +import static org.atsign.client.util.EncryptionUtil.generateRSAKeyPair; +import static org.atsign.client.util.EnrollmentId.createEnrollmentId; +import static org.atsign.common.AtSign.createAtSign; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.atsign.client.api.AtKeys; +import org.atsign.client.connection.api.AtClientConnection; +import org.atsign.common.exceptions.AtOnReadyException; +import org.atsign.common.exceptions.AtUnauthenticatedException; +import org.junit.jupiter.api.Test; + +public class AuthenticationTest { + + @Test + public void testAuthenticateWithCramDoesNotThrowException() throws Exception { + AtClientConnection conn = TestConnectionBuilder.builder() + .stub("from:@alice", "data:challenge") + .stub("cram:7e91508d5.+", "data:success") + .build(); + + Authentication.authenticateWithCram(conn, createAtSign("@alice"), "secret"); + } + + @Test + public void testAuthenticateWithCramFailThrowsExpectedException() throws Exception { + AtClientConnection conn = TestConnectionBuilder.builder() + .stub("from:@alice", "data:challenge") + .stub("cram:.+", "error:AT0401:deliberate") + .build(); + + Exception ex = assertThrows(AtUnauthenticatedException.class, + () -> Authentication.authenticateWithCram(conn, createAtSign("@alice"), "secret")); + assertThat(ex.getMessage(), containsString("deliberate")); + } + + @Test + public void testAuthenticateWithPkamDoesNotThrowException() throws Exception { + AtKeys keys = AtKeys.builder().apkamKeyPair(generateRSAKeyPair()).build(); + AtClientConnection conn = TestConnectionBuilder.builder() + .stub("from:@alice", "data:challenge") + .stub("pkam:[^{].+", "data:success") + .build(); + + Authentication.authenticateWithPkam(conn, createAtSign("@alice"), keys); + } + + @Test + public void testAuthenticateWithApkamWithEnrollmentId() throws Exception { + AtKeys keys = AtKeys.builder().apkamKeyPair(generateRSAKeyPair()).enrollmentId(createEnrollmentId("12345")).build(); + AtClientConnection conn = TestConnectionBuilder.builder() + .stub("from:@alice", "data:challenge") + .stub("pkam:signingAlgo:rsa2048:hashingAlgo:sha256:enrollmentId:12345:.+", "data:success") + .build(); + + Authentication.authenticateWithPkam(conn, createAtSign("@alice"), keys); + } + + @Test + public void testAuthenticateWithApkamFailThrowsExpectedException() throws Exception { + AtKeys keys = AtKeys.builder().apkamKeyPair(generateRSAKeyPair()).enrollmentId(createEnrollmentId("12345")).build(); + AtClientConnection conn = TestConnectionBuilder.builder() + .stub("from:@alice", "data:challenge") + .stub("pkam:.+", "error:AT0401:deliberate") + .build(); + + Exception ex = assertThrows(AtUnauthenticatedException.class, + () -> Authentication.authenticateWithPkam(conn, createAtSign("@alice"), keys)); + assertThat(ex.getMessage(), containsString("deliberate")); + } + + @Test + public void testPkamAuthenticatorThrowsOnReadyException() throws Exception { + AtKeys keys = AtKeys.builder().apkamKeyPair(generateRSAKeyPair()).enrollmentId(createEnrollmentId("12345")).build(); + AtClientConnection conn = TestConnectionBuilder.builder() + .stub("from:@alice", "data:challenge") + .stub("pkam:.+", "error:AT0401:deliberate") + .build(); + + Exception ex = assertThrows(Exception.class, + () -> Authentication.pkamAuthenticator(createAtSign("@alice"), keys).accept(conn)); + assertThat(ex, instanceOf(AtOnReadyException.class)); + assertThat(ex.getMessage(), containsString("deliberate")); + } + + @Test + public void testBytesToHex() throws Exception { + byte[] bytes = new byte[] {(byte) 0x00, (byte) 0x0f, (byte) 0xff}; + assertThat(Authentication.bytesToHex(bytes), is("000fff")); + } +} diff --git a/at_client/src/test/java/org/atsign/client/connection/protocol/DataTest.java b/at_client/src/test/java/org/atsign/client/connection/protocol/DataTest.java new file mode 100644 index 00000000..4adaa8b3 --- /dev/null +++ b/at_client/src/test/java/org/atsign/client/connection/protocol/DataTest.java @@ -0,0 +1,99 @@ +package org.atsign.client.connection.protocol; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Map; + +import org.atsign.common.Metadata; +import org.atsign.common.response_models.LookupResponse; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; + +public class DataTest { + + @Test + public void testMatchDataSuccess() { + assertThat(Data.matchDataSuccess("data:success"), is("success")); + } + + @Test + public void testMatchDataSuccessThrowException() { + Exception ex = assertThrows(Exception.class, () -> Data.matchDataSuccess("error:epicfail")); + assertThat(ex.getMessage(), containsString("expected [data:(success)] but input was : error:epicfail")); + } + + @Test + public void testMatchDataStringNoWhitespace() { + assertThat(Data.matchDataStringNoWhitespace("data:somestring"), is("somestring")); + } + + @Test + public void testMatchDataStringNoWhitespaceThrowException() { + assertThrows(Exception.class, () -> Data.matchDataStringNoWhitespace("data:some string with spaces")); + } + + @Test + public void testMatchData() { + assertThat(Data.matchData("data:some string"), is("some string")); + assertThat(Data.matchData("data:{\"member\":value}"), is("{\"member\":value}")); + } + + @Test + public void testMatchDataInt() { + assertThat(Data.matchDataInt("data:42"), is(42)); + } + + @Test + public void testMatchDataNegativeInt() { + assertThat(Data.matchDataInt("data:-42"), is(-42)); + } + + @Test + public void testMatchDataJsonList() { + assertThat(Data.matchDataJsonList("data:[1,2,3]"), contains(1, 2, 3)); + } + + @Test + public void testMatchDataJsonListOfStrings() { + assertThat(Data.matchDataJsonListOfStrings("data:[\"a\",\"b\"]"), contains("a", "b")); + } + + @Test + public void testMatchDataJsonMapOfStringsNonEmpty() { + assertThat(Data.matchDataJsonMapOfStrings("data:{\"k\":\"v\"}"), hasEntry("k", "v")); + } + + @Test + public void testMatchDataJsonMapOfStringsAllowEmpty() { + Map result = Data.matchDataJsonMapOfStrings("data:{}", true); + assertThat(result.entrySet(), Matchers.empty()); + } + + @Test + public void testMatchDataJsonMapOfObjectsNonEmpty() { + Map result = Data.matchDataJsonMapOfObjects("data:{\"k\":42}"); + assertThat(result.get("k"), is(42)); + } + + @Test + public void testMatchDataJsonMapOfObjectsAllowEmpty() { + Map result = Data.matchDataJsonMapOfObjects("data:{}", true); + assertThat(result.isEmpty(), is(true)); + } + + @Test + public void testMatchLookupResponse() { + String json = "data:{\"data\":\"xyz\"}"; + LookupResponse result = Data.matchLookupResponse(json); + assertThat(result.data, is("xyz")); + } + + @Test + public void testMatchMetadata() { + String json = "data:{\"ttr\":0}"; + Metadata result = Data.matchMetadata(json); + assertThat(result.ttr(), is(0L)); + } +} diff --git a/at_client/src/test/java/org/atsign/client/connection/protocol/EnrollTest.java b/at_client/src/test/java/org/atsign/client/connection/protocol/EnrollTest.java new file mode 100644 index 00000000..dc352a4c --- /dev/null +++ b/at_client/src/test/java/org/atsign/client/connection/protocol/EnrollTest.java @@ -0,0 +1,250 @@ +package org.atsign.client.connection.protocol; + +import static java.lang.String.format; +import static java.util.Collections.singletonMap; +import static org.atsign.client.util.EncryptionUtil.*; +import static org.atsign.client.util.EnrollmentId.createEnrollmentId; +import static org.atsign.common.AtSign.createAtSign; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; + +import org.atsign.client.api.AtKeys; +import org.atsign.client.connection.api.AtClientConnection; +import org.atsign.client.util.EnrollmentId; +import org.atsign.common.AtException; +import org.atsign.common.AtSign; +import org.atsign.common.exceptions.AtExceptions.AtServerRuntimeException; +import org.junit.jupiter.api.Test; + +public class EnrollTest { + + public static final String RSA_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAi0ZpMHagomCnC6MX" + + "GIRbG9our0NsmPsSPLKSpL/BbJU8nMqwbsSVlDoW9LX8h8yyLeMDC/AsrHjF3jviXWEYNQhZTZohPP" + + "ioFSEMUHv9N1OC67KoMvZNRU1hEKe2kOaXUQl/Bud257QKl4I9bxr7L1qZD+TPrzclKGKgkKtWDB6M" + + "+nlql3wFUGRny3RWEjNvLdv0XsIfQwQPO16dZ9vYSXHTiFueqRuTu+HjBVjWudnV2eERpdMq5Hg+Mst" + + "TYE1uCPuLR5+dcn6UaW1b2H6X9o4ps46BMKLr+xa+/E0EBdAQIhd0QP5O3/ZNLbhJnn7jB+QokmNKoKVN55p7yu0QdwIDAQAB"; + + public static final String AES_KEY = "U1xSuG5yjRTolsqoXFcHaSw4al0UJAiWF9Ae3wQ20uM="; + + public static final String IV = "9OD1f3XTbZjz0fWJcw/5Ww=="; + + + @Test + public void testDeleteCramSecretDoesNotThrowException() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("delete:privatekey:at_secret", "data:1") + .build(); + + Enroll.deleteCramSecret(connection); + } + + @Test + public void testDeleteCramSecretThrowsException() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("delete:privatekey:at_secret", "error:AT0001:deliberate") + .build(); + + assertThrows(AtServerRuntimeException.class, () -> Enroll.deleteCramSecret(connection)); + } + + @Test + public void testOnboardThrowsExceptionIfSigningPublicKeyIsMissing() throws Exception { + AtSign atSign = createAtSign("@alice"); + AtKeys keys = AtKeys.builder() + .apkamKeyPair(generateRSAKeyPair()) + .encryptKeyPair(generateRSAKeyPair()) + .build(); + + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("scan", "data:[\"signing_publickey@gary\"]") + .build(); + + Exception ex = assertThrows(Exception.class, + () -> Enroll.onboard(connection, atSign, keys, "secret", "app", "device", false)); + assertThat(ex.getMessage(), containsString("not connected to the atsign's at server")); + } + + @Test + void testOtpSendsExpectedCommandAndMatchesExpectedResponse() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("otp:get", "data:ABC123") + .build(); + + assertThat(Enroll.otp(connection), equalTo("ABC123")); + } + + @Test + void testOtpThrowsExceptionOnError() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("otp:get", "error:AT0013:deliberate") + .build(); + + assertThrows(AtException.class, () -> Enroll.otp(connection)); + } + + @Test + public void testOnboard() throws Exception { + AtSign atSign = createAtSign("@alice"); + AtKeys keys = AtKeys.builder() + .apkamKeyPair(generateRSAKeyPair()) + .encryptKeyPair(generateRSAKeyPair()) + .build(); + + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("scan", "data:[\"signing_publickey@alice\"]") + .stub("from:@alice", "data:challenge") + .stub("cram:7e91508d5.+", "data:success") + .stub("enroll:request.+", "data:{\"enrollmentId\":\"904dcbf7\",\"status\":\"approved\"}") + .stub("pkam:[^{].+", "data:success") + .stub("update:public:publickey@alice .+", "data:1") + .build(); + + AtKeys newKeys = Enroll.onboard(connection, atSign, keys, "secret", "app", "device", false); + + assertThat(newKeys, is(not(sameInstance(keys)))); + assertThat(newKeys.getEnrollmentId(), equalTo(createEnrollmentId("904dcbf7"))); + } + + @Test + public void testEnroll() throws Exception { + AtSign atSign = createAtSign("@alice"); + AtKeys keys = AtKeys.builder() + .apkamKeyPair(generateRSAKeyPair()) + .apkamSymmetricKey(generateAESKeyBase64()) + .build(); + + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("lookup:publickey@alice", "data:" + RSA_KEY) + .stub("enroll:request\\{.+}", "data:{\"enrollmentId\":\"759acb09\",\"status\":\"pending\"}") + .build(); + + AtKeys newKeys = Enroll.enroll(connection, atSign, keys, "OTP123", "app", "device", singletonMap("ns", "rw")); + + assertThat(newKeys, is(not(sameInstance(keys)))); + assertThat(newKeys.getEnrollmentId(), equalTo(createEnrollmentId("759acb09"))); + assertThat(newKeys.getEncryptPublicKey(), notNullValue()); + } + + + @Test + public void testComplete() throws Exception { + AtSign atSign = createAtSign("@alice"); + AtKeys keys = AtKeys.builder() + .apkamKeyPair(generateRSAKeyPair()) + .apkamSymmetricKey(generateAESKeyBase64()) + .enrollmentId(createEnrollmentId("12345")) + .build(); + + String selfEncryptKeysGetResponse = format("data:{\"value\":\"%s\",\"iv\":\"%s\"}", + aesEncryptToBase64(AES_KEY, keys.getApkamSymmetricKey(), IV), IV); + String privateEncryptKeysGetResponse = format("data:{\"value\":\"%s\",\"iv\":\"%s\"}", + aesEncryptToBase64(RSA_KEY, keys.getApkamSymmetricKey(), IV), IV); + + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("from:@alice", "data:challenge") + .stub("pkam:[^{].+", "data:success") + .stub("keys:get:keyName:12345.default_self_enc_key.__manage@alice", selfEncryptKeysGetResponse) + .stub("keys:get:keyName:12345.default_enc_private_key.__manage@alice", privateEncryptKeysGetResponse) + .build(); + + AtKeys newKeys = Enroll.complete(connection, atSign, keys); + + assertThat(newKeys, is(not(sameInstance(keys)))); + assertThat(newKeys.getEncryptPrivateKey(), notNullValue()); + assertThat(newKeys.getSelfEncryptKey(), notNullValue()); + } + + @Test + public void testApprove() throws Exception { + AtKeys keys = AtKeys.builder() + .encryptKeyPair(generateRSAKeyPair()) + .selfEncryptKey(generateAESKeyBase64()) + .build(); + + String fetchResponse = format("data:{\"appName\":\"app1\",\"deviceName\":\"device1\"," + + "\"namespace\":{\"ns\":\"rw\"},\"encryptedAPKAMSymmetricKey\":\"%s\",\"status\":\"pending\"}", + rsaEncryptToBase64(AES_KEY, keys.getEncryptPublicKey())); + + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("enroll:fetch\\{\"enrollmentId\":\"12345\"}", fetchResponse) + .stub("enroll:approve\\{\"enrollmentId\":\"12345\".+", + "data:{\"status\":\"approved\",\"enrollmentId\":\"12345\"}") + .build(); + + Enroll.approve(connection, keys, createEnrollmentId("12345")); + } + + @Test + public void testDeny() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("enroll:deny\\{\"enrollmentId\":\"12345\".+", + "data:{\"status\":\"denied\",\"enrollmentId\":\"12345\"}") + .build(); + + Enroll.deny(connection, createEnrollmentId("12345")); + } + + @Test + public void testRevoke() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("enroll:revoke\\{\"enrollmentId\":\"12345\".+", + "data:{\"status\":\"revoked\",\"enrollmentId\":\"12345\"}") + .build(); + + Enroll.revoke(connection, createEnrollmentId("12345")); + } + + @Test + public void testUnrevoke() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("enroll:unrevoke\\{\"enrollmentId\":\"12345\".+", + "data:{\"status\":\"approved\",\"enrollmentId\":\"12345\"}") + .build(); + + Enroll.unrevoke(connection, createEnrollmentId("12345")); + } + + @Test + public void testDelete() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("enroll:delete\\{\"enrollmentId\":\"12345\".+", + "data:{\"status\":\"deleted\",\"enrollmentId\":\"12345\"}") + .build(); + + Enroll.delete(connection, createEnrollmentId("12345")); + } + + @Test + public void testList() throws Exception { + String response = "data:{" + + " \"bc8bfdf3-eadd-4373-b0cc-a9c8a52c96c5.new.enrollments.__manage@alice\": {" + + " \"sessionId\": \"_e425ba99-88d7-4382-b40d-aeb61ec77ee6\"," + + " \"appName\": \"app\"," + + " \"deviceName\": \"device\"," + + " \"namespaces\": {" + + " \"fredns\": \"rw\"" + + " }," + + " \"apkamPublicKey\": \"\"," + + " \"requestType\": \"newEnrollment\"," + + " \"approval\": {" + + " \"state\": \"pending\"" + + " }," + + " \"encryptedAPKAMSymmetricKey\": \"\"," + + " \"apkamKeysExpiryInMillis\": 0," + + " \"status\": \"pending\"," + + " \"namespace\": {" + + " \"fredns\": \"rw\"" + + " }}}"; + + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("enroll:list\\{\"enrollmentStatusFilter\":\\[\"pending\"]}", response) + .build(); + + List ids = Enroll.list(connection, "pending"); + assertThat(ids, contains(createEnrollmentId("bc8bfdf3-eadd-4373-b0cc-a9c8a52c96c5"))); + } + +} diff --git a/at_client/src/test/java/org/atsign/client/connection/protocol/ErrorTest.java b/at_client/src/test/java/org/atsign/client/connection/protocol/ErrorTest.java new file mode 100644 index 00000000..9754ad9c --- /dev/null +++ b/at_client/src/test/java/org/atsign/client/connection/protocol/ErrorTest.java @@ -0,0 +1,39 @@ +package org.atsign.client.connection.protocol; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.atsign.common.AtException; +import org.atsign.common.exceptions.AtUnauthenticatedException; +import org.junit.jupiter.api.Test; + +class ErrorTest { + + @Test + public void testError() { + assertThat(Error.matchError("error:fail"), is("fail")); + assertThat(Error.matchError("error:some reason"), is("some reason")); + assertThat(Error.matchError("error:AT0401:an error message"), is("AT0401:an error message")); + } + + @Test + public void testThrowExceptionIfErrorDoesNothingIfNoError() throws AtException { + Error.throwExceptionIfError("data:success"); + Error.throwExceptionIfError(""); + } + + @Test + public void testThrowExceptionIfErrorThrowsTypedException() { + Exception ex = assertThrows(Exception.class, () -> Error.throwExceptionIfError("error:AT0401:an error message")); + assertThat(ex, instanceOf(AtUnauthenticatedException.class)); + assertThat(ex.getMessage(), containsString("an error message")); + } + + @Test + public void testThrowExceptionIfErrorThrowsExceptionIfNull() { + Exception ex = assertThrows(Exception.class, () -> Error.throwExceptionIfError(null)); + assertThat(ex, instanceOf(IllegalArgumentException.class)); + } + +} diff --git a/at_client/src/test/java/org/atsign/client/connection/protocol/KeysTest.java b/at_client/src/test/java/org/atsign/client/connection/protocol/KeysTest.java new file mode 100644 index 00000000..e87e6c49 --- /dev/null +++ b/at_client/src/test/java/org/atsign/client/connection/protocol/KeysTest.java @@ -0,0 +1,94 @@ +package org.atsign.client.connection.protocol; + +import static org.atsign.common.AtSign.createAtSign; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; + +import java.util.List; + +import org.atsign.client.connection.api.AtClientConnection; +import org.junit.jupiter.api.Test; + +class KeysTest { + + @Test + void testDeleteKeyRawKey() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("delete:selfkey1@alice", "data:1") + .build(); + + Keys.deleteKey(connection, "selfkey1@alice"); + } + + @Test + void testDeleteKeyRawKeyThrowsExceptionIfResponseIsError() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("delete:selfkey1@alice", "error:AT0001:an error message") + .build(); + + assertThrows(Exception.class, () -> Keys.deleteKey(connection, "selfkey1@alice")); + } + + @Test + void testDeleteKey() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("delete:keyname@colin", "data:1") + .build(); + + org.atsign.common.Keys.SelfKey key = org.atsign.common.Keys.selfKeyBuilder() + .name("keyname") + .sharedBy(createAtSign("colin")) + .build(); + Keys.deleteKey(connection, key); + + verify(connection).sendSync("delete:keyname@colin"); + } + + @Test + void getKeysWithMetaDataReturnsExpectedResults() throws Exception { + + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("scan:showHidden:true .+", "data:[\"public:publickey@gary\",\"public:signing_publickey@gary\"]") + .stub("llookup:meta:public:publickey@gary", "data:{\"ttl\":1000}") + .stub("llookup:meta:public:signing_publickey@gary", "data:{\"ttl\":2000}") + .build(); + + List result = Keys.getKeys(connection, ".+", true); + + assertThat(result.size(), equalTo(2)); + assertThat(result.get(0).rawKey(), equalTo("public:publickey@gary")); + assertThat(result.get(0).name(), equalTo("publickey")); + assertThat(result.get(0).sharedBy(), equalTo(createAtSign("gary"))); + assertThat(result.get(0).metadata().ttl(), equalTo(1000L)); + + assertThat(result.get(1).rawKey(), equalTo("public:signing_publickey@gary")); + assertThat(result.get(1).name(), equalTo("signing_publickey")); + assertThat(result.get(1).sharedBy(), equalTo(createAtSign("gary"))); + assertThat(result.get(1).metadata().ttl(), equalTo(2000L)); + } + + @Test + void getKeysWithoutMetaDataReturnsExpectedResults() throws Exception { + + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("scan:showHidden:true .+", "data:[\"public:publickey@gary\",\"public:signing_publickey@gary\"]") + .build(); + + List result = Keys.getKeys(connection, ".*gary", false); + + assertThat(result.size(), equalTo(2)); + assertThat(result.get(0).rawKey(), equalTo("public:publickey@gary")); + assertThat(result.get(0).name(), equalTo("publickey")); + assertThat(result.get(0).sharedBy(), equalTo(createAtSign("gary"))); + assertThat(result.get(0).metadata().ttl(), nullValue()); + + assertThat(result.get(1).rawKey(), equalTo("public:signing_publickey@gary")); + assertThat(result.get(1).name(), equalTo("signing_publickey")); + assertThat(result.get(1).sharedBy(), equalTo(createAtSign("gary"))); + assertThat(result.get(1).metadata().ttl(), nullValue()); + } + +} diff --git a/at_client/src/test/java/org/atsign/client/connection/protocol/NotificationsTest.java b/at_client/src/test/java/org/atsign/client/connection/protocol/NotificationsTest.java new file mode 100644 index 00000000..b7446a3d --- /dev/null +++ b/at_client/src/test/java/org/atsign/client/connection/protocol/NotificationsTest.java @@ -0,0 +1,184 @@ +package org.atsign.client.connection.protocol; + +import static org.atsign.client.util.EncryptionUtil.generateRSAKeyPair; +import static org.atsign.common.AtSign.createAtSign; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.function.Consumer; + +import org.atsign.client.api.AtEvents; +import org.atsign.client.api.AtKeys; +import org.atsign.client.connection.api.AtClientConnection; +import org.atsign.common.AtSign; +import org.atsign.common.exceptions.AtOnReadyException; +import org.atsign.common.exceptions.AtTimeoutException; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.Mockito; +import org.mockito.stubbing.Answer; + +class NotificationsTest { + + @Test + void testMonitorSendExpectedCommands() throws Exception { + AtSign atSign = createAtSign("colin"); + AtKeys keys = AtKeys.builder().apkamKeyPair(generateRSAKeyPair()).build(); + AtClientConnection conn = mock(AtClientConnection.class); + stubAuthentication(conn, atSign); + + Consumer consumer = mock(Consumer.class); + Notifications.monitor(conn, atSign, keys, consumer); + + verify(conn).sendSync(eq("monitor"), eq(consumer)); + } + + @Test + void testMonitorWrapsConsumer() throws Exception { + AtSign atSign = createAtSign("colin"); + AtKeys keys = AtKeys.builder().apkamKeyPair(generateRSAKeyPair()).build(); + AtClientConnection conn = mock(AtClientConnection.class); + stubAuthentication(conn, atSign); + Consumer consumer = mock(Consumer.class); + doAnswer((Answer) invocation -> { + throw new AtTimeoutException("deliberate"); + }).when(conn).sendSync(eq("monitor"), Mockito.any(Consumer.class)); + + Exception ex = assertThrows(Exception.class, () -> Notifications.monitor(atSign, keys, consumer).accept(conn)); + assertThat(ex, instanceOf(AtOnReadyException.class)); + } + + @Test + void testMatchNotification() { + Map eventData = Notifications.matchNotification("notification:{\"id\":\"-1\",\"from\":\"@gary\"}"); + assertThat(eventData.get("id"), equalTo("-1")); + assertThat(eventData.get("from"), equalTo("@gary")); + } + + @Test + void testMatchNotificationThrowsException() { + Exception ex = assertThrows(Exception.class, () -> Notifications.matchNotification("data:ok")); + assertThat(ex.getMessage(), containsString("expected [notification:\\s*(\\{.+})] but input was : data:ok")); + } + + @Test + void testEventBusBridgePublishesExpectedEventForStatsNotification() { + AtEvents.AtEventBus eventBus = mock(AtEvents.AtEventBus.class); + Notifications.EventBusBridge consumer = new Notifications.EventBusBridge(eventBus, createAtSign("colin")); + + consumer.accept("notification: {\"id\":\"-1\",\"from\":\"@gary\",\"to\":\"@gary\"" + + ",\"key\":\"statsNotification.@gary\",\"value\":\"229\"" + + ",\"operation\":\"update\",\"epochMillis\":100000,\"messageType\":\"MessageType.key\"" + + ",\"isEncrypted\":false,\"metadata\":null}"); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(eventBus).publishEvent(eq(AtEvents.AtEventType.statsNotification), captor.capture()); + assertThat(captor.getValue().get("id"), equalTo("-1")); + assertThat(captor.getValue().get("operation"), equalTo("update")); + assertThat(captor.getValue().get("epochMillis"), equalTo(100000)); + } + + @Test + void testEventBusBridgePublishesExpectedEventForSharedKeyNotification() { + AtEvents.AtEventBus eventBus = mock(AtEvents.AtEventBus.class); + Notifications.EventBusBridge consumer = new Notifications.EventBusBridge(eventBus, createAtSign("gary")); + + consumer.accept("notification: {\"id\":\"0480060d\",\"from\":\"@colin\"" + + ",\"to\":\"@gary\",\"key\":\"@gary:shared_key@colin\"" + + ",\"value\":\"xxx==\",\"operation\":\"update\",\"epochMillis\":1773077019848" + + ",\"messageType\":\"MessageType.key\",\"isEncrypted\":false,\"metadata\":{\"encKeyName\":null" + + ",\"encAlgo\":null,\"ivNonce\":null,\"skeEncKeyName\":null,\"skeEncAlgo\":null,\"sharedKeyEnc\":null" + + ",\"pubKeyCS\":null,\"dataSignature\":null,\"pubKeyHash\":null}}"); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(eventBus).publishEvent(eq(AtEvents.AtEventType.sharedKeyNotification), captor.capture()); + assertThat(captor.getValue().get("id"), equalTo("0480060d")); + assertThat(captor.getValue().get("operation"), equalTo("update")); + assertThat(captor.getValue().get("epochMillis"), equalTo(1773077019848L)); + } + + @Test + void testEventBusBridgePublishesExpectedEventForUpdateNotification() { + AtEvents.AtEventBus eventBus = mock(AtEvents.AtEventBus.class); + Notifications.EventBusBridge consumer = new Notifications.EventBusBridge(eventBus, createAtSign("gary")); + + consumer.accept("notification: {\"id\":\"cc72371c\",\"from\":\"@colin\",\"to\":\"@gary\"" + + ",\"key\":\"@gary:test@colin\",\"value\":\"C8gg7hDuJ4BVk6hrgu2GCQ==\",\"operation\":\"update\"" + + ",\"epochMillis\":1773077220149,\"messageType\":\"MessageType.key\",\"isEncrypted\":true" + + ",\"metadata\":{\"encKeyName\":null,\"encAlgo\":null,\"ivNonce\":\"yeZOQZleN6sXeVUuajn12w==\"" + + ",\"skeEncKeyName\":null,\"skeEncAlgo\":null,\"sharedKeyEnc\":null,\"pubKeyCS\":null" + + ",\"dataSignature\":null,\"pubKeyHash\":null}}"); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(eventBus).publishEvent(eq(AtEvents.AtEventType.updateNotification), captor.capture()); + assertThat(captor.getValue().get("id"), equalTo("cc72371c")); + assertThat(captor.getValue().get("operation"), equalTo("update")); + assertThat(captor.getValue().get("epochMillis"), equalTo(1773077220149L)); + } + + @Test + void testEventBusBridgeCatchesUnexpectedExceptions() { + AtEvents.AtEventBus eventBus = mock(AtEvents.AtEventBus.class); + Notifications.EventBusBridge consumer = new Notifications.EventBusBridge(eventBus, createAtSign("gary")); + + consumer.accept("xyz"); + + verifyNoInteractions(eventBus); + } + + @Test + void testEventBusBridgePublishesExpectedEventForDeleteNotification() { + AtEvents.AtEventBus eventBus = mock(AtEvents.AtEventBus.class); + Notifications.EventBusBridge consumer = new Notifications.EventBusBridge(eventBus, createAtSign("gary")); + + consumer.accept("notification: {\"id\":\"41b265d8\",\"from\":\"@colin\",\"to\":\"@gary\"" + + ",\"key\":\"@gary:test@colin\",\"value\":null,\"operation\":\"delete\",\"epochMillis\":1773077405537" + + ",\"messageType\":\"MessageType.key\",\"isEncrypted\":true,\"metadata\":{\"encKeyName\":null" + + ",\"encAlgo\":null,\"ivNonce\":null,\"skeEncKeyName\":null,\"skeEncAlgo\":null,\"sharedKeyEnc\":null" + + ",\"pubKeyCS\":null,\"dataSignature\":null,\"pubKeyHash\":null}}"); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(eventBus).publishEvent(eq(AtEvents.AtEventType.deleteNotification), captor.capture()); + assertThat(captor.getValue().get("id"), equalTo("41b265d8")); + assertThat(captor.getValue().get("operation"), equalTo("delete")); + assertThat(captor.getValue().get("epochMillis"), equalTo(1773077405537L)); + } + + @Test + void testEventBusBridgePublishesExpectedEventForUnrecognizedNotification() { + AtEvents.AtEventBus eventBus = mock(AtEvents.AtEventBus.class); + Notifications.EventBusBridge consumer = new Notifications.EventBusBridge(eventBus, createAtSign("gary")); + + consumer.accept("notification: {\"id\":\"41b265d8\",\"from\":\"@colin\",\"to\":\"@gary\"" + + ",\"key\":\"@gary:test@colin\",\"value\":null,\"operation\":\"unrecognized\",\"epochMillis\":1773077405537" + + ",\"messageType\":\"MessageType.key\",\"isEncrypted\":true,\"metadata\":{\"encKeyName\":null" + + ",\"encAlgo\":null,\"ivNonce\":null,\"skeEncKeyName\":null,\"skeEncAlgo\":null,\"sharedKeyEnc\":null" + + ",\"pubKeyCS\":null,\"dataSignature\":null,\"pubKeyHash\":null}}"); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(eventBus).publishEvent(eq(AtEvents.AtEventType.monitorException), captor.capture()); + assertThat(captor.getValue().get("key"), equalTo("__monitorException__")); + assertThat(captor.getValue().get("exception"), equalTo("unknown notification operation 'unrecognized'")); + } + + private static void stubAuthentication(AtClientConnection conn, AtSign atSign) + throws ExecutionException, InterruptedException { + when(conn.sendSync(anyString())).thenAnswer((Answer) invocation -> { + String command = invocation.getArgument(0); + if (command.matches("from:" + atSign)) { + return "data:challenge"; + } else if (command.matches("pkam:[^{].+")) { + return "data:success"; + } else { + throw new IllegalArgumentException("unexpected command : " + command); + } + }); + } + + +} diff --git a/at_client/src/test/java/org/atsign/client/connection/protocol/PublicKeysTest.java b/at_client/src/test/java/org/atsign/client/connection/protocol/PublicKeysTest.java new file mode 100644 index 00000000..5f7996da --- /dev/null +++ b/at_client/src/test/java/org/atsign/client/connection/protocol/PublicKeysTest.java @@ -0,0 +1,174 @@ +package org.atsign.client.connection.protocol; + +import static org.atsign.common.AtSign.createAtSign; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.concurrent.ExecutionException; + +import org.atsign.client.api.AtKeys; +import org.atsign.client.connection.api.AtClientConnection; +import org.atsign.client.util.EncryptionUtil; +import org.atsign.common.Keys; +import org.atsign.common.exceptions.AtKeyNotFoundException; +import org.atsign.common.exceptions.AtExceptions.AtServerRuntimeException; +import org.atsign.common.options.GetRequestOptions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class PublicKeysTest { + + private Keys.PublicKey key; + + private AtKeys keys; + + @BeforeEach + public void setup() throws Exception { + keys = AtKeys.builder() + .encryptKeyPair(EncryptionUtil.generateRSAKeyPair()) + .build(); + key = Keys.publicKeyBuilder() + .sharedBy(createAtSign("gary")) + .name("test") + .build(); + } + + @Test + void testGetSharedByMe() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("llookup:all:public:test@gary", createMockLookupResponse("public:test@gary", "hello world")) + .build(); + + String actual = PublicKeys.get(connection, createAtSign("gary"), key, null); + + assertThat(actual, equalTo("hello world")); + } + + @Test + void testGetCachedSharedByMe() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("llookup:all:public:test@gary", createMockLookupResponse("cached:public:test@gary", "hello world")) + .build(); + + PublicKeys.get(connection, createAtSign("gary"), key, null); + + assertThat(key.metadata().isCached(), is(true)); + } + + @Test + void testGetSharedByMeNoSuchKey() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("llookup:all:public:test@gary", "error:AT0015:deliberate") + .build(); + + assertThrows(AtKeyNotFoundException.class, () -> PublicKeys.get(connection, createAtSign("gary"), key, null)); + } + + @Test + void testGetSharedByMeExecutionException() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("llookup:all:public:test@gary", new ExecutionException("deliberate", null)) + .build(); + + assertThrows(RuntimeException.class, () -> PublicKeys.get(connection, createAtSign("gary"), key, null)); + } + + @Test + void testGetSharedByOther() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("plookup:all:test@gary", createMockLookupResponse("public:test@gary", "hello world")) + .build(); + + String actual = PublicKeys.get(connection, createAtSign("colin"), key, null); + + assertThat(actual, equalTo("hello world")); + } + + @Test + void testGetCachedSharedByOther() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("plookup:all:test@gary", createMockLookupResponse("cached:public:test@gary", "hello world")) + .build(); + + PublicKeys.get(connection, createAtSign("colin"), key, null); + + assertThat(key.metadata().isCached(), is(true)); + } + + @Test + void testGetSharedByOtherBypassCache() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("plookup:bypassCache:true:all:test@gary", createMockLookupResponse("public:test@gary", "hello world")) + .build(); + + GetRequestOptions options = GetRequestOptions.builder().bypassCache(true).build(); + String actual = PublicKeys.get(connection, createAtSign("colin"), key, options); + + assertThat(actual, equalTo("hello world")); + } + + + + @Test + void testGetSharedByOtherNoSuchKey() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("plookup:all:test@gary", "error:AT0015:deliberate") + .build(); + + assertThrows(AtKeyNotFoundException.class, () -> PublicKeys.get(connection, createAtSign("colin"), key, null)); + } + + @Test + void testGetSharedByOtherExecutionException() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stubExecutionException("plookup:all:test@gary") + .build(); + + assertThrows(RuntimeException.class, () -> PublicKeys.get(connection, createAtSign("colin"), key, null)); + } + + @Test + void testPutSendsExpectedCommands() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("update:dataSignature:.+:isEncrypted:false:public:test@gary hello world", "data:123") + .build(); + + PublicKeys.put(connection, keys, key, "hello world"); + } + + @Test + void testPutThrowExceptionIfCommandFails() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("update:dataSignature:.+:isEncrypted:false:public:test@gary hello world", "error:AT0001:deliberate") + .build(); + + AtKeys keys = AtKeys.builder() + .encryptKeyPair(EncryptionUtil.generateRSAKeyPair()) + .build(); + + assertThrows(AtServerRuntimeException.class, () -> PublicKeys.put(connection, keys, key, "hello world")); + } + + @Test + void testPutExecutionException() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stubExecutionException("update:dataSignature:.+:isEncrypted:false:public:test@gary hello world") + .build(); + + AtKeys keys = AtKeys.builder() + .encryptKeyPair(EncryptionUtil.generateRSAKeyPair()) + .build(); + + assertThrows(RuntimeException.class, () -> PublicKeys.put(connection, keys, key, "hello world")); + } + + private static String createMockLookupResponse(String key, String value) { + return String.format("data:{" + + "\"key\": \"%s\"," + + "\"data\": \"%s\"," + + "\"metaData\": {\"ttl\": 86400000, \"isBinary\": false, \"isEncrypted\": false, \"isPublic\": true}" + + "}", key, value); + } +} diff --git a/at_client/src/test/java/org/atsign/client/connection/protocol/ResponsesTest.java b/at_client/src/test/java/org/atsign/client/connection/protocol/ResponsesTest.java new file mode 100644 index 00000000..73b5276a --- /dev/null +++ b/at_client/src/test/java/org/atsign/client/connection/protocol/ResponsesTest.java @@ -0,0 +1,96 @@ +package org.atsign.client.connection.protocol; + +import static java.util.Collections.emptyList; +import static java.util.Collections.emptyMap; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +import org.junit.jupiter.api.Test; + +class ResponsesTest { + + @Test + void testDecodeJsonMapOfStringsReturnsExpectedResults() { + String json = "{\"key\":\"public:test@gary\",\"data\":\"hello world\"}"; + + Map expected = new HashMap<>(); + expected.put("key", "public:test@gary"); + expected.put("data", "hello world"); + + assertThat(Responses.decodeJsonMapOfStrings(json), equalTo(expected)); + assertThat(Responses.decodeJsonMapOfStrings("{}"), equalTo(emptyMap())); + assertThrows(RuntimeException.class, () -> Responses.decodeJsonMapOfStrings("x" + json + "x")); + } + + @Test + void testDecodeJsonMapOfObjectsReturnsExpectedResults() { + String json = "{\"key\":\"public:test@gary\",\"ttl\":1000001,\"ccd\":true}"; + + Map expected = new HashMap<>(); + expected.put("key", "public:test@gary"); + expected.put("ttl", 1000001); + expected.put("ccd", true); + + assertThat(Responses.decodeJsonMapOfObjects(json), equalTo(expected)); + assertThat(Responses.decodeJsonMapOfObjects("{}"), equalTo(emptyMap())); + assertThrows(RuntimeException.class, () -> Responses.decodeJsonMapOfObjects("x" + json + "x")); + } + + @Test + void testDecodeJsonListReturnsExpectedResults() { + String json = "[\"public:test@gary\",\"public:test2@gary\"]"; + + List expected = new ArrayList<>(); + expected.add("public:test@gary"); + expected.add("public:test2@gary"); + + assertThat(Responses.decodeJsonList(json), equalTo(expected)); + assertThat(Responses.decodeJsonList("[]"), equalTo(emptyList())); + assertThrows(RuntimeException.class, () -> Responses.decodeJsonList("x" + json + "x")); + } + + @Test + void decodeJsonListOfStringsReturnsExpectedResults() { + String json = "[\"public:test@gary\",\"public:test2@gary\"]"; + + List expected = new ArrayList<>(); + expected.add("public:test@gary"); + expected.add("public:test2@gary"); + + assertThat(Responses.decodeJsonListOfStrings(json), equalTo(expected)); + assertThat(Responses.decodeJsonListOfStrings("[]"), equalTo(emptyList())); + assertThrows(RuntimeException.class, () -> Responses.decodeJsonListOfStrings("x" + json + "x")); + } + + @Test + void testMatchReturnsExpectedResults() { + assertThat(Responses.match("abc", Pattern.compile("abc")), equalTo("abc")); + assertThat(Responses.match("abc", Pattern.compile("a(bc)")), equalTo("bc")); + assertThat(Responses.match("abc", Pattern.compile("(a)b(c)")), equalTo("ac")); + RuntimeException ex = assertThrows(RuntimeException.class, () -> Responses.match("xyz", Pattern.compile("abc"))); + assertThat(ex.getMessage(), containsString("expected [abc] but input was : xyz")); + } + + @Test + void testMatchWithTransformReturnsExpectedResults() { + assertThat(Responses.match("abc", Pattern.compile("abc"), String::toUpperCase), equalTo("ABC")); + assertThat(Responses.match("abc", Pattern.compile("a(bc)"), String::toUpperCase), equalTo("BC")); + assertThat(Responses.match("abc", Pattern.compile("(a)b(c)"), String::toUpperCase), equalTo("AC")); + RuntimeException ex = + assertThrows(RuntimeException.class, () -> Responses.match("xyz", Pattern.compile("abc"), String::toUpperCase)); + assertThat(ex.getMessage(), containsString("expected [abc] but input was : xyz")); + ex = assertThrows(RuntimeException.class, + () -> Responses.match("abc", Pattern.compile("abc"), s -> { + throw new RuntimeException("deliberate"); + })); + assertThat(ex.getMessage(), containsString("deliberate")); + } +} diff --git a/at_client/src/test/java/org/atsign/client/connection/protocol/ScanTest.java b/at_client/src/test/java/org/atsign/client/connection/protocol/ScanTest.java new file mode 100644 index 00000000..0ebbe93e --- /dev/null +++ b/at_client/src/test/java/org/atsign/client/connection/protocol/ScanTest.java @@ -0,0 +1,54 @@ +package org.atsign.client.connection.protocol; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; + +import org.atsign.client.connection.api.AtClientConnection; +import org.atsign.common.exceptions.AtExceptions.AtServerRuntimeException; +import org.junit.jupiter.api.Test; + +class ScanTest { + + @Test + void testScanReturnsExpectedResults() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("scan:showHidden:true .*", "data:[]") + .build(); + + assertThat(Scan.scan(connection, true, ".*"), equalTo(List.of())); + + connection = TestConnectionBuilder.builder() + .stub("scan:showHidden:true .+", "data:[\"public:publickey@gary\",\"public:signing_publickey@gary\"]") + .build(); + + assertThat(Scan.scan(connection, true, ".+"), + equalTo(List.of("public:publickey@gary", "public:signing_publickey@gary"))); + + connection = TestConnectionBuilder.builder() + .stub("scan .*", "data:[\"public:publickey@gary\"]") + .build(); + assertThat(Scan.scan(connection, false, ".*"), + equalTo(List.of("public:publickey@gary"))); + } + + @Test + void testScanThrowsServerException() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("scan:showHidden:true .*", "error:AT0001:deliberate") + .build(); + assertThrows(AtServerRuntimeException.class, () -> Scan.scan(connection, true, ".*")); + } + + + @Test + void testScanThrowsExecutionException() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stubExecutionException("scan:showHidden:true .*") + .build(); + assertThrows(RuntimeException.class, () -> Scan.scan(connection, true, ".*")); + } + +} diff --git a/at_client/src/test/java/org/atsign/client/connection/protocol/SelfKeysTest.java b/at_client/src/test/java/org/atsign/client/connection/protocol/SelfKeysTest.java new file mode 100644 index 00000000..484fdc0d --- /dev/null +++ b/at_client/src/test/java/org/atsign/client/connection/protocol/SelfKeysTest.java @@ -0,0 +1,105 @@ +package org.atsign.client.connection.protocol; + +import static org.atsign.client.util.EncryptionUtil.*; +import static org.atsign.common.AtSign.createAtSign; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.verify; + +import org.atsign.client.api.AtKeys; +import org.atsign.client.connection.api.AtClientConnection; +import org.atsign.common.Keys; +import org.atsign.common.exceptions.AtExceptions.AtServerRuntimeException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class SelfKeysTest { + + private Keys.SelfKey key; + + private AtKeys keys; + + @BeforeEach + public void setup() throws Exception { + keys = AtKeys.builder() + .selfEncryptKey(generateAESKeyBase64()) + .encryptKeyPair(generateRSAKeyPair()) + .build(); + key = Keys.selfKeyBuilder() + .sharedBy(createAtSign("gary")) + .name("test") + .build(); + } + + @Test + void testGet() throws Exception { + + String iv = generateRandomIvBase64(16); + String encrypted = aesEncryptToBase64("hello me", keys.getSelfEncryptKey(), iv); + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("llookup:all:test@gary", createMockLookupResponse("test@gary", encrypted, iv)) + .build(); + + String actual = SelfKeys.get(connection, keys, key); + + assertThat(actual, equalTo("hello me")); + } + + @Test + void testGetException() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("llookup:all:test@gary", "error:AT0001:deliberate") + .build(); + + assertThrows(AtServerRuntimeException.class, () -> SelfKeys.get(connection, keys, key)); + } + + @Test + void testGetExecutionException() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stubExecutionException("llookup:all:test@gary") + .build(); + + assertThrows(RuntimeException.class, () -> SelfKeys.get(connection, keys, key)); + } + + @Test + void testPut() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("update:dataSignature:.+:isEncrypted:true:ivNonce:.+:test@gary .+", "data:123") + .build(); + + SelfKeys.put(connection, keys, key, "hello world"); + verify(connection).sendSync(argThat(s -> !s.contains("hello world"))); + } + + @Test + void testPutAtException() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("update:dataSignature:.+:isEncrypted:true:ivNonce:.+:test@gary .+", "error:AT0001:deliberate") + .build(); + + assertThrows(AtServerRuntimeException.class, () -> SelfKeys.put(connection, keys, key, "hello world")); + } + + @Test + void testPutExecutionException() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stubExecutionException("update:dataSignature:.+:isEncrypted:true:ivNonce:.+:test@gary .+") + .build(); + + assertThrows(RuntimeException.class, () -> SelfKeys.put(connection, keys, key, "hello world")); + } + + private static String createMockLookupResponse(String key, String encrypted, String iv) { + + return String.format("data:{" + + "\"key\": \"%s\"," + + "\"data\": \"%s\"," + + "\"metaData\": {\"ttl\": 86400000, \"ivNonce\": \"%s\", \"isEncrypted\": true, \"isPublic\": false}" + + "}", key, encrypted, iv); + } + +} diff --git a/at_client/src/test/java/org/atsign/client/connection/protocol/SharedKeysTest.java b/at_client/src/test/java/org/atsign/client/connection/protocol/SharedKeysTest.java new file mode 100644 index 00000000..e9e0e587 --- /dev/null +++ b/at_client/src/test/java/org/atsign/client/connection/protocol/SharedKeysTest.java @@ -0,0 +1,157 @@ +package org.atsign.client.connection.protocol; + +import static org.atsign.client.util.EncryptionUtil.*; +import static org.atsign.common.AtSign.createAtSign; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.verify; + +import org.atsign.client.api.AtKeys; +import org.atsign.client.connection.api.AtClientConnection; +import org.atsign.common.Keys; +import org.atsign.common.exceptions.AtExceptions.AtServerRuntimeException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class SharedKeysTest { + + private Keys.SharedKey key; + + private AtKeys keys; + + @BeforeEach + public void setup() throws Exception { + keys = AtKeys.builder() + .encryptKeyPair(generateRSAKeyPair()) + .selfEncryptKey(generateAESKeyBase64()) + .build(); + key = Keys.sharedKeyBuilder() + .sharedBy(createAtSign("gary")) + .sharedWith(createAtSign("colin")) + .name("test") + .build(); + } + + @Test + void testGetSharedByMe() throws Exception { + String encryptKey = generateAESKeyBase64(); + String iv = generateRandomIvBase64(16); + String encrypted = aesEncryptToBase64("hello colin", encryptKey, iv); + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("llookup:shared_key.colin@gary", "data:" + rsaEncryptToBase64(encryptKey, keys.getEncryptPublicKey())) + .stub("llookup:all:@colin:test@gary", createMockLookupResponse("@colin:test@gary", encrypted, iv)) + .build(); + + String actual = SharedKeys.get(connection, createAtSign("gary"), keys, key); + + assertThat(actual, equalTo("hello colin")); + } + + @Test + void testGetNotSharedByMeOrWithMeThrowsException() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .build(); + + RuntimeException ex = + assertThrows(RuntimeException.class, () -> SharedKeys.get(connection, createAtSign("alice"), keys, key)); + assertThat(ex.getMessage(), containsString("the client atsign is neither the sharedBy or sharedWith")); + } + + @Test + void testGetSharedByMeCachedKey() throws Exception { + String encryptKey = generateAESKeyBase64(); + String iv = generateRandomIvBase64(16); + String encrypted = aesEncryptToBase64("hello colin", encryptKey, iv); + keys.put("shared_key.colin", encryptKey); + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("llookup:all:@colin:test@gary", createMockLookupResponse("@colin:test@gary", encrypted, iv)) + .build(); + + String actual = SharedKeys.get(connection, createAtSign("gary"), keys, key); + + assertThat(actual, equalTo("hello colin")); + } + + @Test + void testGetSharedByMeServerException() throws Exception { + String encryptKey = generateAESKeyBase64(); + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("llookup:shared_key.colin@gary", "data:" + rsaEncryptToBase64(encryptKey, keys.getEncryptPublicKey())) + .stub("llookup:all:@colin:test@gary", "error:AT0001:deliberate") + .build(); + + assertThrows(AtServerRuntimeException.class, () -> SharedKeys.get(connection, createAtSign("gary"), keys, key)); + } + + @Test + void testGetSharedByMeExecutionException() throws Exception { + String encryptKey = generateAESKeyBase64(); + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("llookup:shared_key.colin@gary", "data:" + rsaEncryptToBase64(encryptKey, keys.getEncryptPublicKey())) + .stubExecutionException("llookup:all:@colin:test@gary") + .build(); + + assertThrows(RuntimeException.class, () -> SharedKeys.get(connection, createAtSign("gary"), keys, key)); + } + + @Test + void getGetSharedByOther() throws Exception { + String encryptKey = generateAESKeyBase64(); + String iv = generateRandomIvBase64(16); + String encrypted = aesEncryptToBase64("hello colin", encryptKey, iv); + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("lookup:all:test@gary", createMockLookupResponse("@colin:test@gary", encrypted, iv)) + .stub("lookup:shared_key@gary", "data:" + rsaEncryptToBase64(encryptKey, keys.getEncryptPublicKey())) + .build(); + + String actual = SharedKeys.get(connection, createAtSign("colin"), keys, key); + + assertThat(actual, equalTo("hello colin")); + } + + @Test + void testPutWhenSharedKeyAlreadyExists() throws Exception { + String encryptKey = generateAESKeyBase64(); + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("llookup:shared_key.colin@gary", "data:" + rsaEncryptToBase64(encryptKey, keys.getEncryptPublicKey())) + .stub("update:isEncrypted:true:ivNonce:.+:@colin:test@gary .+", "data:123") + .build(); + + SharedKeys.put(connection, createAtSign("gary"), keys, key, "hello colin"); + verify(connection).sendSync(argThat(s -> s.contains("update:") && !s.contains("hello colin"))); + } + + @Test + void testPutWhenSharedKeDoesNotAlreadyExists() throws Exception { + AtClientConnection connection = TestConnectionBuilder.builder() + .stub("llookup:shared_key.colin@gary", "error:AT0015:deliberate") + .stub("plookup:publickey@colin", "data:" + keys.getEncryptPublicKey()) + .stub("update:shared_key.colin@gary .+", "data:1") + .stub("update:ttr:86400000:@colin:shared_key@gary .+", "data:2") + .stub("update:isEncrypted:true:ivNonce:.+:@colin:test@gary .+", "data:3") + .build(); + + SharedKeys.put(connection, createAtSign("gary"), keys, key, "hello colin"); + } + + private static String createMockLookupResponse(String key, String value) { + return String.format("data:{" + + "\"key\": \"%s\"," + + "\"data\": \"%s\"," + + "\"metaData\": {\"ttl\": 86400000, \"isEncrypted\": false, \"isPublic\": true}" + + "}", key, value); + } + + + private static String createMockLookupResponse(String key, String encrypted, String iv) { + return String.format("data:{" + + "\"key\": \"%s\"," + + "\"data\": \"%s\"," + + "\"metaData\": {\"ttl\": 86400000, \"ivNonce\": \"%s\", \"isEncrypted\": true, \"isPublic\": false}" + + "}", key, encrypted, iv); + } + +} diff --git a/at_client/src/test/java/org/atsign/client/connection/protocol/TestConnectionBuilder.java b/at_client/src/test/java/org/atsign/client/connection/protocol/TestConnectionBuilder.java new file mode 100644 index 00000000..0487d380 --- /dev/null +++ b/at_client/src/test/java/org/atsign/client/connection/protocol/TestConnectionBuilder.java @@ -0,0 +1,76 @@ +package org.atsign.client.connection.protocol; + +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.ExecutionException; +import java.util.function.Function; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.atsign.client.connection.api.AtClientConnection; +import org.mockito.Mockito; +import org.mockito.stubbing.Answer; + +public class TestConnectionBuilder { + + private Map mapping = new LinkedHashMap<>(); + + public static TestConnectionBuilder builder() { + return new TestConnectionBuilder(); + } + + public TestConnectionBuilder stub(String command, String response) { + return stub(Pattern.compile(command), response); + } + + public TestConnectionBuilder stub(Pattern command, String response) { + mapping.put(command, response); + return this; + } + + public TestConnectionBuilder stub(String command, Function fn) { + return stub(Pattern.compile(command), fn); + } + + public TestConnectionBuilder stub(Pattern command, Function fn) { + mapping.put(command, fn); + return this; + } + + public TestConnectionBuilder stub(String command, Exception ex) { + return stub(Pattern.compile(command), ex); + } + + public TestConnectionBuilder stubExecutionException(String command) { + return stub(Pattern.compile(command), new ExecutionException("deliberate", null)); + } + + public TestConnectionBuilder stub(Pattern command, Exception ex) { + mapping.put(command, ex); + return this; + } + + public AtClientConnection build() throws ExecutionException, InterruptedException { + AtClientConnection connection = Mockito.mock(AtClientConnection.class); + when(connection.sendSync(anyString())).thenAnswer((Answer) invocation -> { + String command = invocation.getArgument(0); + for (Map.Entry entry : mapping.entrySet()) { + Matcher matcher = entry.getKey().matcher(command); + if (matcher.matches()) { + if (entry.getValue() instanceof Throwable) { + throw (Throwable) entry.getValue(); + } else if (entry.getValue() instanceof Function) { + return ((Function) entry.getValue()).apply(matcher); + } else { + return (String) entry.getValue(); + } + } + } + throw new IllegalArgumentException("no stub for " + command); + }); + return connection; + } +} diff --git a/at_client/src/test/java/org/atsign/cucumber/steps/ActivateSteps.java b/at_client/src/test/java/org/atsign/cucumber/steps/ActivateSteps.java index fe3bf896..57693193 100644 --- a/at_client/src/test/java/org/atsign/cucumber/steps/ActivateSteps.java +++ b/at_client/src/test/java/org/atsign/cucumber/steps/ActivateSteps.java @@ -1,5 +1,7 @@ package org.atsign.cucumber.steps; +import static org.atsign.client.connection.protocol.Authentication.authenticateWithPkam; +import static org.atsign.client.connection.protocol.Data.matchDataJsonListOfStrings; import static org.atsign.cucumber.helpers.Helpers.getFirstValue; import static org.atsign.cucumber.helpers.Helpers.toCanonicalMaps; import static org.hamcrest.MatcherAssert.assertThat; @@ -11,8 +13,10 @@ import java.util.Map; import java.util.Stack; -import org.atsign.client.api.impl.connections.AtSecondaryConnection; import org.atsign.client.cli.Activate; +import org.atsign.client.connection.api.AtClientConnection; +import org.atsign.client.connection.protocol.Enroll; +import org.atsign.client.connection.protocol.Keys; import org.atsign.client.util.EnrollmentId; import org.atsign.client.util.KeysUtil; import org.atsign.common.AtSign; @@ -227,22 +231,22 @@ public ActivateTeardown(AtSign atSign, File keysFile, String cramSecret, Enrollm public void run() { - try (AtSecondaryConnection connection = createAtSecondaryConnection(atSign, rootUrl, 0)) { + try (AtClientConnection connection = createConnection(rootUrl, atSign, 0)) { - authenticateWithApkam(connection, atSign, KeysUtil.loadKeys(keysFile)); + authenticateWithPkam(connection, atSign, KeysUtil.loadKeys(keysFile)); // delete keys that have been created - matchDataJsonListOfStrings(connection.executeCommand("scan")).stream() + matchDataJsonListOfStrings(connection.sendSync("scan")).stream() .filter(k -> !isProtectedKey(atSign, k)) - .forEach(k -> deleteKeyNoThrow(connection, k)); + .forEach(k -> deleteKeyNoThrow(connection, atSign, k)); // remove enrollments - list(connection, "pending").forEach(id -> denyDeleteNoThrow(connection, id)); - list(connection, "denied").forEach(id -> deleteNoThrow(connection, id)); - list(connection, "approved").stream() + Enroll.list(connection, "pending").forEach(id -> denyDeleteNoThrow(connection, atSign, id)); + Enroll.list(connection, "denied").forEach(id -> deleteNoThrow(connection, atSign, id)); + Enroll.list(connection, "approved").stream() .filter(id -> !id.equals(onboardEnrollmentId)) - .forEach(id -> revokeDeleteNoThrow(connection, id)); - revokeDeleteNoThrow(connection, onboardEnrollmentId); + .forEach(id -> revokeDeleteNoThrow(connection, atSign, id)); + revokeDeleteNoThrow(connection, atSign, onboardEnrollmentId); } catch (Exception e) { log.error("teardown for {} failed : {}", atSign, e.getMessage()); } @@ -255,47 +259,47 @@ private boolean isProtectedKey(AtSign atSign, String key) { || key.contains(("__manage@")); } - protected void deleteKeyNoThrow(AtSecondaryConnection connection, String key) { + protected void deleteKeyNoThrow(AtClientConnection connection, AtSign atSign, String key) { try { - log.debug("teardown for {} deleting key {}", connection.getAtSign(), key); - deleteKey(connection, key); + log.debug("teardown for {} deleting key {}", atSign, key); + Keys.deleteKey(connection, key); } catch (Exception e) { log.error("teardown for {} failed to delete key {} in onboarded server : {}", - connection.getAtSign(), key, e.getMessage()); + atSign, key, e.getMessage()); } } - private void deleteNoThrow(AtSecondaryConnection connection, EnrollmentId id) { + private void deleteNoThrow(AtClientConnection connection, AtSign atSign, EnrollmentId id) { try { log.debug("teardown for {} deleting enroll request {}", id); delete(connection, id); } catch (Exception e) { log.error("teardown for {} failed to enroll delete {} in onboarded server : {}", - connection.getAtSign(), id, e.getMessage()); + atSign, id, e.getMessage()); } } - private void denyDeleteNoThrow(AtSecondaryConnection connection, EnrollmentId id) { + private void denyDeleteNoThrow(AtClientConnection connection, AtSign atsign, EnrollmentId id) { try { - log.debug("teardown for {} denying enroll request {}", connection.getAtSign(), id); - deny(connection, id); - log.debug("teardown for {} deleting enroll request {}", connection.getAtSign(), id); - delete(connection, id); + log.debug("teardown for {} denying enroll request {}", atsign, id); + Enroll.deny(connection, id); + log.debug("teardown for {} deleting enroll request {}", atsign, id); + Enroll.delete(connection, id); } catch (Exception e) { log.error("teardown for {} failed to enroll deny and delete {} in onboarded server : {}", - connection.getAtSign(), id, e.getMessage()); + atsign, id, e.getMessage()); } } - private void revokeDeleteNoThrow(AtSecondaryConnection connection, EnrollmentId id) { + private void revokeDeleteNoThrow(AtClientConnection connection, AtSign atSign, EnrollmentId id) { try { - log.debug("teardown for {} revoking enroll request {}", connection.getAtSign(), id); - revoke(connection, id); - log.debug("teardown for {} deleting enroll request {}", connection.getAtSign(), id); - delete(connection, id); + log.debug("teardown for {} revoking enroll request {}", atSign, id); + Enroll.revoke(connection, id); + log.debug("teardown for {} deleting enroll request {}", atSign, id); + Enroll.delete(connection, id); } catch (Exception e) { log.error("teardown for {} failed to enroll revoke and delete {} in onboarded server : {}", - connection.getAtSign(), id, e.getMessage()); + atSign, id, e.getMessage()); } } } diff --git a/at_client/src/test/java/org/atsign/cucumber/steps/AtClientContext.java b/at_client/src/test/java/org/atsign/cucumber/steps/AtClientContext.java index 3205e374..59cdcfe6 100644 --- a/at_client/src/test/java/org/atsign/cucumber/steps/AtClientContext.java +++ b/at_client/src/test/java/org/atsign/cucumber/steps/AtClientContext.java @@ -1,7 +1,7 @@ package org.atsign.cucumber.steps; import static java.util.concurrent.TimeUnit.SECONDS; -import static org.atsign.client.cli.AbstractCli.decodeJsonListOfStrings; +import static org.atsign.client.connection.protocol.Responses.decodeJsonListOfStrings; import static org.atsign.client.util.Preconditions.checkNotNull; import static org.atsign.cucumber.helpers.Helpers.isHostPortReachable; import static org.awaitility.Awaitility.await; @@ -21,6 +21,7 @@ import org.atsign.client.api.AtClient; import org.atsign.client.api.AtEvents; import org.atsign.client.api.AtKeys; +import org.atsign.client.api.impl.clients.AtClients; import org.atsign.client.util.EnrollmentId; import org.atsign.client.util.KeysUtil; import org.atsign.common.AtException; @@ -249,10 +250,15 @@ private void stopAtClientMonitor(AtClient atClient) { @Then("exception was {exception}") public void assertExpectedExceptionClass(Class expectedClass) throws AtException { - if (expectedException.getCause() != null) { - assertThat(expectedException.getCause().getClass(), typeCompatibleWith(expectedClass)); - } else { - assertThat(expectedException.getClass(), typeCompatibleWith(expectedClass)); + try { + if (expectedException.getCause() != null) { + assertThat(expectedException.getCause().getClass(), typeCompatibleWith(expectedClass)); + } else { + assertThat(expectedException.getClass(), typeCompatibleWith(expectedClass)); + } + } catch (Throwable e) { + expectedException.printStackTrace(); + throw e; } } @@ -324,7 +330,12 @@ private AtClient createAtClient(AtSign atSign, AtKeys keys, boolean withMonitor) if (rootHostAndPort == null) { throw new IllegalArgumentException("root host and port not set"); } - AtClient atClient = AtClient.withRemoteSecondary(rootHostAndPort, atSign, keys, isVerbose()); + AtClient atClient = AtClients.builder() + .url(rootHostAndPort) + .atSign(atSign) + .keys(keys) + .isVerbose(isVerbose()) + .build(); AtClientEventListener listener = new AtClientEventListener(qualifiedAtSign); atClient.addEventListener(listener, ALL_EVENT_TYPES); clients.put(qualifiedAtSign, atClient); @@ -472,7 +483,7 @@ private void deleteKeyNoThrow(AtClient client, String key) { private List scanNoThrow(AtClient client) { try { - String json = client.getSecondary().executeCommand("scan:showHidden:true .*", true).getRawDataResponse(); + String json = client.executeCommand("scan:showHidden:true .*", true).getRawDataResponse(); return decodeJsonListOfStrings(json); } catch (Exception e) { log.error("failed to scan : {}", e.getMessage()); diff --git a/at_client/src/test/resources/features/Activate.feature b/at_client/src/test/resources/features/Activate.feature index 63dd1de0..87650264 100644 --- a/at_client/src/test/resources/features/Activate.feature +++ b/at_client/src/test/resources/features/Activate.feature @@ -44,7 +44,7 @@ Feature: AtClient API tests for onboarding and enrolling atsign | Namespace | Access Control | | ns | rw | Then AtClient with keys @srie-app1-device1.atKeys fails for @srie - And exception message matches "PKAM command failed: error:AT0026:enrollment_id: .+ is pending" + And exception message matches "AT0026:enrollment_id: .+ is pending" Scenario: After enrolling an app and device and approving an AtClient can be created and used to get and put keys When @srie Activate.onboard with SrieKeys._cramKey from at_demo_apkam_keys.dart in at_demo_data package @@ -76,7 +76,7 @@ Feature: AtClient API tests for onboarding and enrolling atsign | ns | rw | When @srie Activate.deny for last enrollment Then AtClient with keys @srie-app1-device1.atKeys fails for @srie - And exception message matches "PKAM command failed: error:AT0025:enrollment_id: .+ is denied" + And exception message matches "AT0025:enrollment_id: .+ is denied" Scenario: After enrolling an app and device and approving but then revoking an AtClient cannot be created And @srie Activate.onboard with SrieKeys._cramKey from at_demo_apkam_keys.dart in at_demo_data package diff --git a/at_client/src/test/resources/features/Monitor.feature b/at_client/src/test/resources/features/Monitor.feature index c81341cc..b3c5cb0b 100644 --- a/at_client/src/test/resources/features/Monitor.feature +++ b/at_client/src/test/resources/features/Monitor.feature @@ -25,9 +25,11 @@ Feature: AtClient API Monitor tests Scenario: SharedKey put triggers expected notifications for shared with atsign When @colin AtClient.put for SharedKey test shared with @gary and value "hello world" + And @colin AtClient.delete for SharedKey test shared with @gary Then @gary AtClient monitor receives the following | Event Type | messageType | from | to | operation | key | decryptedValue | | sharedKeyNotification | MessageType.key | @colin | @gary | update | @gary:shared_key@colin | | | updateNotification | MessageType.key | @colin | @gary | update | @gary:test@colin | | | decryptedUpdateNotification | MessageType.key | @colin | @gary | update | @gary:test@colin | hello world | + | deleteNotification | MessageType.key | @colin | @gary | delete | @gary:test@colin | | diff --git a/at_client/src/test/resources/simpleLogger.properties b/at_client/src/test/resources/simpleLogger.properties index 1d52cd54..21d6eb02 100644 --- a/at_client/src/test/resources/simpleLogger.properties +++ b/at_client/src/test/resources/simpleLogger.properties @@ -1,3 +1,5 @@ org.slf4j.simpleLogger.defaultLogLevel=info org.slf4j.simpleLogger.showDateTime=true -org.slf4j.simpleLogger.dateTimeFormat=HH:mm:ss +org.slf4j.simpleLogger.dateTimeFormat=HH:mm:ss.SSS + +#org.slf4j.simpleLogger.log.org.atsign.client.connection.netty=debug diff --git a/at_shell/src/main/java/org/atsign/client/cli/REPL.java b/at_shell/src/main/java/org/atsign/client/cli/REPL.java index 38931828..71196c40 100644 --- a/at_shell/src/main/java/org/atsign/client/cli/REPL.java +++ b/at_shell/src/main/java/org/atsign/client/cli/REPL.java @@ -20,7 +20,7 @@ import org.atsign.common.Keys.SelfKey; import org.atsign.common.Keys.SharedKey; import org.atsign.common.exceptions.AtIllegalArgumentException; -import org.atsign.common.exceptions.AtInvalidSyntaxException; +import org.atsign.common.exceptions.AtExceptions.AtInvalidSyntaxException; import org.fusesource.jansi.Ansi; import org.fusesource.jansi.AnsiConsole; diff --git a/examples/src/main/java/org/atsign/examples/PublicKeyGetBypassCacheExample.java b/examples/src/main/java/org/atsign/examples/PublicKeyGetBypassCacheExample.java index 21ad8494..9927ea16 100644 --- a/examples/src/main/java/org/atsign/examples/PublicKeyGetBypassCacheExample.java +++ b/examples/src/main/java/org/atsign/examples/PublicKeyGetBypassCacheExample.java @@ -31,7 +31,7 @@ public static void main(String[] args) { PublicKey pk = Keys.publicKeyBuilder().sharedBy(new AtSign("@bob")).name(KEY_NAME).build(); // 5. get the value associated with the key - String response = atClient.get(pk, (GetRequestOptions) new GetRequestOptions().bypassCache(true).build()).get(); + String response = atClient.get(pk, GetRequestOptions.builder().bypassCache(true).build()).get(); System.out.println(response); } catch (AtException | IOException | InterruptedException | ExecutionException e) { diff --git a/pom.xml b/pom.xml index 9d3f6311..869514a7 100644 --- a/pom.xml +++ b/pom.xml @@ -48,6 +48,7 @@ 2.16.1 1.78.1 + 4.1.109.Final 4.7.6 2.0.13 1.18.30 @@ -85,6 +86,17 @@ bcprov-jdk15to18 ${version.bouncycastle} + + org.bouncycastle + bcpkix-jdk18on + ${version.bouncycastle} + + + + io.netty + netty-all + ${version.netty} + info.picocli @@ -331,6 +343,27 @@ + + org.jacoco + jacoco-maven-plugin + 0.8.11 + + + prepare-agent + + prepare-agent + + + + report + verify + + report + + + + + org.codehaus.mojo exec-maven-plugin From 9b8d87f68bd32668a5a704bfe0a5944d7d216948 Mon Sep 17 00:00:00 2001 From: akafredperry Date: Tue, 10 Mar 2026 15:41:55 +0000 Subject: [PATCH 2/5] feature: removed redundant code --- .../java/org/atsign/client/api/AtClient.java | 198 +---- .../org/atsign/client/api/AtConnection.java | 44 - .../client/api/AtConnectionFactory.java | 32 - .../java/org/atsign/client/api/Secondary.java | 154 ---- .../client/api/impl/clients/AtClientImpl.java | 796 ------------------ .../api/impl/clients/DefaultAtClientImpl.java | 171 ++-- .../impl/connections/AtConnectionBase.java | 193 ----- .../impl/connections/AtMonitorConnection.java | 258 ------ .../impl/connections/AtRootConnection.java | 80 -- .../connections/AtSecondaryConnection.java | 66 -- .../DefaultAtConnectionFactory.java | 49 -- .../api/impl/secondaries/RemoteSecondary.java | 197 ----- .../java/org/atsign/client/cli/Delete.java | 13 +- .../main/java/org/atsign/client/cli/Get.java | 9 +- .../main/java/org/atsign/client/cli/Scan.java | 7 +- .../java/org/atsign/client/cli/Share.java | 13 +- .../client/connection/protocol/Enroll.java | 16 +- .../java/org/atsign/client/util/ArgsUtil.java | 27 - .../client/util/AtClientValidation.java | 39 - .../java/org/atsign/client/util/AuthUtil.java | 128 --- .../atsign/common/ResponseTransformers.java | 73 -- .../AtResponseHandlingException.java | 2 +- .../exceptions/AtServerIsPausedException.java | 2 +- .../AtUnknownResponseException.java | 3 +- .../org/atsign/client/api/SecondaryTest.java | 20 - .../atsign/common/AtClientValidationIT.java | 54 -- .../org/atsign/common/FromStringTest.java | 28 +- .../common/ResponseTransformerTest.java | 83 -- .../cucumber/steps/AtClientContext.java | 11 +- .../main/java/org/atsign/client/cli/REPL.java | 40 +- .../examples/PublicKeyDeleteExample.java | 22 +- .../PublicKeyGetBypassCacheExample.java | 19 +- .../atsign/examples/PublicKeyGetExample.java | 19 +- .../atsign/examples/PublicKeyPutExample.java | 23 +- .../atsign/examples/SelfKeyDeleteExample.java | 22 +- .../atsign/examples/SelfKeyGetExample.java | 19 +- .../atsign/examples/SelfKeyPutExample.java | 22 +- .../examples/SharedKeyDeleteExample.java | 22 +- .../examples/SharedKeyGetOtherExample.java | 19 +- .../examples/SharedKeyGetSelfExample.java | 19 +- 40 files changed, 276 insertions(+), 2736 deletions(-) delete mode 100644 at_client/src/main/java/org/atsign/client/api/AtConnection.java delete mode 100644 at_client/src/main/java/org/atsign/client/api/AtConnectionFactory.java delete mode 100644 at_client/src/main/java/org/atsign/client/api/Secondary.java delete mode 100644 at_client/src/main/java/org/atsign/client/api/impl/clients/AtClientImpl.java delete mode 100644 at_client/src/main/java/org/atsign/client/api/impl/connections/AtConnectionBase.java delete mode 100644 at_client/src/main/java/org/atsign/client/api/impl/connections/AtMonitorConnection.java delete mode 100644 at_client/src/main/java/org/atsign/client/api/impl/connections/AtRootConnection.java delete mode 100644 at_client/src/main/java/org/atsign/client/api/impl/connections/AtSecondaryConnection.java delete mode 100644 at_client/src/main/java/org/atsign/client/api/impl/connections/DefaultAtConnectionFactory.java delete mode 100644 at_client/src/main/java/org/atsign/client/api/impl/secondaries/RemoteSecondary.java delete mode 100644 at_client/src/main/java/org/atsign/client/util/ArgsUtil.java delete mode 100644 at_client/src/main/java/org/atsign/client/util/AtClientValidation.java delete mode 100644 at_client/src/main/java/org/atsign/client/util/AuthUtil.java delete mode 100644 at_client/src/main/java/org/atsign/common/ResponseTransformers.java delete mode 100644 at_client/src/test/java/org/atsign/client/api/SecondaryTest.java delete mode 100644 at_client/src/test/java/org/atsign/common/AtClientValidationIT.java delete mode 100644 at_client/src/test/java/org/atsign/common/ResponseTransformerTest.java diff --git a/at_client/src/main/java/org/atsign/client/api/AtClient.java b/at_client/src/main/java/org/atsign/client/api/AtClient.java index 5fde566b..c2e1a2bd 100644 --- a/at_client/src/main/java/org/atsign/client/api/AtClient.java +++ b/at_client/src/main/java/org/atsign/client/api/AtClient.java @@ -1,205 +1,39 @@ package org.atsign.client.api; -import static org.atsign.common.Keys.AtKey; -import static org.atsign.common.Keys.PublicKey; -import static org.atsign.common.Keys.SelfKey; -import static org.atsign.common.Keys.SharedKey; +import static org.atsign.common.Keys.*; -import java.io.Closeable; -import java.io.IOException; import java.util.List; import java.util.concurrent.CompletableFuture; -import org.atsign.client.api.impl.clients.AtClientImpl; -import org.atsign.client.api.impl.connections.AtRootConnection; -import org.atsign.client.api.impl.connections.DefaultAtConnectionFactory; -import org.atsign.client.api.impl.events.SimpleAtEventBus; -import org.atsign.client.api.impl.secondaries.RemoteSecondary; -import org.atsign.common.AtException; +import org.atsign.client.connection.api.AtClientConnection; import org.atsign.common.AtSign; -import org.atsign.common.exceptions.AtSecondaryConnectException; -import org.atsign.common.exceptions.AtSecondaryNotFoundException; import org.atsign.common.options.GetRequestOptions; /** * The primary interface of the AtSign client library. */ @SuppressWarnings("unused") -public interface AtClient extends Secondary, AtEvents.AtEventBus, Closeable { - - /** - * Standard AtClient factory - uses production @ root to look up the cloud secondary address for - * this atSign - * - * @param atSign the {@link AtSign} of this client - e.g. @alice - * @param keys the {@link AtKeys} for this client - * @return An {@link AtClient} - * @throws AtException if something goes wrong with looking up or connecting to the remote secondary - */ - static AtClient withRemoteSecondary(AtSign atSign, AtKeys keys) throws AtException { - return withRemoteSecondary("root.atsign.org:64", atSign, keys); - } - - /** - * Standard AtClient factory - uses production @ root to look up the cloud secondary address for - * this atSign - * - * @param atSign the {@link AtSign} of this client - e.g. @alice - * @param keys the {@link AtKeys} for this client - * @param verbose set to true for chatty logs - * @return An {@link AtClient} - * @throws AtException if something goes wrong with looking up or connecting to the remote secondary - */ - static AtClient withRemoteSecondary(AtSign atSign, AtKeys keys, boolean verbose) throws AtException { - return withRemoteSecondary("root.atsign.org:64", atSign, keys, verbose); - } - - /** - * Factory to use when you wish to use a custom Secondary.AddressFinder - * - * @param atSign the {@link AtSign} of this client - e.g. @alice - * @param keys the {@link AtKeys} for this client - * @param secondaryAddressFinder will be used to find the Secondary.Address of the atSign - * @return An {@link AtClient} - * @throws AtException if any other exception occurs while connecting to the remote (cloud) - * secondary - */ - static AtClient withRemoteSecondary(AtSign atSign, AtKeys keys, Secondary.AddressFinder secondaryAddressFinder) - throws AtException { - Secondary.Address remoteSecondaryAddress; - try { - remoteSecondaryAddress = secondaryAddressFinder.findSecondary(atSign); - } catch (IOException e) { - throw new AtSecondaryConnectException("Failed to find secondary, with IOException", e); - } - return withRemoteSecondary(atSign, keys, remoteSecondaryAddress, false); - } - - /** - * Factory - returns default AtClientImpl with a RemoteSecondary and a DefaultConnectionFactory - * - * @param rootUrl the address of the root server to use - e.g. root.atsign.org:64 for production - * at-signs - * @param atSign the {@link AtSign} of this client - e.g. @alice - * @param keys the {@link AtKeys} for this client - * @return An {@link AtClient} - * @throws AtException if anything goes wrong during construction - */ - static AtClient withRemoteSecondary(String rootUrl, AtSign atSign, AtKeys keys) throws AtException { - return withRemoteSecondary(rootUrl, atSign, keys, false); - } - - /** - * Factory - returns default AtClientImpl with a RemoteSecondary and a DefaultConnectionFactory - * - * @param rootUrl the address of the root server to use - e.g. root.atsign.org:64 for production - * at-signs - * @param atSign the {@link AtSign} of this client - e.g. @alice - * @param keys the {@link AtKeys} for this client - * @param verbose set to true for chatty logs - * @return An {@link AtClient} - * @throws AtException if anything goes wrong during construction - */ - static AtClient withRemoteSecondary(String rootUrl, AtSign atSign, AtKeys keys, boolean verbose) throws AtException { - DefaultAtConnectionFactory connectionFactory = new DefaultAtConnectionFactory(); - - Secondary.Address secondaryAddress; - try { - AtRootConnection rootConnection = connectionFactory.getRootConnection(new SimpleAtEventBus(), rootUrl, verbose); - rootConnection.connect(); - secondaryAddress = rootConnection.findSecondary(atSign); - } catch (AtSecondaryNotFoundException e) { - throw e; - } catch (Exception e) { - throw new AtSecondaryNotFoundException("Failed to lookup remote secondary", e); - } - - return withRemoteSecondary(atSign, keys, secondaryAddress, verbose); - } - - /** - * Factory to use when you wish to use a custom Secondary.AddressFinder - * - * @param atSign the {@link AtSign} of this client - e.g. @alice - * @param keys the {@link AtKeys} for this client - * @param verbose set to true for chatty logs - * @return An {@link AtClient} - * @throws IOException if thrown by the address finder - * @throws AtException if any other exception occurs while connecting to the remote (cloud) - * secondary - */ - static AtClient withRemoteSecondary(AtSign atSign, AtKeys keys, Secondary.AddressFinder secondaryAddressFinder, - boolean verbose) - throws IOException, AtException { - Secondary.Address remoteSecondaryAddress = secondaryAddressFinder.findSecondary(atSign); - return withRemoteSecondary(atSign, keys, remoteSecondaryAddress, verbose); - } - - /** - * Factory to use when you already know the address of the remote (cloud) secondary - * - * @param atSign the {@link AtSign} of this client - e.g. @alice - * @param keys the {@link AtKeys} for this client - * @param remoteSecondaryAddress the address of the remote secondary server - * @param verbose set to true for chatty logs - * @return An {@link AtClient} - * @throws AtException if any other exception occurs while connecting to the remote (cloud) - * secondary - */ - static AtClient withRemoteSecondary(AtSign atSign, AtKeys keys, Secondary.Address remoteSecondaryAddress, - boolean verbose) - throws AtException { - DefaultAtConnectionFactory connectionFactory = new DefaultAtConnectionFactory(); - AtEvents.AtEventBus eventBus = new SimpleAtEventBus(); - - RemoteSecondary secondary; - try { - secondary = new RemoteSecondary(eventBus, atSign, remoteSecondaryAddress, keys, connectionFactory, verbose); - } catch (IOException e) { - throw new AtSecondaryConnectException("Failed to create RemoteSecondary", e); - } - - return new AtClientImpl(eventBus, atSign, keys, secondary); - } - - /** - * Factory to use when you already know the address of the remote (cloud) secondary - * - * @param remoteSecondaryAddress the address of the remote secondary server - * @param atSign the {@link AtSign} of this client - e.g. @alice - * @param keys the {@link AtKeys} for this client - * @return An {@link AtClient} - * @throws AtException if any other exception occurs while connecting to the remote (cloud) - * secondary - */ - static AtClient withRemoteSecondary(Secondary.Address remoteSecondaryAddress, AtSign atSign, AtKeys keys) - throws AtException { - return withRemoteSecondary(atSign, keys, remoteSecondaryAddress, false); - } - - +public interface AtClient extends AtEvents.AtEventBus, AutoCloseable { AtSign getAtSign(); - Secondary getSecondary(); - AtKeys getEncryptionKeys(); CompletableFuture get(SharedKey sharedKey); CompletableFuture getBinary(SharedKey sharedKey); - CompletableFuture put(SharedKey sharedKey, String value); + CompletableFuture put(SharedKey sharedKey, String value); - CompletableFuture delete(SharedKey sharedKey); + CompletableFuture delete(SharedKey sharedKey); CompletableFuture get(SelfKey selfKey); CompletableFuture getBinary(SelfKey selfKey); - CompletableFuture put(SelfKey selfKey, String value); + CompletableFuture put(SelfKey selfKey, String value); - CompletableFuture delete(SelfKey selfKey); + CompletableFuture delete(SelfKey selfKey); CompletableFuture get(PublicKey publicKey); @@ -209,17 +43,25 @@ static AtClient withRemoteSecondary(Secondary.Address remoteSecondaryAddress, At CompletableFuture getBinary(PublicKey publicKey, GetRequestOptions getRequestOptions); - CompletableFuture put(PublicKey publicKey, String value); + CompletableFuture put(PublicKey publicKey, String value); - CompletableFuture delete(PublicKey publicKey); + CompletableFuture delete(PublicKey publicKey); - CompletableFuture put(SharedKey sharedKey, byte[] value); + CompletableFuture put(SharedKey sharedKey, byte[] value); - CompletableFuture put(SelfKey selfKey, byte[] value); + CompletableFuture put(SelfKey selfKey, byte[] value); - CompletableFuture put(PublicKey publicKey, byte[] value); + CompletableFuture put(PublicKey publicKey, byte[] value); CompletableFuture> getAtKeys(String regex); CompletableFuture> getAtKeys(String regex, boolean fetchMetadata); + + void startMonitor(); + + void stopMonitor(); + + boolean isMonitorRunning(); + + AtClientConnection getCommandExecutor(); } diff --git a/at_client/src/main/java/org/atsign/client/api/AtConnection.java b/at_client/src/main/java/org/atsign/client/api/AtConnection.java deleted file mode 100644 index 21ace30b..00000000 --- a/at_client/src/main/java/org/atsign/client/api/AtConnection.java +++ /dev/null @@ -1,44 +0,0 @@ -package org.atsign.client.api; - -import java.io.IOException; -import java.net.Socket; - -import org.atsign.common.AtException; - -/** - * A simple abstraction around connections to @ platform services - e.g. the root server and - * secondary servers - */ -@SuppressWarnings("unused") -public interface AtConnection { - String getUrl(); - - String getHost(); - - int getPort(); - - Socket getSocket(); - - boolean isConnected(); - - boolean isAutoReconnect(); - - boolean isVerbose(); - - void setVerbose(boolean verbose); - - void connect() throws IOException, AtException; - - void disconnect(); - - String executeCommand(String command) throws IOException; - - /** - * Represents something which can implement atprotocol authentication workflow - * by executing commands with a {@link AtConnection} - */ - interface Authenticator { - void authenticate(AtConnection connection) throws AtException, IOException; - } - -} diff --git a/at_client/src/main/java/org/atsign/client/api/AtConnectionFactory.java b/at_client/src/main/java/org/atsign/client/api/AtConnectionFactory.java deleted file mode 100644 index 8936769f..00000000 --- a/at_client/src/main/java/org/atsign/client/api/AtConnectionFactory.java +++ /dev/null @@ -1,32 +0,0 @@ -package org.atsign.client.api; - -import org.atsign.client.api.impl.connections.AtRootConnection; -import org.atsign.client.api.impl.connections.AtSecondaryConnection; -import org.atsign.common.AtSign; - -/** - * For getting a hold of AtConnections to things. We inject an AtConnectionFactory into - * AtClientImpl, primarily for testability - */ -public interface AtConnectionFactory { - AtSecondaryConnection getSecondaryConnection(AtEvents.AtEventBus eventBus, - AtSign atSign, - Secondary.Address secondaryAddress, - AtConnection.Authenticator authenticator); - - AtSecondaryConnection getSecondaryConnection(AtEvents.AtEventBus eventBus, - AtSign atSign, - String secondaryUrl, - AtConnection.Authenticator authenticator, - boolean verbose); - - AtSecondaryConnection getSecondaryConnection(AtEvents.AtEventBus eventBus, - AtSign atSign, - Secondary.Address secondaryAddress, - AtConnection.Authenticator authenticator, - boolean verbose); - - AtRootConnection getRootConnection(AtEvents.AtEventBus eventBus, String rootUrl); - - AtRootConnection getRootConnection(AtEvents.AtEventBus eventBus, String rootUrl, boolean verbose); -} diff --git a/at_client/src/main/java/org/atsign/client/api/Secondary.java b/at_client/src/main/java/org/atsign/client/api/Secondary.java deleted file mode 100644 index b6a0619b..00000000 --- a/at_client/src/main/java/org/atsign/client/api/Secondary.java +++ /dev/null @@ -1,154 +0,0 @@ -package org.atsign.client.api; - -import java.io.Closeable; -import java.io.IOException; - -import org.atsign.client.connection.protocol.AtExceptions; -import org.atsign.common.AtException; -import org.atsign.common.AtSign; -import org.atsign.common.exceptions.*; - -/** - * Clients ultimately talk to a Secondary server - usually this is a microservice which implements - * the @ protocol server spec, running somewhere in the cloud. - *
- * In the initial implementation we just have AtClientImpl talking to a RemoteSecondary which in - * turn - * talks, via TLS over a secure socket, to the cloud Secondary server. - *
- * As we implement client-side offline storage, performance caching etc., we can expect e.g. - *
- * AtClient {@code ->} FastCacheSecondary {@code ->} OfflineStorageSecondary {@code ->} - * RemoteSecondary - *
- * where FastCacheSecondary might be an in-memory LRU cache, and OfflineStorageSecondary is a - * persistent cache of some or all of the information in the RemoteSecondary. To make this - * possible, each Secondary will need to be able to fully handle the @ protocol, thus the - * interface is effectively the same as when interacting with a cloud secondary via openssl - * from command line. - */ -public interface Secondary extends AtEvents.AtEventListener, Closeable { - /** - * @param command in @ protocol format - * @param throwExceptionOnErrorResponse sometimes we want to inspect an error response, - * sometimes we want to just throw an exception - * @return response in @ protocol format - * @throws AtException if there was an error response and throwExceptionOnErrorResponse is true - * @throws IOException if one is encountered - */ - Response executeCommand(String command, boolean throwExceptionOnErrorResponse) throws IOException, AtException; - - void startMonitor(); - - void stopMonitor(); - - boolean isMonitorRunning(); - - /** - * Used to hold the partially decoded response from a {@link Secondary} - */ - class Response { - private String rawDataResponse = null; - private String rawErrorResponse; - private String errorCode; - private String errorText; - - public String getRawDataResponse() { - return rawDataResponse; - } - - public void setRawDataResponse(String s) { - rawDataResponse = s; - rawErrorResponse = null; - errorCode = null; - errorText = null; - } - - public String getRawErrorResponse() { - return rawErrorResponse; - } - - public void setRawErrorResponse(String s) { - // In format "AT1234-meaning of error code : " - rawErrorResponse = s; - rawDataResponse = null; - - int codeDelimiter = rawErrorResponse.indexOf(":"); - String errorCodeSegment = rawErrorResponse.substring(0, codeDelimiter).trim(); - String[] separatedByHyphen = errorCodeSegment.split("-"); - errorCode = separatedByHyphen[0].trim(); - errorText = rawErrorResponse.substring(codeDelimiter + 1).trim(); - } - - public boolean isError() { - return rawErrorResponse != null; - } - - public String getErrorCode() { - return errorCode; - } - - public String getErrorText() { - return errorText; - } - - @Override - public String toString() { - if (isError()) { - return "error:" + rawErrorResponse; - } else { - return "data:" + rawDataResponse; - } - } - - public AtException getException() { - if (!isError()) { - return null; - } - return AtExceptions.toTypedException(errorCode, errorText); - } - } - - /** - * Value class for hostname and port tuple - */ - class Address { - public final String host; - public final int port; - - public Address(String host, int port) { - this.host = host; - this.port = port; - } - - public static Address fromString(String hostAndPort) throws IllegalArgumentException { - String[] split = hostAndPort.split(":"); - if (split.length != 2) { - throw new IllegalArgumentException( - "Cannot construct Secondary.Address from malformed host:port string '" + hostAndPort + "'"); - } - String host = split[0]; - int port; - try { - port = Integer.parseInt(split[1]); - } catch (NumberFormatException e) { - throw new IllegalArgumentException( - "Cannot construct Secondary.Address from malformed host:port string '" + hostAndPort + "'"); - } - return new Address(host, port); - } - - @Override - public String toString() { - return host + ":" + port; - } - } - - /** - * Represents something that, given an {@link AtSign}, can resolve the {@link Address} of the - * {@link Secondary} for this {@link AtSign} - */ - interface AddressFinder { - Address findSecondary(AtSign atSign) throws IOException, AtSecondaryNotFoundException; - } -} diff --git a/at_client/src/main/java/org/atsign/client/api/impl/clients/AtClientImpl.java b/at_client/src/main/java/org/atsign/client/api/impl/clients/AtClientImpl.java deleted file mode 100644 index 9f320e31..00000000 --- a/at_client/src/main/java/org/atsign/client/api/impl/clients/AtClientImpl.java +++ /dev/null @@ -1,796 +0,0 @@ -package org.atsign.client.api.impl.clients; - -import static org.atsign.client.api.AtEvents.AtEventType.decryptedUpdateNotification; -import static org.atsign.client.api.AtKeyNames.toSharedByMeKeyName; -import static org.atsign.client.util.Preconditions.checkNotNull; -import static org.atsign.common.VerbBuilders.*; -import static org.atsign.common.VerbBuilders.LookupOperation.all; -import static org.atsign.common.VerbBuilders.LookupOperation.meta; - -import java.io.IOException; -import java.util.*; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; -import java.util.concurrent.TimeUnit; - - -import lombok.extern.slf4j.Slf4j; -import org.atsign.client.api.AtClient; -import org.atsign.client.api.AtEvents.AtEventBus; -import org.atsign.client.api.AtEvents.AtEventListener; -import org.atsign.client.api.AtEvents.AtEventType; -import org.atsign.client.api.AtKeyNames; -import org.atsign.client.api.AtKeys; -import org.atsign.client.api.Secondary; -import org.atsign.client.util.EncryptionUtil; -import org.atsign.common.*; -import org.atsign.common.Keys.AtKey; -import org.atsign.common.Keys.PublicKey; -import org.atsign.common.Keys.SelfKey; -import org.atsign.common.Keys.SharedKey; -import org.atsign.common.exceptions.*; -import org.atsign.common.options.GetRequestOptions; -import org.atsign.common.response_models.LookupResponse; - -import com.fasterxml.jackson.core.JsonProcessingException; - -/** - * Implementation of an {@link AtClient} which wraps a {@link Secondary} - * in order to implement the "map like" features of the {@link AtClient} interface - */ -@SuppressWarnings({"RedundantThrows", "unused"}) -@Slf4j -public class AtClientImpl implements AtClient { - - // Factory method - creates an AtClientImpl with a RemoteSecondary - - private final AtSign atSign; - - @Override - public AtSign getAtSign() { - return atSign; - } - - private final AtKeys keys; - - @Override - public AtKeys getEncryptionKeys() { - return keys; - } - - private final Secondary secondary; - - @Override - public Secondary getSecondary() { - return secondary; - } - - private final AtEventBus eventBus; - - public AtClientImpl(AtEventBus eventBus, AtSign atSign, AtKeys keys, Secondary secondary) { - this.eventBus = eventBus; - this.atSign = atSign; - this.keys = keys; - this.secondary = secondary; - checkNotNull(keys.getEncryptPrivateKey(), "AtKeys have not been fully enrolled"); - eventBus.addEventListener(this, EnumSet.allOf(AtEventType.class)); - } - - @Override - public void startMonitor() { - secondary.startMonitor(); - } - - @Override - public void stopMonitor() { - secondary.stopMonitor(); - } - - @Override - public boolean isMonitorRunning() { - return secondary.isMonitorRunning(); - } - - @Override - public synchronized void addEventListener(AtEventListener listener, Set eventTypes) { - eventBus.addEventListener(listener, eventTypes); - } - - @Override - public synchronized void removeEventListener(AtEventListener listener) { - eventBus.removeEventListener(listener); - } - - @Override - public int publishEvent(AtEventType eventType, Map eventData) { - return eventBus.publishEvent(eventType, eventData); - } - - @Override - public synchronized void handleEvent(AtEventType eventType, Map eventData) { - switch (eventType) { - case sharedKeyNotification: - // We've got notification that someone has shared an encryption key with us - // If we also got a value, we can decrypt it and add it to our keys map - // Note: a value isn't supplied when the ttr on the shared key was set to 0 - if (eventData.get("value") != null) { - String sharedSharedKeyName = (String) eventData.get("key"); - String sharedSharedKeyEncryptedValue = (String) eventData.get("value"); - // decrypt it with our encryption private key - try { - String sharedKeyDecryptedValue = - EncryptionUtil.rsaDecryptFromBase64(sharedSharedKeyEncryptedValue, keys.getEncryptPrivateKey()); - keys.put(sharedSharedKeyName, sharedKeyDecryptedValue); - } catch (Exception e) { - log.error("caught exception {} while decrypting received shared key {}", e, sharedSharedKeyName); - } - } - break; - case updateNotification: - // Let's see if we can decrypt it on the fly - if (eventData.get("value") != null) { - String key = (String) eventData.get("key"); - String encryptedValue = (String) eventData.get("value"); - @SuppressWarnings("unchecked") - Map metadata = (Map) eventData.get("metadata"); - String ivNonce = (String) metadata.get("ivNonce"); - - try { - // decrypt it with the symmetric key that the other atSign shared with me - SharedKey sk = Keys.sharedKeyBuilder().rawKey(key).build(); - String encryptionKeySharedByOther = getEncryptionKeySharedByOther(sk); - - String decryptedValue = - EncryptionUtil.aesDecryptFromBase64(encryptedValue, encryptionKeySharedByOther, ivNonce); - HashMap newEventData = new HashMap<>(eventData); - newEventData.put("decryptedValue", decryptedValue); - eventBus.publishEvent(decryptedUpdateNotification, newEventData); - } catch (Exception e) { - log.error("caught exception while decrypting received data with key name [{}]", key, e); - } - } - break; - default: - break; - } - } - - @Override - public CompletableFuture get(SharedKey sharedKey) { - return CompletableFuture.supplyAsync(() -> { - try { - return _get(sharedKey); - } catch (Exception e) { - throw new CompletionException(e); - } - }); - } - - @Override - public CompletableFuture getBinary(SharedKey sharedKey) { - return CompletableFuture.supplyAsync(() -> { - try { - return _getBinary(sharedKey); - } catch (Exception e) { - throw new CompletionException(e); - } - }); - } - - @Override - public CompletableFuture put(SharedKey sharedKey, String value) { - return CompletableFuture.supplyAsync(() -> { - try { - return _put(sharedKey, value); - } catch (Exception e) { - throw new CompletionException(e); - } - }); - } - - @Override - public CompletableFuture delete(SharedKey sharedKey) { - return CompletableFuture.supplyAsync(() -> { - try { - return _delete(sharedKey); - } catch (Exception e) { - throw new CompletionException(e); - } - }); - } - - @Override - public CompletableFuture get(SelfKey selfKey) { - return CompletableFuture.supplyAsync(() -> { - try { - return _get(selfKey); - } catch (Exception e) { - throw new CompletionException(e); - } - }); - } - - @Override - public CompletableFuture getBinary(SelfKey selfKey) { - return CompletableFuture.supplyAsync(() -> { - try { - return _getBinary(selfKey); - } catch (Exception e) { - throw new CompletionException(e); - } - }); - } - - @Override - public CompletableFuture put(SelfKey selfKey, String value) { - return CompletableFuture.supplyAsync(() -> { - try { - return _put(selfKey, value); - } catch (Exception e) { - throw new CompletionException(e); - } - }); - } - - @Override - public CompletableFuture delete(SelfKey selfKey) { - return CompletableFuture.supplyAsync(() -> { - try { - return _delete(selfKey); - } catch (Exception e) { - throw new CompletionException(e); - } - }); - } - - @Override - public CompletableFuture get(PublicKey publicKey) { - return CompletableFuture.supplyAsync(() -> { - try { - return _get(publicKey); - } catch (Exception e) { - throw new CompletionException(e); - } - }); - } - - @Override - public CompletableFuture get(PublicKey publicKey, GetRequestOptions getRequestOptions) { - return CompletableFuture.supplyAsync(() -> { - try { - return _get(publicKey, getRequestOptions); - } catch (Exception e) { - throw new CompletionException(e); - } - }); - } - - @Override - public CompletableFuture getBinary(PublicKey publicKey) { - return CompletableFuture.supplyAsync(() -> { - try { - return _getBinary(publicKey); - } catch (Exception e) { - throw new CompletionException(e); - } - }); - } - - @Override - public CompletableFuture getBinary(PublicKey publicKey, GetRequestOptions getRequestOptions) { - return CompletableFuture.supplyAsync(() -> { - try { - return _getBinary(publicKey, getRequestOptions); - } catch (Exception e) { - throw new CompletionException(e); - } - }); - } - - @Override - public CompletableFuture put(PublicKey publicKey, String value) { - return CompletableFuture.supplyAsync(() -> { - try { - return _put(publicKey, value); - } catch (Exception e) { - throw new CompletionException(e); - } - }); - } - - @Override - public CompletableFuture delete(PublicKey publicKey) { - return CompletableFuture.supplyAsync(() -> { - try { - return _delete(publicKey); - } catch (Exception e) { - throw new CompletionException(e); - } - }); - } - - @Override - public CompletableFuture put(SharedKey sharedKey, byte[] value) { - return CompletableFuture.supplyAsync(() -> { - try { - return _put(sharedKey, value); - } catch (Exception e) { - throw new CompletionException(e); - } - }); - } - - @Override - public CompletableFuture put(SelfKey selfKey, byte[] value) { - return CompletableFuture.supplyAsync(() -> { - try { - return _put(selfKey, value); - } catch (Exception e) { - throw new CompletionException(e); - } - }); - } - - @Override - public CompletableFuture put(PublicKey publicKey, byte[] value) { - return CompletableFuture.supplyAsync(() -> { - try { - return _put(publicKey, value); - } catch (Exception e) { - throw new CompletionException(e); - } - }); - } - - @Override - public CompletableFuture> getAtKeys(String regex) { - return getAtKeys(regex, true); - } - - @Override - public CompletableFuture> getAtKeys(String regex, boolean fetchMetadata) { - return CompletableFuture.supplyAsync(() -> { - try { - return _getAtKeys(regex, fetchMetadata); - } catch (Exception e) { - throw new CompletionException(e); - } - }); - } - - /** - * Synchronous, talks @-protocol directly to the client's Secondary server - * - * @param command in @ protocol format - * @param throwExceptionOnErrorResponse sometimes we want to inspect an error response, - * sometimes we want to just throw an exception - * @return a Secondary Response - * @throws AtException if the response from the Secondary starts with 'error:', or - * if there is any other exception - */ - @Override - public Response executeCommand(String command, boolean throwExceptionOnErrorResponse) - throws AtException, IOException { - return secondary.executeCommand(command, throwExceptionOnErrorResponse); - } - - @Override - public void close() throws IOException { - stopMonitor(); - secondary.close(); - } - - // ============================================================================================================================================ - // ============================================================================================================================================ - // ============================================================================================================================================ - - // - // Synchronous methods which do the actual work - // - private String _get(SharedKey sharedKey) throws AtException { - if (sharedKey.sharedBy().equals(atSign)) { - return _getSharedByMeWithOther(sharedKey); - } else { - return _getSharedByOtherWithMe(sharedKey); - } - } - - private String _getSharedByMeWithOther(SharedKey sharedKey) throws AtException { - String shareEncryptionKey = getEncryptionKeySharedByMe(sharedKey); - - String command = llookupCommandBuilder().key(sharedKey).operation(all).build(); - LookupResponse response = getLookupResponse(command); - - try { - return EncryptionUtil.aesDecryptFromBase64(response.data, shareEncryptionKey, response.metaData.ivNonce()); - } catch (Exception e) { - throw new AtDecryptionException("Failed to decrypt value with shared encryption key", e); - } - } - - private String _getSharedByOtherWithMe(SharedKey sharedKey) throws AtException { - String what; - String shareEncryptionKey = getEncryptionKeySharedByOther(sharedKey); - - String command = lookupCommandBuilder().key(sharedKey).operation(all).build(); - LookupResponse response = getLookupResponse(command); - - what = "decrypt value with shared encryption key"; - try { - return EncryptionUtil.aesDecryptFromBase64(response.data, shareEncryptionKey, response.metaData.ivNonce()); - } catch (Exception e) { - throw new AtDecryptionException("Failed to " + what, e); - } - } - - private String _put(SharedKey sharedKey, String value) throws AtException { - if (!this.atSign.equals(sharedKey.sharedBy())) { - throw new AtIllegalArgumentException( - "sharedBy is [" + sharedKey.sharedBy() + "] but should be this client's atSign [" + atSign + "]"); - } - String what = ""; - String cipherText; - try { - what = "fetch/create shared encryption key"; - String shareToEncryptionKey = getEncryptionKeySharedByMe(sharedKey); - - what = "encrypt value with shared encryption key"; - String iv = EncryptionUtil.generateRandomIvBase64(16); - sharedKey.updateMissingMetadata(Metadata.builder().ivNonce(iv).build()); - cipherText = EncryptionUtil.aesEncryptToBase64(value, shareToEncryptionKey, iv); - } catch (Exception e) { - throw new AtEncryptionException("Failed to " + what, e); - } - - String command = updateCommandBuilder().key(sharedKey).value(cipherText).build(); - - try { - return secondary.executeCommand(command, true).toString(); - } catch (IOException e) { - throw new AtSecondaryConnectException("Failed to execute " + command, e); - } - } - - private String _delete(SharedKey sharedKey) throws AtException { - String command = deleteCommandBuilder().key(sharedKey).build(); - try { - return secondary.executeCommand(command, true).toString(); - } catch (IOException e) { - throw new AtSecondaryConnectException("Failed to execute " + command, e); - } - } - - private String _get(SelfKey key) throws AtException { - // 1. build command - String command = llookupCommandBuilder().key(key).operation(all).build(); - - // 2. execute command - LookupResponse fetched = getLookupResponse(command); - - // 3. decrypt the value - String decryptedValue; - String encryptedValue = fetched.data; - String selfEncryptionKey = keys.getSelfEncryptKey(); - String iv = checkNotNull(fetched.metaData.ivNonce(), "ivNonce is null"); - decryptedValue = EncryptionUtil.aesDecryptFromBase64(encryptedValue, selfEncryptionKey, iv); - - // 4. update metadata. squash the fetchedMetadata with current key.metadata (fetchedMetadata has higher priority) - key.overwriteMetadata(fetched.metaData); - - return decryptedValue; - } - - private String _put(SelfKey selfKey, String value) throws AtException { - // 1. generate dataSignature - Metadata metadata = Metadata.builder() - .dataSignature(generateSignature(value)) - .ivNonce(EncryptionUtil.generateRandomIvBase64(16)) - .build(); - selfKey.updateMissingMetadata(metadata); - // 2. encrypt data with self encryption key - String cipherText = EncryptionUtil.aesEncryptToBase64(value, keys.getSelfEncryptKey(), metadata.ivNonce()); - - // 3. update secondary - String command = updateCommandBuilder().key(selfKey).value(cipherText).build(); - try { - return secondary.executeCommand(command, true).toString(); - } catch (IOException e) { - throw new AtSecondaryConnectException("Failed to execute " + command, e); - } - } - - private String _delete(SelfKey key) throws AtException { - // 1. build delete command - String command = deleteCommandBuilder().key(key).build(); - - // 2. run command - try { - return secondary.executeCommand(command, true).toString(); - } catch (IOException e) { - throw new AtSecondaryConnectException("Failed to execute " + command, e); - } - } - - private String _get(PublicKey key) throws AtException { - return _get(key, null); - } - - private String _get(PublicKey key, GetRequestOptions getRequestOptions) throws AtException { - // 1. build command - String command; - if (atSign.equals(key.sharedBy())) { - command = llookupCommandBuilder().key(key).operation(all).build(); - } else { - boolean bypassCache = getRequestOptions != null && getRequestOptions.isBypassCache(); - command = plookupCommandBuilder().key(key).bypassCache(bypassCache).operation(all).build(); - } - - // 2. run the command - LookupResponse fetched = getLookupResponse(command); - - // 4. update key object metadata - Metadata metadata = fetched.metaData.toBuilder().isCached(fetched.key.contains("cached:")).build(); - key.overwriteMetadata(metadata); - - // 5. return the AtValue - return fetched.data; - } - - private String _put(PublicKey publicKey, String value) throws AtException { - // 1. generate dataSignature - Metadata metadata = Metadata.builder().dataSignature(generateSignature(value)).build(); - publicKey.updateMissingMetadata(metadata); - - // 2. build command - String command = updateCommandBuilder().key(publicKey).value(value).build(); - - // 3. run command - try { - return secondary.executeCommand(command, true).toString(); - } catch (IOException e) { - throw new AtSecondaryConnectException("Failed to execute " + command, e); - } - } - - private String _delete(PublicKey key) throws AtException { - // 1. build command - String command = deleteCommandBuilder().key(key).build(); - - // 2. run command - try { - return secondary.executeCommand(command, true).toString(); - } catch (IOException e) { - throw new AtSecondaryConnectException("Failed to execute " + command, e); - } - } - - private byte[] _getBinary(SharedKey sharedKey) throws AtException { - throw new RuntimeException("Not Implemented"); - } - - private byte[] _getBinary(SelfKey selfKey) throws AtException { - throw new RuntimeException("Not Implemented"); - } - - private byte[] _getBinary(PublicKey publicKey) throws AtException { - throw new RuntimeException("Not Implemented"); - } - - private byte[] _getBinary(PublicKey publicKey, GetRequestOptions getRequestOptions) throws AtException { - throw new RuntimeException("Not Implemented"); - } - - private String _put(SharedKey sharedKey, byte[] value) throws AtException { - throw new RuntimeException("Not Implemented"); - } - - private String _put(SelfKey selfKey, byte[] value) throws AtException { - throw new RuntimeException("Not Implemented"); - } - - private String _put(PublicKey publicKey, byte[] value) throws AtException { - throw new RuntimeException("Not Implemented"); - } - - private List _getAtKeys(String regex, boolean fetchMetadata) throws AtException { - String scanCommand = scanCommandBuilder().regex(regex).showHidden(true).build(); - Response scanRawResponse; - try { - scanRawResponse = executeCommand(scanCommand, true); - } catch (IOException e) { - throw new AtSecondaryConnectException("Failed to execute " + scanCommand, e); - } - ResponseTransformers.ScanResponseTransformer scanResponseTransformer = - new ResponseTransformers.ScanResponseTransformer(AtClientImpl::isNotManagementKey); - List rawArray = scanResponseTransformer.apply(scanRawResponse); - - List atKeys = new ArrayList<>(); - for (String atKeyRaw : rawArray) { - AtKey atKey = Keys.keyBuilder().rawKey(atKeyRaw).build(); - if (fetchMetadata) { - String llookupCommand = llookupCommandBuilder().operation(meta).rawKey(atKeyRaw).build(); - Response llookupMetaResponse; - try { - llookupMetaResponse = secondary.executeCommand(llookupCommand, true); - } catch (IOException e) { - throw new AtSecondaryConnectException("Failed to execute " + llookupCommand, e); - } - try { - // atKey.metadata has priority over llookupMetaRaw.data - Metadata responseMetadata = Metadata.fromJson(llookupMetaResponse.getRawDataResponse()); - atKey.updateMissingMetadata(responseMetadata); - } catch (JsonProcessingException e) { - throw new AtResponseHandlingException("Failed to parse JSON " + llookupMetaResponse.getRawDataResponse(), e); - } - } - atKeys.add(atKey); - } - return atKeys; - } - - // ============================================================================================================================================ - // ============================================================================================================================================ - // ============================================================================================================================================ - - // - // Internal utility methods. Will move these to another class later, so that other AtClient implementations can easily use them. - // - - private LookupResponse getLookupResponse(String command) throws AtException { - Response response; - try { - response = secondary.executeCommand(command, true); - } catch (IOException e) { - throw new AtSecondaryConnectException("Failed to execute " + command, e); - } - - // 3. transform the data to a LlookupAllResponse object - LookupResponse fetched; - try { - fetched = Json.MAPPER.readValue(response.getRawDataResponse(), LookupResponse.class); - } catch (JsonProcessingException e) { - throw new AtResponseHandlingException("Failed to parse JSON " + response.getRawDataResponse(), e); - } - return fetched; - } - - private String getEncryptionKeySharedByMe(SharedKey key) throws AtException { - Secondary.Response rawResponse; - String command = llookupCommandBuilder().keyName(toSharedByMeKeyName(key.sharedWith())).sharedBy(atSign).build(); - try { - rawResponse = secondary.executeCommand(command, false); - } catch (IOException e) { - throw new AtSecondaryConnectException("Failed to execute " + command, e); - } - - if (rawResponse.isError()) { - if (rawResponse.getException() instanceof AtKeyNotFoundException) { - // No key found - so we should create one - return createSharedEncryptionKey(key); - } else { - throw rawResponse.getException(); - } - } - - // When we stored it, we encrypted it with our encryption public key; so we need to decrypt it now with our - // encryption private key - return EncryptionUtil.rsaDecryptFromBase64(rawResponse.getRawDataResponse(), keys.getEncryptPrivateKey()); - } - - private String getEncryptionKeySharedByOther(SharedKey sharedKey) throws AtException { - // Let's see if it's in our in-memory cache - String sharedSharedKeyName = AtKeyNames.toSharedWithMeKeyName(sharedKey.sharedBy(), sharedKey.sharedWith()); - - String sharedKeyValue = keys.get(sharedSharedKeyName); - if (sharedKeyValue != null) { - return sharedKeyValue; - } - - String what = ""; - - // Not in memory so now let's try to fetch from remote - e.g. if I'm @bob, lookup:shared_key@alice - String lookupCommand = lookupCommandBuilder().keyName(AtKeyNames.SHARED_KEY).sharedBy(sharedKey.sharedBy()).build(); - Response rawResponse; - try { - rawResponse = secondary.executeCommand(lookupCommand, true); - } catch (IOException e) { - throw new AtSecondaryConnectException("Failed to execute " + lookupCommand, e); - } - - String sharedSharedKeyDecryptedValue; - try { - sharedSharedKeyDecryptedValue = - EncryptionUtil.rsaDecryptFromBase64(rawResponse.getRawDataResponse(), keys.getEncryptPrivateKey()); - } catch (Exception e) { - throw new AtDecryptionException("Failed to decrypt the shared_key with our encryption private key", e); - } - keys.put(sharedSharedKeyName, sharedSharedKeyDecryptedValue); - - return sharedSharedKeyDecryptedValue; - } - - private String createSharedEncryptionKey(SharedKey sharedKey) throws AtException { - // We need their public key - String theirPublicEncryptionKey = getPublicEncryptionKey(sharedKey.sharedWith()); - if (theirPublicEncryptionKey == null) { - throw new AtKeyNotFoundException(" public key " + sharedKey.sharedWith() - + " not found but service is running - maybe that AtSign has not yet been onboarded"); - } - - // Cut an AES key - String aesKey; - try { - aesKey = EncryptionUtil.generateAESKeyBase64(); - } catch (Exception e) { - throw new AtEncryptionException("Failed to generate AES key for sharing with " + sharedKey.sharedWith(), e); - } - - String what = ""; - try { - // Encrypt key with the other at-sign's publickey and save it @bob:shared_key@alice - what = "encrypt new shared key with their public key"; - String encryptedForOther = EncryptionUtil.rsaEncryptToBase64(aesKey, theirPublicEncryptionKey); - - what = "encrypt new shared key with our public key"; - // Encrypt key with our publickey and save it shared_key.bob@alice - String encryptedForUs = EncryptionUtil.rsaEncryptToBase64(aesKey, keys.getEncryptPublicKey()); - - what = "save encrypted shared key for us"; - String updateForUs = updateCommandBuilder() - .keyName(toSharedByMeKeyName(sharedKey.sharedWith())) - .sharedBy(sharedKey.sharedBy()) - .value(encryptedForUs) - .build(); - secondary.executeCommand(updateForUs, true); - - what = "save encrypted shared key for them"; - String updateForOther = updateCommandBuilder() - .keyName(AtKeyNames.SHARED_KEY) - .sharedBy(sharedKey.sharedBy()) - .sharedWith(sharedKey.sharedWith()) - .ttr(TimeUnit.HOURS.toMillis(24)) - .value(encryptedForOther) - .build(); - secondary.executeCommand(updateForOther, true); - } catch (Exception e) { - throw new AtEncryptionException("Failed to " + what, e); - } - - return aesKey; - } - - private String getPublicEncryptionKey(AtSign sharedWith) throws AtException { - Secondary.Response rawResponse; - - String command = plookupCommandBuilder().keyName(AtKeyNames.PUBLIC_ENCRYPT).sharedBy(sharedWith).build(); - try { - rawResponse = secondary.executeCommand(command, false); - } catch (IOException e) { - throw new AtSecondaryConnectException("Failed to execute " + command, e); - } - - if (rawResponse.isError()) { - if (rawResponse.getException() instanceof AtKeyNotFoundException) { - return null; - } else { - throw rawResponse.getException(); - } - } else { - return rawResponse.getRawDataResponse(); - } - } - - private String generateSignature(String value) throws AtException { - String signature; - try { - signature = EncryptionUtil.signSHA256RSA(value, keys.getEncryptPrivateKey()); - } catch (Exception e) { - throw new AtEncryptionException("Failed to sign value: " + value, e); - } - return signature; - } - - private static boolean isNotManagementKey(String s) { - return !s.matches(".+\\.__manage@.+"); - } -} diff --git a/at_client/src/main/java/org/atsign/client/api/impl/clients/DefaultAtClientImpl.java b/at_client/src/main/java/org/atsign/client/api/impl/clients/DefaultAtClientImpl.java index f8e03a98..141fec69 100644 --- a/at_client/src/main/java/org/atsign/client/api/impl/clients/DefaultAtClientImpl.java +++ b/at_client/src/main/java/org/atsign/client/api/impl/clients/DefaultAtClientImpl.java @@ -1,9 +1,6 @@ package org.atsign.client.api.impl.clients; import static org.atsign.client.api.AtEvents.AtEventType.decryptedUpdateNotification; -import static org.atsign.client.connection.protocol.Data.matchData; -import static org.atsign.client.connection.protocol.Error.matchError; -import static org.atsign.client.connection.protocol.Error.throwExceptionIfError; import static org.atsign.client.util.EncryptionUtil.aesDecryptFromBase64; import static org.atsign.client.util.EncryptionUtil.rsaDecryptFromBase64; import static org.atsign.client.util.Preconditions.checkNotNull; @@ -20,7 +17,6 @@ import org.atsign.client.api.AtEvents.AtEventListener; import org.atsign.client.api.AtEvents.AtEventType; import org.atsign.client.api.AtKeys; -import org.atsign.client.api.Secondary; import org.atsign.client.connection.api.AtClientConnection; import org.atsign.client.connection.protocol.*; import org.atsign.common.AtException; @@ -61,6 +57,11 @@ public AtKeys getEncryptionKeys() { return keys; } + @Override + public AtClientConnection getCommandExecutor() { + return connection; + } + @Builder public DefaultAtClientImpl(AtSign atSign, AtKeys keys, AtClientConnection connection, AtEventBus eventBus) { checkNotNull(keys.getEncryptPrivateKey(), "AtKeys have not been fully enrolled"); @@ -115,13 +116,7 @@ public int publishEvent(AtEventType eventType, Map eventData) { @Override public CompletableFuture get(SharedKey sharedKey) { - return CompletableFuture.supplyAsync(() -> { - try { - return SharedKeys.get(connection, atSign, keys, sharedKey); - } catch (Exception e) { - throw new CompletionException(e); - } - }); + return wrapAsync(() -> SharedKeys.get(connection, atSign, keys, sharedKey)); } @Override @@ -130,31 +125,18 @@ public CompletableFuture getBinary(SharedKey sharedKey) { } @Override - public CompletableFuture put(SharedKey sharedKey, String value) { - return CompletableFuture.supplyAsync(() -> { - try { - SharedKeys.put(connection, atSign, keys, sharedKey, value); - return null; - } catch (Exception e) { - throw new CompletionException(e); - } - }); + public CompletableFuture put(SharedKey sharedKey, String value) { + return wrapAsync(() -> SharedKeys.put(connection, atSign, keys, sharedKey, value)); } @Override - public CompletableFuture delete(SharedKey sharedKey) { - return deleteKey(sharedKey); + public CompletableFuture delete(SharedKey sharedKey) { + return wrapAsync(() -> Keys.deleteKey(connection, sharedKey)); } @Override public CompletableFuture get(SelfKey selfKey) { - return CompletableFuture.supplyAsync(() -> { - try { - return SelfKeys.get(connection, keys, selfKey); - } catch (Exception e) { - throw new CompletionException(e); - } - }); + return wrapAsync(() -> SelfKeys.get(connection, keys, selfKey)); } @Override @@ -163,42 +145,23 @@ public CompletableFuture getBinary(SelfKey selfKey) { } @Override - public CompletableFuture put(SelfKey selfKey, String value) { - return CompletableFuture.supplyAsync(() -> { - try { - SelfKeys.put(connection, keys, selfKey, value); - return null; - } catch (Exception e) { - throw new CompletionException(e); - } - }); + public CompletableFuture put(SelfKey selfKey, String value) { + return wrapAsync(() -> SelfKeys.put(connection, keys, selfKey, value)); } @Override - public CompletableFuture delete(SelfKey selfKey) { - return deleteKey(selfKey); + public CompletableFuture delete(SelfKey selfKey) { + return wrapAsync(() -> Keys.deleteKey(connection, selfKey)); } @Override public CompletableFuture get(PublicKey publicKey) { - return CompletableFuture.supplyAsync(() -> { - try { - return PublicKeys.get(connection, atSign, publicKey, null); - } catch (Exception e) { - throw new CompletionException(e); - } - }); + return wrapAsync(() -> PublicKeys.get(connection, atSign, publicKey, null)); } @Override public CompletableFuture get(PublicKey publicKey, GetRequestOptions options) { - return CompletableFuture.supplyAsync(() -> { - try { - return PublicKeys.get(connection, atSign, publicKey, options); - } catch (Exception e) { - throw new CompletionException(e); - } - }); + return wrapAsync(() -> PublicKeys.get(connection, atSign, publicKey, options)); } @Override @@ -212,34 +175,27 @@ public CompletableFuture getBinary(PublicKey publicKey, GetRequestOption } @Override - public CompletableFuture put(PublicKey publicKey, String value) { - return CompletableFuture.supplyAsync(() -> { - try { - PublicKeys.put(connection, keys, publicKey, value); - return null; - } catch (Exception e) { - throw new CompletionException(e); - } - }); + public CompletableFuture put(PublicKey publicKey, String value) { + return wrapAsync(() -> PublicKeys.put(connection, keys, publicKey, value)); } @Override - public CompletableFuture delete(PublicKey publicKey) { - return deleteKey(publicKey); + public CompletableFuture delete(PublicKey publicKey) { + return wrapAsync(() -> Keys.deleteKey(connection, publicKey)); } @Override - public CompletableFuture put(SharedKey sharedKey, byte[] value) { + public CompletableFuture put(SharedKey sharedKey, byte[] value) { throw new UnsupportedOperationException("to be implemented"); } @Override - public CompletableFuture put(SelfKey selfKey, byte[] value) { + public CompletableFuture put(SelfKey selfKey, byte[] value) { throw new UnsupportedOperationException("to be implemented"); } @Override - public CompletableFuture put(PublicKey publicKey, byte[] value) { + public CompletableFuture put(PublicKey publicKey, byte[] value) { throw new UnsupportedOperationException("to be implemented"); } @@ -259,46 +215,7 @@ public CompletableFuture> getAtKeys(String regex, boolean fetchMetad }); } - @Override - public Response executeCommand(String command, boolean throwExceptionOnErrorResponse) - throws AtException, IOException { - - try { - String s = connection.sendSync(command); - Response response = new Response(); - if (throwExceptionOnErrorResponse) { - response.setRawDataResponse(throwExceptionIfError(matchData(s))); - } else { - if (s.startsWith("error:")) { - response.setRawErrorResponse(matchError(s)); - } else { - response.setRawDataResponse(matchData(s)); - } - } - return response; - } catch (ExecutionException | InterruptedException e) { - throw new RuntimeException(e); - } - } - - @Override - public Secondary getSecondary() { - throw new UnsupportedOperationException("not supported"); - } - - private CompletableFuture deleteKey(AtKey key) { - return CompletableFuture.supplyAsync(() -> { - try { - Keys.deleteKey(connection, key); - return null; - } catch (Exception e) { - throw new CompletionException(e); - } - }); - } - - @Override - public void handleEvent(AtEventType eventType, Map eventData) { + private void handleEvent(AtEventType eventType, Map eventData) { try { switch (eventType) { case sharedKeyNotification: @@ -342,4 +259,42 @@ private void onUpdateNotification(Map eventData) throws AtExcept eventBus.publishEvent(decryptedUpdateNotification, newEventData); } } + + /** + * A runnable command which returns a value but can throw {@link AtException}s or execution + * exceptions + */ + public interface AtCommandThatReturnsString { + String run() throws AtException, ExecutionException, InterruptedException; + } + + private static CompletableFuture wrapAsync(AtCommandThatReturnsString command) { + return CompletableFuture.supplyAsync(() -> { + try { + return command.run(); + } catch (Exception e) { + throw new CompletionException(e); + } + }); + } + + /** + * A runnable command which does NOT return a value but can throw {@link AtException}s or execution + * exceptions + */ + public interface AtCommandThatReturnsVoid { + void run() throws AtException, ExecutionException, InterruptedException; + } + + private static CompletableFuture wrapAsync(AtCommandThatReturnsVoid command) { + return CompletableFuture.supplyAsync(() -> { + try { + command.run(); + return null; + } catch (Exception e) { + throw new CompletionException(e); + } + }); + } + } diff --git a/at_client/src/main/java/org/atsign/client/api/impl/connections/AtConnectionBase.java b/at_client/src/main/java/org/atsign/client/api/impl/connections/AtConnectionBase.java deleted file mode 100644 index bacd1fa9..00000000 --- a/at_client/src/main/java/org/atsign/client/api/impl/connections/AtConnectionBase.java +++ /dev/null @@ -1,193 +0,0 @@ -package org.atsign.client.api.impl.connections; - -import java.io.IOException; -import java.io.PrintWriter; -import java.net.Socket; -import java.util.Scanner; - -import javax.net.SocketFactory; -import javax.net.ssl.SSLSocketFactory; - -import lombok.extern.slf4j.Slf4j; -import org.atsign.client.api.AtConnection; -import org.atsign.client.api.AtEvents; -import org.atsign.common.AtException; - -/** - * Core implementation of an {@link AtConnection} which uses Java NIO. This - * class contains the implementation of the socket connect / disconnect, - * writing atprotocol commands and reading atprotocol responses. - * If the socket becomes disconnected then it will be re-connected as part - * of sending the next command. - */ -@Slf4j -public abstract class AtConnectionBase implements AtConnection { - - private final String url; - - @Override - public String getUrl() { - return url; - } - - private final String host; - - @Override - public String getHost() { - return host; - } - - private final int port; - - @Override - public int getPort() { - return port; - } - - private Socket socket; - - @Override - public Socket getSocket() { - return socket; - } - - private boolean connected = false; - - @Override - public boolean isConnected() { - return connected; - } - - private final boolean autoReconnect; - - @Override - public boolean isAutoReconnect() { - return autoReconnect; - } - - protected boolean verbose; - - @Override - public boolean isVerbose() { - return verbose; - } - - @Override - public void setVerbose(boolean verbose) { - this.verbose = verbose; - } - - protected final Authenticator authenticator; - - public Authenticator getAuthenticator() { - return authenticator; - } - - protected PrintWriter socketWriter; - protected Scanner socketScanner; - - protected final AtEvents.AtEventBus eventBus; - - public AtConnectionBase(AtEvents.AtEventBus eventBus, - String url, - AtConnection.Authenticator authenticator, - boolean autoReconnect, - boolean verbose) { - this.eventBus = eventBus; - this.url = url; - this.host = url.split(":")[0]; - this.port = Integer.parseInt(url.split(":")[1]); - this.autoReconnect = autoReconnect; - this.verbose = verbose; - this.authenticator = authenticator; - } - - @Override - public synchronized void disconnect() { - if (!isConnected()) { - return; - } - connected = false; - try { - log.debug("disconnecting from {}:{}", host, port); - socket.close(); - socketScanner.close(); - socketWriter.close(); - socket.shutdownInput(); - socket.shutdownOutput(); - } catch (Exception ignore) { - } - } - - @Override - public synchronized void connect() throws IOException, AtException { - if (isConnected()) { - return; - } - SocketFactory sf = SSLSocketFactory.getDefault(); - log.debug("connecting to {}:{}...", host, port); - this.socket = sf.createSocket(host, port); - this.socketWriter = new PrintWriter(socket.getOutputStream()); - this.socketScanner = new Scanner(socket.getInputStream()); - log.debug("connected to {}:{}", host, port); - - if (authenticator != null) { - authenticator.authenticate(this); - } - connected = true; - } - - protected abstract String parseRawResponse(String rawResponse) throws IOException; - - @Override - public final synchronized String executeCommand(String command) throws IOException { - return executeCommand(command, autoReconnect, true); - } - - protected synchronized String executeCommand(String command, boolean retryOnException, boolean readTheResponse) - throws IOException { - if (socket.isClosed()) { - throw new IOException("executeCommand failed: socket is closed"); - } - try { - if (!command.endsWith("\n")) { - command = command + "\n"; - } - socketWriter.write(command); - socketWriter.flush(); - - if (verbose) { - log.info("SENT: {}", command); - } - - if (readTheResponse) { - // Responses are always terminated by newline - String rawResponse = socketScanner.nextLine(); - if (verbose) { - log.info("RCVD: {}", rawResponse); - } - - return parseRawResponse(rawResponse); - } else { - return ""; - } - } catch (Exception first) { - disconnect(); - - if (retryOnException) { - log.error("Caught exception {} : reconnecting", first.toString()); - try { - connect(); - return executeCommand(command, false, true); - } catch (Exception second) { - log.error("failed on retry", second); - throw new IOException("Failed to reconnect after original exception " + first + " : ", second); - } - } else { - connected = false; - - throw new IOException(first); - } - } - } -} diff --git a/at_client/src/main/java/org/atsign/client/api/impl/connections/AtMonitorConnection.java b/at_client/src/main/java/org/atsign/client/api/impl/connections/AtMonitorConnection.java deleted file mode 100644 index dc80d9a5..00000000 --- a/at_client/src/main/java/org/atsign/client/api/impl/connections/AtMonitorConnection.java +++ /dev/null @@ -1,258 +0,0 @@ -package org.atsign.client.api.impl.connections; - -import static org.atsign.client.api.AtEvents.AtEventBus; -import static org.atsign.client.api.AtEvents.AtEventType; -import static org.atsign.client.api.AtEvents.AtEventType.deleteNotification; -import static org.atsign.client.api.AtEvents.AtEventType.monitorException; -import static org.atsign.client.api.AtEvents.AtEventType.monitorHeartbeatAck; -import static org.atsign.client.api.AtEvents.AtEventType.sharedKeyNotification; -import static org.atsign.client.api.AtEvents.AtEventType.statsNotification; -import static org.atsign.client.api.AtEvents.AtEventType.updateNotification; - -import java.util.HashMap; - -import lombok.extern.slf4j.Slf4j; -import org.atsign.common.AtSign; - -import com.fasterxml.jackson.databind.ObjectMapper; -import org.atsign.common.Json; - -/** - * A {@link AtMonitorConnection} represents a connection to an AtServer which, - * when started will send the atprotocol monitor command and then processes - * notifications and heartbeat messages until it is stopped. If the socket disconnects - * then it will be automatically reconnected. - */ -@Slf4j -public class AtMonitorConnection extends AtSecondaryConnection implements Runnable { - - private static final ObjectMapper mapper = Json.MAPPER; - - private long _lastReceivedTime = 0; - - public long getLastReceivedTime() { - return _lastReceivedTime; - } - - public void setLastReceivedTime(long lastReceivedTime) { - this._lastReceivedTime = lastReceivedTime; - } - - private boolean running = false; - - public boolean isRunning() { - return running; - } - - private boolean _shouldBeRunning = false; - - private void setShouldBeRunning(boolean b) { - _shouldBeRunning = b; - } - - public boolean isShouldBeRunning() { - return _shouldBeRunning; - } - - public AtMonitorConnection(AtEventBus eventBus, - AtSign atSign, - String secondaryUrl, - Authenticator authenticator, - boolean verbose) { - // Note that the Monitor doesn't make use of the auto-reconnect functionality, it does its own thing - super(eventBus, atSign, secondaryUrl, authenticator, false, verbose); - startHeartbeat(); - } - - private long lastHeartbeatSentTime = System.currentTimeMillis(); - private long lastHeartbeatAckTime = System.currentTimeMillis(); - private final int heartbeatIntervalMillis = 30000; - - private void startHeartbeat() { - new Thread(() -> { - while (true) { - if (isShouldBeRunning()) { - if (!isRunning() || lastHeartbeatSentTime - lastHeartbeatAckTime >= heartbeatIntervalMillis) { - try { - // heartbeats have stopped being acked - log.error("Monitor heartbeats not being received"); - stopMonitor(); - long waitStartTime = System.currentTimeMillis(); - while (isRunning() && System.currentTimeMillis() - waitStartTime < 5000) { - // wait for monitor to stop - try { - // noinspection BusyWait - Thread.sleep(1000); - } catch (Exception ignore) { - } - } - if (isRunning()) { - log.error("Monitor thread has not stopped, but going to start another one anyway"); - } - startMonitor(); - } catch (Exception e) { - log.error("Monitor restart failed", e); - } - } else { - if (System.currentTimeMillis() - lastHeartbeatSentTime > heartbeatIntervalMillis) { - try { - executeCommand("noop:0", false, false); - lastHeartbeatSentTime = System.currentTimeMillis(); - } catch (Exception ignore) { - // Can't do anything, the heartbeat loop will take care of restarting the monitor connection - } - } - } - } - try { - // noinspection BusyWait - Thread.sleep(heartbeatIntervalMillis / 5); - } catch (Exception ignore) { - } - } - }).start(); - } - - /** - * @return true if the monitor start request has succeeded, or if the monitor is already running. - */ - @SuppressWarnings("UnusedReturnValue") - public synchronized boolean startMonitor() { - lastHeartbeatSentTime = lastHeartbeatAckTime = System.currentTimeMillis(); - - setShouldBeRunning(true); - if (!running) { - running = true; - if (!isConnected()) { - try { - connect(); - } catch (Exception e) { - log.error("startMonitor failed to connect to secondary : {}", e.getMessage()); - running = false; - return false; - } - } - new Thread(this).start(); - } - return true; - } - - public synchronized void stopMonitor() { - setShouldBeRunning(false); - lastHeartbeatSentTime = lastHeartbeatAckTime = System.currentTimeMillis(); - disconnect(); - } - - /** - * Please don't call this directly. Call startMonitor() instead, which starts the monitor in its own - * thread - */ - @SuppressWarnings("unchecked") - @Override - public void run() { - String what = ""; - // call executeCommand("monitor: