summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLuca Milanesio <luca.milanesio@gmail.com>2020-06-07 09:49:42 +0100
committerLuca Milanesio <luca.milanesio@gmail.com>2020-06-07 09:49:42 +0100
commit28869fc35ae06fee6d88eb45037dbd51352e7959 (patch)
tree3515a2d02457ed5aae9ca2cd973e3bda97e5459c
parent82db49e3174897fb733295d4c3153b140d8d15de (diff)
parent3e316093bd172a2d1a2d2e14eff9615834907197 (diff)
Merge branch 'stable-3.1' into stable-3.2
* stable-3.1: Fix header of "Pitfalls" subsection of Private Changes documentation AccountIT#accountIdApi: Get account API with id rather than name Fix header of "Pitfalls" subsection of Private Changes documentation Set version to 2.15.20-SNAPSHOT Set version to 2.15.19 Close open SSH connections upon account deactivation Allow to listen for account deactivations Extract method to iterate SSH sessions CacheBasedWebSession: Remove unnecessary 'final' in constructor args Add account listener integration tests using a real plugin Bazel: Add always pass test to avoid boilerplate in the CI Deny access over HTTP for disabled accounts Bazel: Consistently use bazelisk during publishing of artifacts Change-Id: I2d23a6fdcf6b44a9f55f3c01f5090aa8f0a343d4
-rw-r--r--Documentation/dev-plugins.txt4
-rw-r--r--Documentation/intro-user.txt3
-rw-r--r--java/com/google/gerrit/acceptance/ExtensionRegistry.java8
-rw-r--r--java/com/google/gerrit/extensions/events/AccountActivationListener.java42
-rw-r--r--java/com/google/gerrit/httpd/CacheBasedWebSession.java22
-rw-r--r--java/com/google/gerrit/httpd/H2CacheBasedWebSession.java12
-rw-r--r--java/com/google/gerrit/server/account/SetInactiveFlag.java24
-rw-r--r--java/com/google/gerrit/server/config/GerritGlobalModule.java2
-rw-r--r--java/com/google/gerrit/server/validators/AccountActivationValidationListener.java6
-rw-r--r--java/com/google/gerrit/sshd/InactiveAccountDisconnector.java60
-rw-r--r--java/com/google/gerrit/sshd/SshModule.java4
-rw-r--r--java/com/google/gerrit/sshd/SshUtil.java30
-rw-r--r--java/com/google/gerrit/sshd/commands/CloseConnection.java51
-rw-r--r--javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java108
-rw-r--r--javatests/com/google/gerrit/acceptance/api/accounts/AccountListenersIT.java212
-rw-r--r--javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java48
16 files changed, 594 insertions, 42 deletions
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 8ab3d62bed..c8c2dff80f 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -391,6 +391,10 @@ Allows to listen to events visible to the specified user. These are the
same link:cmd-stream-events.html#events[events] that are also streamed
by the link:cmd-stream-events.html[gerrit stream-events] command.
+* `com.google.gerrit.extensions.events.AccountActivationListener`:
++
+User account got activated or deactivated
+
* `com.google.gerrit.extensions.events.LifecycleListener`:
+
Plugin start and stop
diff --git a/Documentation/intro-user.txt b/Documentation/intro-user.txt
index 75ad9c2cf4..2323df3a0c 100644
--- a/Documentation/intro-user.txt
+++ b/Documentation/intro-user.txt
@@ -615,8 +615,7 @@ In that case, care should be taken to prevent the CI system from
exposing secret details.
[[private-changes-pitfalls]]
-Pitfalls
-===
+=== Pitfalls
If private changes are used, be aware of the following pitfalls:
diff --git a/java/com/google/gerrit/acceptance/ExtensionRegistry.java b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
index 5376d23a02..cfe796478e 100644
--- a/java/com/google/gerrit/acceptance/ExtensionRegistry.java
+++ b/java/com/google/gerrit/acceptance/ExtensionRegistry.java
@@ -18,6 +18,7 @@ import com.google.gerrit.extensions.api.changes.ActionVisitor;
import com.google.gerrit.extensions.config.CapabilityDefinition;
import com.google.gerrit.extensions.config.DownloadScheme;
import com.google.gerrit.extensions.config.PluginProjectPermissionDefinition;
+import com.google.gerrit.extensions.events.AccountActivationListener;
import com.google.gerrit.extensions.events.AccountIndexedListener;
import com.google.gerrit.extensions.events.ChangeIndexedListener;
import com.google.gerrit.extensions.events.CommentAddedListener;
@@ -73,6 +74,7 @@ public class ExtensionRegistry {
private final DynamicSet<GroupBackend> groupBackends;
private final DynamicSet<AccountActivationValidationListener>
accountActivationValidationListeners;
+ private final DynamicSet<AccountActivationListener> accountActivationListeners;
private final DynamicSet<OnSubmitValidationListener> onSubmitValidationListeners;
private final DynamicSet<WorkInProgressStateChangedListener> workInProgressStateChangedListeners;
private final DynamicMap<CapabilityDefinition> capabilityDefinitions;
@@ -101,6 +103,7 @@ public class ExtensionRegistry {
DynamicSet<RevisionCreatedListener> revisionCreatedListeners,
DynamicSet<GroupBackend> groupBackends,
DynamicSet<AccountActivationValidationListener> accountActivationValidationListeners,
+ DynamicSet<AccountActivationListener> accountActivationListeners,
DynamicSet<OnSubmitValidationListener> onSubmitValidationListeners,
DynamicSet<WorkInProgressStateChangedListener> workInProgressStateChangedListeners,
DynamicMap<CapabilityDefinition> capabilityDefinitions,
@@ -126,6 +129,7 @@ public class ExtensionRegistry {
this.revisionCreatedListeners = revisionCreatedListeners;
this.groupBackends = groupBackends;
this.accountActivationValidationListeners = accountActivationValidationListeners;
+ this.accountActivationListeners = accountActivationListeners;
this.onSubmitValidationListeners = onSubmitValidationListeners;
this.workInProgressStateChangedListeners = workInProgressStateChangedListeners;
this.capabilityDefinitions = capabilityDefinitions;
@@ -229,6 +233,10 @@ public class ExtensionRegistry {
return add(accountActivationValidationListeners, accountActivationValidationListener);
}
+ public Registration add(AccountActivationListener accountDeactivatedListener) {
+ return add(accountActivationListeners, accountDeactivatedListener);
+ }
+
public Registration add(OnSubmitValidationListener onSubmitValidationListener) {
return add(onSubmitValidationListeners, onSubmitValidationListener);
}
diff --git a/java/com/google/gerrit/extensions/events/AccountActivationListener.java b/java/com/google/gerrit/extensions/events/AccountActivationListener.java
new file mode 100644
index 0000000000..b45533b8bf
--- /dev/null
+++ b/java/com/google/gerrit/extensions/events/AccountActivationListener.java
@@ -0,0 +1,42 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.extensions.events;
+
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+
+/**
+ * Notified whenever an account got activated or deactivated.
+ *
+ * <p>This listener is called only after an account got (de)activated and hence cannot cancel the
+ * (de)activation. See {@link
+ * com.google.gerrit.server.validators.AccountActivationValidationListener} for a listener that can
+ * cancel a (de)activation.
+ */
+@ExtensionPoint
+public interface AccountActivationListener {
+ /**
+ * Invoked after an account got activated
+ *
+ * @param id of the account
+ */
+ default void onAccountActivated(int id) {}
+
+ /**
+ * Invoked after an account got deactivated
+ *
+ * @param id of the account
+ */
+ default void onAccountDeactivated(int id) {}
+}
diff --git a/java/com/google/gerrit/httpd/CacheBasedWebSession.java b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
index 7f878aa9fd..5c4830c617 100644
--- a/java/com/google/gerrit/httpd/CacheBasedWebSession.java
+++ b/java/com/google/gerrit/httpd/CacheBasedWebSession.java
@@ -16,6 +16,7 @@ package com.google.gerrit.httpd;
import static java.util.concurrent.TimeUnit.HOURS;
+import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Strings;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.entities.Account;
@@ -27,6 +28,7 @@ import com.google.gerrit.server.AccessPath;
import com.google.gerrit.server.AnonymousUser;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.account.AuthResult;
import com.google.gerrit.server.account.externalids.ExternalId;
import com.google.gerrit.server.config.AuthConfig;
@@ -40,7 +42,7 @@ import org.eclipse.jgit.http.server.GitSmartHttpTools;
@RequestScoped
public abstract class CacheBasedWebSession implements WebSession {
- private static final String ACCOUNT_COOKIE = "GerritAccount";
+ @VisibleForTesting public static final String ACCOUNT_COOKIE = "GerritAccount";
protected static final long MAX_AGE_MINUTES = HOURS.toMinutes(12);
private final HttpServletRequest request;
@@ -50,6 +52,7 @@ public abstract class CacheBasedWebSession implements WebSession {
private final Provider<AnonymousUser> anonymousProvider;
private final IdentifiedUser.RequestFactory identified;
private final EnumSet<AccessPath> okPaths = EnumSet.of(AccessPath.UNKNOWN);
+ private final AccountCache byIdCache;
private Cookie outCookie;
private Key key;
@@ -62,13 +65,15 @@ public abstract class CacheBasedWebSession implements WebSession {
WebSessionManager manager,
AuthConfig authConfig,
Provider<AnonymousUser> anonymousProvider,
- IdentifiedUser.RequestFactory identified) {
+ IdentifiedUser.RequestFactory identified,
+ AccountCache byIdCache) {
this.request = request;
this.response = response;
this.manager = manager;
this.authConfig = authConfig;
this.anonymousProvider = anonymousProvider;
this.identified = identified;
+ this.byIdCache = byIdCache;
if (request.getRequestURI() == null || !GitSmartHttpTools.isGitClient(request)) {
String cookie = readCookie(request);
@@ -85,6 +90,10 @@ public abstract class CacheBasedWebSession implements WebSession {
authFromQueryParameter(token);
}
}
+ if (val != null && !checkAccountStatus(val.getAccountId())) {
+ val = null;
+ okPaths.clear();
+ }
if (val != null && val.needsCookieRefresh()) {
// Session is more than half old; update cache entry with new expiration date.
val = manager.createVal(key, val);
@@ -177,6 +186,11 @@ public abstract class CacheBasedWebSession implements WebSession {
manager.destroy(key);
}
+ if (!checkAccountStatus(id)) {
+ val = null;
+ return;
+ }
+
key = manager.createKey(id);
val = manager.createVal(key, id, rememberMe, identity, null, null);
saveCookie();
@@ -207,6 +221,10 @@ public abstract class CacheBasedWebSession implements WebSession {
return val != null ? val.getSessionId() : null;
}
+ private boolean checkAccountStatus(Account.Id id) {
+ return byIdCache.get(id).filter(as -> as.account().isActive()).isPresent();
+ }
+
private void saveCookie() {
if (response == null) {
return;
diff --git a/java/com/google/gerrit/httpd/H2CacheBasedWebSession.java b/java/com/google/gerrit/httpd/H2CacheBasedWebSession.java
index caced27a79..830d8d609b 100644
--- a/java/com/google/gerrit/httpd/H2CacheBasedWebSession.java
+++ b/java/com/google/gerrit/httpd/H2CacheBasedWebSession.java
@@ -20,6 +20,7 @@ import com.google.gerrit.extensions.registration.DynamicItem;
import com.google.gerrit.httpd.WebSessionManager.Val;
import com.google.gerrit.server.AnonymousUser;
import com.google.gerrit.server.IdentifiedUser.RequestFactory;
+import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.cache.CacheModule;
import com.google.gerrit.server.config.AuthConfig;
import com.google.inject.Inject;
@@ -59,8 +60,15 @@ public class H2CacheBasedWebSession extends CacheBasedWebSession {
@Named(WebSessionManager.CACHE_NAME) Cache<String, Val> cache,
AuthConfig authConfig,
Provider<AnonymousUser> anonymousProvider,
- RequestFactory identified) {
+ RequestFactory identified,
+ AccountCache byIdCache) {
super(
- request, response, managerFactory.create(cache), authConfig, anonymousProvider, identified);
+ request,
+ response,
+ managerFactory.create(cache),
+ authConfig,
+ anonymousProvider,
+ identified,
+ byIdCache);
}
}
diff --git a/java/com/google/gerrit/server/account/SetInactiveFlag.java b/java/com/google/gerrit/server/account/SetInactiveFlag.java
index 32ed6943ec..4b68198dca 100644
--- a/java/com/google/gerrit/server/account/SetInactiveFlag.java
+++ b/java/com/google/gerrit/server/account/SetInactiveFlag.java
@@ -15,6 +15,7 @@
package com.google.gerrit.server.account;
import com.google.gerrit.entities.Account;
+import com.google.gerrit.extensions.events.AccountActivationListener;
import com.google.gerrit.extensions.restapi.ResourceConflictException;
import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
import com.google.gerrit.extensions.restapi.Response;
@@ -38,13 +39,16 @@ public class SetInactiveFlag {
private final PluginSetContext<AccountActivationValidationListener>
accountActivationValidationListeners;
private final Provider<AccountsUpdate> accountsUpdateProvider;
+ private final PluginSetContext<AccountActivationListener> accountActivationListeners;
@Inject
SetInactiveFlag(
PluginSetContext<AccountActivationValidationListener> accountActivationValidationListeners,
- @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider) {
+ @ServerInitiated Provider<AccountsUpdate> accountsUpdateProvider,
+ PluginSetContext<AccountActivationListener> accountActivationListeners) {
this.accountActivationValidationListeners = accountActivationValidationListeners;
this.accountsUpdateProvider = accountsUpdateProvider;
+ this.accountActivationListeners = accountActivationListeners;
}
public Response<?> deactivate(Account.Id accountId)
@@ -77,6 +81,12 @@ public class SetInactiveFlag {
if (alreadyInactive.get()) {
throw new ResourceConflictException("account not active");
}
+
+ // At this point the account got set inactive and no errors occurred
+
+ int id = accountId.get();
+ accountActivationListeners.runEach(l -> l.onAccountDeactivated(id));
+
return Response.none();
}
@@ -107,6 +117,16 @@ public class SetInactiveFlag {
if (exception.get().isPresent()) {
throw exception.get().get();
}
- return alreadyActive.get() ? Response.ok() : Response.created();
+
+ Response<String> res;
+ if (alreadyActive.get()) {
+ res = Response.ok();
+ } else {
+ res = Response.created();
+
+ int id = accountId.get();
+ accountActivationListeners.runEach(l -> l.onAccountActivated(id));
+ }
+ return res;
}
}
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index e447f2b59e..cf592bf772 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -31,6 +31,7 @@ import com.google.gerrit.extensions.config.DownloadScheme;
import com.google.gerrit.extensions.config.ExternalIncludedIn;
import com.google.gerrit.extensions.config.FactoryModule;
import com.google.gerrit.extensions.config.PluginProjectPermissionDefinition;
+import com.google.gerrit.extensions.events.AccountActivationListener;
import com.google.gerrit.extensions.events.AccountIndexedListener;
import com.google.gerrit.extensions.events.AgreementSignupListener;
import com.google.gerrit.extensions.events.AssigneeChangedListener;
@@ -343,6 +344,7 @@ public class GerritGlobalModule extends FactoryModule {
DynamicSet.setOf(binder(), PostReceiveHook.class);
DynamicSet.setOf(binder(), PreUploadHook.class);
DynamicSet.setOf(binder(), PostUploadHook.class);
+ DynamicSet.setOf(binder(), AccountActivationListener.class);
DynamicSet.setOf(binder(), AccountIndexedListener.class);
DynamicSet.setOf(binder(), ChangeIndexedListener.class);
DynamicSet.setOf(binder(), GroupIndexedListener.class);
diff --git a/java/com/google/gerrit/server/validators/AccountActivationValidationListener.java b/java/com/google/gerrit/server/validators/AccountActivationValidationListener.java
index bc52308609..9fdc9e6d71 100644
--- a/java/com/google/gerrit/server/validators/AccountActivationValidationListener.java
+++ b/java/com/google/gerrit/server/validators/AccountActivationValidationListener.java
@@ -26,6 +26,9 @@ public interface AccountActivationValidationListener {
/**
* Called when an account should be activated to allow validation of the account activation.
*
+ * <p>See {@link com.google.gerrit.extensions.events.AccountActivationListener} for a listener
+ * that's run after the account got activated.
+ *
* @param account the account that should be activated
* @throws ValidationException if validation fails
*/
@@ -34,6 +37,9 @@ public interface AccountActivationValidationListener {
/**
* Called when an account should be deactivated to allow validation of the account deactivation.
*
+ * <p>See {@link com.google.gerrit.extensions.events.AccountActivationListener} for a listener
+ * that's run after the account got deactivated.
+ *
* @param account the account that should be deactivated
* @throws ValidationException if validation fails
*/
diff --git a/java/com/google/gerrit/sshd/InactiveAccountDisconnector.java b/java/com/google/gerrit/sshd/InactiveAccountDisconnector.java
new file mode 100644
index 0000000000..1086626ce6
--- /dev/null
+++ b/java/com/google/gerrit/sshd/InactiveAccountDisconnector.java
@@ -0,0 +1,60 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.sshd;
+
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.extensions.events.AccountActivationListener;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.sshd.BaseCommand.Failure;
+import com.google.inject.Inject;
+import java.io.IOException;
+
+/** Closes open SSH connections upon account deactivation. */
+public class InactiveAccountDisconnector implements AccountActivationListener {
+ private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+ private final SshDaemon sshDaemon;
+
+ @Inject
+ InactiveAccountDisconnector(SshDaemon sshDaemon) {
+ this.sshDaemon = sshDaemon;
+ }
+
+ @Override
+ public void onAccountDeactivated(int id) {
+ try {
+ SshUtil.forEachSshSession(
+ sshDaemon,
+ (sshId, sshSession, abstractSession, ioSession) -> {
+ CurrentUser sessionUser = sshSession.getUser();
+ if (sessionUser.isIdentifiedUser() && sessionUser.getAccountId().get() == id) {
+ logger.atInfo().log(
+ "Disconnecting SSH session %s because user %s(%d) got deactivated",
+ abstractSession, sessionUser.getLoggableName(), id);
+ try {
+ abstractSession.disconnect(-1, "user deactivated");
+ } catch (IOException e) {
+ logger.atWarning().withCause(e).log(
+ "Failure while deactivating session %s", abstractSession);
+ }
+ }
+ });
+ } catch (Failure e) {
+ // Ssh Daemon no longer running. Since we're only disconnecting connections anyways, this is
+ // most likely ok, so we log only at info level.
+ logger.atInfo().withCause(e).log("Failure while disconnecting deactivated account %d", id);
+ }
+ }
+}
diff --git a/java/com/google/gerrit/sshd/SshModule.java b/java/com/google/gerrit/sshd/SshModule.java
index e4aa14cf30..9301f8a39a 100644
--- a/java/com/google/gerrit/sshd/SshModule.java
+++ b/java/com/google/gerrit/sshd/SshModule.java
@@ -19,6 +19,7 @@ import static com.google.inject.Scopes.SINGLETON;
import com.google.common.base.CharMatcher;
import com.google.common.base.Splitter;
+import com.google.gerrit.extensions.events.AccountActivationListener;
import com.google.gerrit.extensions.registration.DynamicItem;
import com.google.gerrit.extensions.registration.DynamicSet;
import com.google.gerrit.lifecycle.LifecycleModule;
@@ -103,6 +104,9 @@ public class SshModule extends LifecycleModule {
DynamicItem.itemOf(binder(), SshCreateCommandInterceptor.class);
DynamicSet.setOf(binder(), SshExecuteCommandInterceptor.class);
+ DynamicSet.bind(binder(), AccountActivationListener.class)
+ .to(InactiveAccountDisconnector.class);
+
listener().toInstance(registerInParentInjectors());
listener().to(SshLog.class);
listener().to(SshDaemon.class);
diff --git a/java/com/google/gerrit/sshd/SshUtil.java b/java/com/google/gerrit/sshd/SshUtil.java
index eac97375b2..abbd81d83a 100644
--- a/java/com/google/gerrit/sshd/SshUtil.java
+++ b/java/com/google/gerrit/sshd/SshUtil.java
@@ -19,6 +19,7 @@ import com.google.gerrit.entities.Account;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.account.AccountSshKey;
+import com.google.gerrit.sshd.BaseCommand.Failure;
import com.google.gerrit.sshd.SshScope.Context;
import java.io.BufferedReader;
import java.io.IOException;
@@ -30,7 +31,10 @@ import java.security.interfaces.DSAPublicKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.InvalidKeySpecException;
import org.apache.sshd.common.SshException;
+import org.apache.sshd.common.io.IoAcceptor;
+import org.apache.sshd.common.io.IoSession;
import org.apache.sshd.common.keyprovider.KeyPairProvider;
+import org.apache.sshd.common.session.helpers.AbstractSession;
import org.apache.sshd.common.util.buffer.ByteArrayBuffer;
import org.apache.sshd.server.session.ServerSession;
@@ -152,4 +156,30 @@ public class SshUtil {
final Account.Id account) {
return userFactory.create(sd.getRemoteAddress(), account);
}
+
+ public static void forEachSshSession(SshDaemon sshDaemon, SessionConsumer consumer)
+ throws Failure {
+ IoAcceptor ioAcceptor = sshDaemon.getIoAcceptor();
+ if (ioAcceptor == null) {
+ throw new Failure(1, "fatal: sshd no longer running");
+ }
+ ioAcceptor
+ .getManagedSessions()
+ .forEach(
+ (id, ioSession) -> {
+ AbstractSession abstractSession = AbstractSession.getSession(ioSession, true);
+ if (abstractSession != null) {
+ SshSession sshSession = abstractSession.getAttribute(SshSession.KEY);
+ if (sshSession != null) {
+ consumer.accept(id, sshSession, abstractSession, ioSession);
+ }
+ }
+ });
+ }
+
+ @FunctionalInterface
+ public interface SessionConsumer {
+ public void accept(
+ Long id, SshSession sshSession, AbstractSession abstractSession, IoSession ioSession);
+ }
}
diff --git a/java/com/google/gerrit/sshd/commands/CloseConnection.java b/java/com/google/gerrit/sshd/commands/CloseConnection.java
index 60a878a62c..093f647997 100644
--- a/java/com/google/gerrit/sshd/commands/CloseConnection.java
+++ b/java/com/google/gerrit/sshd/commands/CloseConnection.java
@@ -23,15 +23,12 @@ import com.google.gerrit.sshd.AdminHighPriorityCommand;
import com.google.gerrit.sshd.CommandMetaData;
import com.google.gerrit.sshd.SshCommand;
import com.google.gerrit.sshd.SshDaemon;
-import com.google.gerrit.sshd.SshSession;
+import com.google.gerrit.sshd.SshUtil;
import com.google.inject.Inject;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import org.apache.sshd.common.future.CloseFuture;
-import org.apache.sshd.common.io.IoAcceptor;
-import org.apache.sshd.common.io.IoSession;
-import org.apache.sshd.common.session.helpers.AbstractSession;
import org.kohsuke.args4j.Argument;
import org.kohsuke.args4j.Option;
@@ -60,36 +57,26 @@ final class CloseConnection extends SshCommand {
@Override
protected void run() throws Failure {
- IoAcceptor acceptor = sshDaemon.getIoAcceptor();
- if (acceptor == null) {
- throw new Failure(1, "fatal: sshd no longer running");
- }
- for (String sessionId : sessionIds) {
- boolean connectionFound = false;
- int id = (int) Long.parseLong(sessionId, 16);
- for (IoSession io : acceptor.getManagedSessions().values()) {
- AbstractSession serverSession = AbstractSession.getSession(io, true);
- SshSession sshSession =
- serverSession != null ? serverSession.getAttribute(SshSession.KEY) : null;
- if (sshSession != null && sshSession.getSessionId() == id) {
- connectionFound = true;
- stdout.println("closing connection " + sessionId + "...");
- CloseFuture future = io.close(true);
- if (wait) {
- try {
- future.await();
- stdout.println("closed connection " + sessionId);
- } catch (IOException e) {
- logger.atWarning().log(
- "Wait for connection to close interrupted: %s", e.getMessage());
+ SshUtil.forEachSshSession(
+ sshDaemon,
+ (k, sshSession, abstractSession, ioSession) -> {
+ String sessionId = String.format("%08x", sshSession.getSessionId());
+ if (sessionIds.remove(sessionId)) {
+ stdout.println("closing connection " + sessionId + "...");
+ CloseFuture future = ioSession.close(true);
+ if (wait) {
+ try {
+ future.await();
+ stdout.println("closed connection " + sessionId);
+ } catch (IOException e) {
+ logger.atWarning().log(
+ "Wait for connection to close interrupted: %s", e.getMessage());
+ }
}
}
- break;
- }
- }
- if (!connectionFound) {
- stderr.print("close connection " + sessionId + ": no such connection\n");
- }
+ });
+ for (String sessionId : sessionIds) {
+ stderr.print("close connection " + sessionId + ": no such connection\n");
}
}
}
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
index d5dd2419cb..f9ba8a2272 100644
--- a/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountIT.java
@@ -45,6 +45,10 @@ import static java.util.concurrent.TimeUnit.SECONDS;
import static java.util.stream.Collectors.toList;
import static java.util.stream.Collectors.toSet;
import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.verifyZeroInteractions;
import com.github.rholder.retry.StopStrategies;
import com.google.common.collect.FluentIterable;
@@ -84,6 +88,7 @@ import com.google.gerrit.entities.Change;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.exceptions.StorageException;
+import com.google.gerrit.extensions.api.accounts.AccountApi;
import com.google.gerrit.extensions.api.accounts.AccountInput;
import com.google.gerrit.extensions.api.accounts.DeleteDraftCommentsInput;
import com.google.gerrit.extensions.api.accounts.DeletedDraftCommentInfo;
@@ -104,6 +109,7 @@ import com.google.gerrit.extensions.common.EmailInfo;
import com.google.gerrit.extensions.common.GpgKeyInfo;
import com.google.gerrit.extensions.common.GroupInfo;
import com.google.gerrit.extensions.common.SshKeyInfo;
+import com.google.gerrit.extensions.events.AccountActivationListener;
import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.extensions.restapi.BadRequestException;
@@ -115,6 +121,7 @@ import com.google.gerrit.git.LockFailureException;
import com.google.gerrit.gpg.Fingerprint;
import com.google.gerrit.gpg.PublicKeyStore;
import com.google.gerrit.gpg.testing.TestKey;
+import com.google.gerrit.httpd.CacheBasedWebSession;
import com.google.gerrit.mail.Address;
import com.google.gerrit.server.ExceptionHook;
import com.google.gerrit.server.ServerInitiated;
@@ -159,6 +166,14 @@ import java.util.Optional;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.stream.Collectors;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.http.HttpResponse;
+import org.apache.http.client.ClientProtocolException;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.impl.client.BasicCookieStore;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClientBuilder;
import org.bouncycastle.bcpg.ArmoredOutputStream;
import org.bouncycastle.openpgp.PGPPublicKey;
import org.bouncycastle.openpgp.PGPPublicKeyRing;
@@ -183,6 +198,7 @@ import org.eclipse.jgit.transport.PushResult;
import org.eclipse.jgit.transport.RemoteRefUpdate;
import org.eclipse.jgit.treewalk.TreeWalk;
import org.junit.After;
+import org.junit.Before;
import org.junit.Test;
public class AccountIT extends AbstractDaemonTest {
@@ -221,6 +237,9 @@ public class AccountIT extends AbstractDaemonTest {
@Inject protected GroupOperations groupOperations;
+ private BasicCookieStore httpCookieStore;
+ private CloseableHttpClient httpclient;
+
@After
public void clearPublicKeyStore() throws Exception {
try (Repository repo = repoManager.openRepository(allUsers)) {
@@ -247,6 +266,16 @@ public class AccountIT extends AbstractDaemonTest {
}
}
+ @Before
+ public void createHttpClient() {
+ httpCookieStore = new BasicCookieStore();
+ httpclient =
+ HttpClientBuilder.create()
+ .disableRedirectHandling()
+ .setDefaultCookieStore(httpCookieStore)
+ .build();
+ }
+
protected void assertLabelPermission(
Project.NameKey project,
GroupReference groupReference,
@@ -577,13 +606,50 @@ public class AccountIT extends AbstractDaemonTest {
}
@Test
+ @GerritConfig(name = "auth.type", value = "DEVELOPMENT_BECOME_ANY_ACCOUNT")
+ public void activeUserGetSessionCookieOnLogin() throws Exception {
+ Integer accountId = accountIdApi().get()._accountId;
+ assertThat(accountIdApi().getActive()).isTrue();
+
+ webLogin(accountId);
+ assertThat(getCookiesNames()).contains(CacheBasedWebSession.ACCOUNT_COOKIE);
+ }
+
+ @Test
+ @GerritConfig(name = "auth.type", value = "DEVELOPMENT_BECOME_ANY_ACCOUNT")
+ public void inactiveUserDoesNotGetCookieOnLogin() throws Exception {
+ Integer accountId = accountIdApi().get()._accountId;
+ accountIdApi().setActive(false);
+ assertThat(accountIdApi().getActive()).isFalse();
+
+ webLogin(accountId);
+ assertThat(getCookiesNames()).isEmpty();
+ }
+
+ @Test
+ @GerritConfig(name = "auth.type", value = "DEVELOPMENT_BECOME_ANY_ACCOUNT")
+ public void userDeactivatedAfterLoginDoesNotGetCookie() throws Exception {
+ Integer accountId = accountIdApi().get()._accountId;
+ assertThat(accountIdApi().getActive()).isTrue();
+
+ webLogin(accountId);
+ assertThat(getCookiesNames()).contains(CacheBasedWebSession.ACCOUNT_COOKIE);
+ httpGetAndAssertStatus("accounts/self/detail", HttpServletResponse.SC_OK);
+
+ accountIdApi().setActive(false);
+ assertThat(accountIdApi().getActive()).isFalse();
+
+ httpGetAndAssertStatus("accounts/self/detail", HttpServletResponse.SC_FORBIDDEN);
+ }
+
+ @Test
public void validateAccountActivation() throws Exception {
Account.Id activatableAccountId =
accountOperations.newAccount().inactive().preferredEmail("foo@activatable.com").create();
Account.Id deactivatableAccountId =
accountOperations.newAccount().preferredEmail("foo@deactivatable.com").create();
- AccountActivationValidationListener listener =
+ AccountActivationValidationListener validationListener =
new AccountActivationValidationListener() {
@Override
public void validateActivation(AccountState account) throws ValidationException {
@@ -601,7 +667,11 @@ public class AccountIT extends AbstractDaemonTest {
}
}
};
- try (Registration registration = extensionRegistry.newRegistration().add(listener)) {
+
+ AccountActivationListener listener = mock(AccountActivationListener.class);
+
+ try (Registration registration =
+ extensionRegistry.newRegistration().add(validationListener).add(listener)) {
/* Test account that can be activated, but not deactivated */
// Deactivate account that is already inactive
ResourceConflictException thrown =
@@ -610,14 +680,18 @@ public class AccountIT extends AbstractDaemonTest {
() -> gApi.accounts().id(activatableAccountId.get()).setActive(false));
assertThat(thrown).hasMessageThat().isEqualTo("account not active");
assertThat(accountOperations.account(activatableAccountId).get().active()).isFalse();
+ verifyZeroInteractions(listener);
// Activate account that can be activated
gApi.accounts().id(activatableAccountId.get()).setActive(true);
assertThat(accountOperations.account(activatableAccountId).get().active()).isTrue();
+ verify(listener).onAccountActivated(activatableAccountId.get());
+ verifyNoMoreInteractions(listener);
// Activate account that is already active
gApi.accounts().id(activatableAccountId.get()).setActive(true);
assertThat(accountOperations.account(activatableAccountId).get().active()).isTrue();
+ verifyZeroInteractions(listener);
// Try deactivating account that cannot be deactivated
thrown =
@@ -626,15 +700,19 @@ public class AccountIT extends AbstractDaemonTest {
() -> gApi.accounts().id(activatableAccountId.get()).setActive(false));
assertThat(thrown).hasMessageThat().isEqualTo("not allowed to deactive account");
assertThat(accountOperations.account(activatableAccountId).get().active()).isTrue();
+ verifyZeroInteractions(listener);
/* Test account that can be deactivated, but not activated */
// Activate account that is already inactive
gApi.accounts().id(deactivatableAccountId.get()).setActive(true);
assertThat(accountOperations.account(deactivatableAccountId).get().active()).isTrue();
+ verifyZeroInteractions(listener);
// Deactivate account that can be deactivated
gApi.accounts().id(deactivatableAccountId.get()).setActive(false);
assertThat(accountOperations.account(deactivatableAccountId).get().active()).isFalse();
+ verify(listener).onAccountDeactivated(deactivatableAccountId.get());
+ verifyNoMoreInteractions(listener);
// Deactivate account that is already inactive
thrown =
@@ -643,6 +721,7 @@ public class AccountIT extends AbstractDaemonTest {
() -> gApi.accounts().id(deactivatableAccountId.get()).setActive(false));
assertThat(thrown).hasMessageThat().isEqualTo("account not active");
assertThat(accountOperations.account(deactivatableAccountId).get().active()).isFalse();
+ verifyZeroInteractions(listener);
// Try activating account that cannot be activated
thrown =
@@ -651,6 +730,7 @@ public class AccountIT extends AbstractDaemonTest {
() -> gApi.accounts().id(deactivatableAccountId.get()).setActive(true));
assertThat(thrown).hasMessageThat().isEqualTo("not allowed to active account");
assertThat(accountOperations.account(deactivatableAccountId).get().active()).isFalse();
+ verifyZeroInteractions(listener);
}
}
@@ -2981,6 +3061,30 @@ public class AccountIT extends AbstractDaemonTest {
assertThat(Iterables.getOnlyElement(accounts)).isEqualTo(expectedAccount.id());
}
+ private AccountApi accountIdApi() throws RestApiException {
+ return gApi.accounts().id(user.id().get());
+ }
+
+ private Set<String> getCookiesNames() {
+ Set<String> cookieNames =
+ httpCookieStore.getCookies().stream()
+ .map(cookie -> cookie.getName())
+ .collect(Collectors.toSet());
+ return cookieNames;
+ }
+
+ private void webLogin(Integer accountId) throws IOException, ClientProtocolException {
+ httpGetAndAssertStatus(
+ "login?account_id=" + accountId, HttpServletResponse.SC_MOVED_TEMPORARILY);
+ }
+
+ private void httpGetAndAssertStatus(String urlPath, int expectedHttpStatus)
+ throws ClientProtocolException, IOException {
+ HttpGet httpGet = new HttpGet(canonicalWebUrl.get() + urlPath);
+ HttpResponse loginResponse = httpclient.execute(httpGet);
+ assertThat(loginResponse.getStatusLine().getStatusCode()).isEqualTo(expectedHttpStatus);
+ }
+
private static class RefUpdateCounter implements GitReferenceUpdatedListener {
private final AtomicLongMap<String> countsByProjectRefs = AtomicLongMap.create();
diff --git a/javatests/com/google/gerrit/acceptance/api/accounts/AccountListenersIT.java b/javatests/com/google/gerrit/acceptance/api/accounts/AccountListenersIT.java
new file mode 100644
index 0000000000..80eff9606a
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/api/accounts/AccountListenersIT.java
@@ -0,0 +1,212 @@
+// Copyright (C) 2020 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.acceptance.api.accounts;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+
+import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.acceptance.testsuite.account.AccountOperations;
+import com.google.gerrit.extensions.events.AccountActivationListener;
+import com.google.gerrit.extensions.registration.DynamicSet;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.account.AccountState;
+import com.google.gerrit.server.validators.AccountActivationValidationListener;
+import com.google.gerrit.server.validators.ValidationException;
+import com.google.inject.AbstractModule;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import org.junit.Before;
+import org.junit.Test;
+
+/**
+ * Tests the wiring of a real plugin's account listeners
+ *
+ * <p>This test really puts focus on the wiring of the account listeners. Tests for the inner
+ * workings of account activation/deactivation can be found in {@link AccountIT}.
+ */
+@TestPlugin(
+ name = "account-listener-it-plugin",
+ sysModule = "com.google.gerrit.acceptance.api.accounts.AccountListenersIT$Module")
+public class AccountListenersIT extends LightweightPluginDaemonTest {
+ @Inject private AccountOperations accountOperations;
+
+ public static class Module extends AbstractModule {
+ @Override
+ protected void configure() {
+ DynamicSet.bind(binder(), AccountActivationValidationListener.class).to(Validator.class);
+ DynamicSet.bind(binder(), AccountActivationListener.class).to(Listener.class);
+ }
+ }
+
+ Validator validator;
+ Listener listener;
+
+ @Before
+ public void setUp() {
+ validator = plugin.getSysInjector().getInstance(Validator.class);
+
+ listener = plugin.getSysInjector().getInstance(Listener.class);
+ }
+
+ @Test
+ public void testActivation() throws RestApiException {
+ int id = accountOperations.newAccount().inactive().create().get();
+
+ gApi.accounts().id(id).setActive(true);
+
+ validator.assertActivationValidation(id);
+ listener.assertActivated(id);
+ assertNoMoreEvents();
+ assertThat(gApi.accounts().id(id).getActive()).isTrue();
+ }
+
+ @Test
+ public void testActivationProhibited() throws RestApiException {
+ int id = accountOperations.newAccount().inactive().create().get();
+
+ validator.failActivationValidations();
+
+ assertThrows(
+ ResourceConflictException.class,
+ () -> {
+ gApi.accounts().id(id).setActive(true);
+ });
+
+ validator.assertActivationValidation(id);
+ // No call to activation listener as validation failed
+ assertNoMoreEvents();
+ assertThat(gApi.accounts().id(id).getActive()).isFalse();
+ }
+
+ @Test
+ public void testDeactivation() throws RestApiException {
+ int id = accountOperations.newAccount().active().create().get();
+
+ gApi.accounts().id(id).setActive(false);
+
+ validator.assertDeactivationValidation(id);
+ listener.assertDeactivated(id);
+ assertNoMoreEvents();
+ assertThat(gApi.accounts().id(id).getActive()).isFalse();
+ }
+
+ @Test
+ public void testDeactivationProhibited() throws RestApiException {
+ int id = accountOperations.newAccount().active().create().get();
+
+ validator.failDeactivationValidations();
+
+ assertThrows(
+ ResourceConflictException.class,
+ () -> {
+ gApi.accounts().id(id).setActive(false);
+ });
+
+ validator.assertDeactivationValidation(id);
+ // No call to activation listener as validation failed
+ assertNoMoreEvents();
+ assertThat(gApi.accounts().id(id).getActive()).isTrue();
+ }
+
+ private void assertNoMoreEvents() {
+ validator.assertNoMoreEvents();
+ listener.assertNoMoreEvents();
+ }
+
+ @Singleton
+ public static class Validator implements AccountActivationValidationListener {
+ private Integer lastIdActivationValidation;
+ private Integer lastIdDeactivationValidation;
+ private boolean failActivationValidations;
+ private boolean failDeactivationValidations;
+
+ @Override
+ public void validateActivation(AccountState account) throws ValidationException {
+ assertThat(lastIdActivationValidation).isNull();
+ lastIdActivationValidation = account.account().id().get();
+ if (failActivationValidations) {
+ throw new ValidationException("testing validation failure");
+ }
+ }
+
+ @Override
+ public void validateDeactivation(AccountState account) throws ValidationException {
+ assertThat(lastIdDeactivationValidation).isNull();
+ lastIdDeactivationValidation = account.account().id().get();
+ if (failDeactivationValidations) {
+ throw new ValidationException("testing validation failure");
+ }
+ }
+
+ public void failActivationValidations() {
+ failActivationValidations = true;
+ }
+
+ public void failDeactivationValidations() {
+ failDeactivationValidations = true;
+ }
+
+ private void assertNoMoreEvents() {
+ assertThat(lastIdActivationValidation).isNull();
+ assertThat(lastIdDeactivationValidation).isNull();
+ }
+
+ private void assertActivationValidation(int id) {
+ assertThat(lastIdActivationValidation).isEqualTo(id);
+ lastIdActivationValidation = null;
+ }
+
+ private void assertDeactivationValidation(int id) {
+ assertThat(lastIdDeactivationValidation).isEqualTo(id);
+ lastIdDeactivationValidation = null;
+ }
+ }
+
+ @Singleton
+ public static class Listener implements AccountActivationListener {
+ private Integer lastIdActivated;
+ private Integer lastIdDeactivated;
+
+ @Override
+ public void onAccountActivated(int id) {
+ assertThat(lastIdActivated).isNull();
+ lastIdActivated = id;
+ }
+
+ @Override
+ public void onAccountDeactivated(int id) {
+ assertThat(lastIdDeactivated).isNull();
+ lastIdDeactivated = id;
+ }
+
+ private void assertNoMoreEvents() {
+ assertThat(lastIdActivated).isNull();
+ assertThat(lastIdDeactivated).isNull();
+ }
+
+ private void assertDeactivated(int id) {
+ assertThat(lastIdDeactivated).isEqualTo(id);
+ lastIdDeactivated = null;
+ }
+
+ private void assertActivated(int id) {
+ assertThat(lastIdActivated).isEqualTo(id);
+ lastIdActivated = null;
+ }
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
index 5421d8c86a..bbe7b814b7 100644
--- a/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
+++ b/javatests/com/google/gerrit/acceptance/ssh/SshCommandsIT.java
@@ -18,6 +18,7 @@ import static com.google.common.collect.ImmutableList.toImmutableList;
import static com.google.common.truth.Truth.assertThat;
import static com.google.common.truth.Truth.assertWithMessage;
+import com.google.common.base.Splitter;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.Streams;
@@ -29,6 +30,9 @@ import com.google.gerrit.acceptance.UseSsh;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
+import java.util.Spliterator;
+import java.util.stream.Collectors;
+import java.util.stream.StreamSupport;
import org.junit.Test;
@NoHttpd
@@ -165,6 +169,38 @@ public class SshCommandsIT extends AbstractDaemonTest {
assertThat(commands).containsExactlyElementsIn(SLAVE_COMMANDS.get("gerrit")).inOrder();
}
+ @Test
+ @Sandboxed
+ public void showConnections() throws Exception {
+ Spliterator<String> connectionsOutput =
+ getOutputLines(adminSshSession.exec("gerrit show-connections"));
+
+ assertThat(findConnectionsInOutput(connectionsOutput, "user")).hasSize(1);
+ }
+
+ @Test
+ @Sandboxed
+ public void cloeConnections() throws Exception {
+ List<String> connectionsOutput =
+ findConnectionsInOutput(
+ getOutputLines(adminSshSession.exec("gerrit show-connections")), "user");
+ String connectionId =
+ Splitter.on(" ")
+ .trimResults()
+ .omitEmptyStrings()
+ .split(connectionsOutput.get(0))
+ .iterator()
+ .next();
+
+ String closeConnectionOutput = adminSshSession.exec("gerrit close-connection " + connectionId);
+ assertThat(closeConnectionOutput).contains(connectionId);
+
+ assertThat(
+ findConnectionsInOutput(
+ getOutputLines(adminSshSession.exec("gerrit show-connections")), "user"))
+ .isEmpty();
+ }
+
private List<String> parseCommandsFromGerritHelpText(String helpText) {
List<String> commands = new ArrayList<>();
@@ -197,4 +233,16 @@ public class SshCommandsIT extends AbstractDaemonTest {
return commands;
}
+
+ private List<String> findConnectionsInOutput(Spliterator<String> connectionsOutput, String user) {
+ List<String> connections =
+ StreamSupport.stream(connectionsOutput, false)
+ .filter(s -> s.contains("localhost") && s.contains(user))
+ .collect(Collectors.toList());
+ return connections;
+ }
+
+ private Spliterator<String> getOutputLines(String output) throws Exception {
+ return Splitter.on("\n").trimResults().omitEmptyStrings().split(output).spliterator();
+ }
}