From ff266536282c5be7270335053547235a49867e4e Mon Sep 17 00:00:00 2001 From: Max Shaposhnik Date: Mon, 13 Dec 2021 09:00:28 +0200 Subject: [PATCH] Added namespace configurator for existing user SSH keys (#192) Signed-off-by: Max Shaposhnik --- .../infrastructure/kubernetes/Constants.java | 3 + .../kubernetes/KubernetesInfraModule.java | 2 + .../configurator/SshKeysConfigurator.java | 206 ++++++++++++++++++ .../configurator/SshKeysConfiguratorTest.java | 123 +++++++++++ .../openshift/OpenShiftInfraModule.java | 2 + 5 files changed, 336 insertions(+) create mode 100644 infrastructures/kubernetes/src/main/java/org/eclipse/che/workspace/infrastructure/kubernetes/namespace/configurator/SshKeysConfigurator.java create mode 100644 infrastructures/kubernetes/src/test/java/org/eclipse/che/workspace/infrastructure/kubernetes/namespace/configurator/SshKeysConfiguratorTest.java diff --git a/infrastructures/kubernetes/src/main/java/org/eclipse/che/workspace/infrastructure/kubernetes/Constants.java b/infrastructures/kubernetes/src/main/java/org/eclipse/che/workspace/infrastructure/kubernetes/Constants.java index 10f1f93fcc..cbb83e535a 100644 --- a/infrastructures/kubernetes/src/main/java/org/eclipse/che/workspace/infrastructure/kubernetes/Constants.java +++ b/infrastructures/kubernetes/src/main/java/org/eclipse/che/workspace/infrastructure/kubernetes/Constants.java @@ -50,6 +50,9 @@ public final class Constants { public static final String DEV_WORKSPACE_MOUNT_LABEL = "controller.devfile.io/mount-to-devworkspace"; + public static final String DEV_WORKSPACE_WATCH_SECRET_LABEL = + "controller.devfile.io/watch-secret"; + public static final String DEV_WORKSPACE_MOUNT_PATH_ANNOTATION = "controller.devfile.io/mount-path"; public static final String DEV_WORKSPACE_MOUNT_AS_ANNOTATION = "controller.devfile.io/mount-as"; diff --git a/infrastructures/kubernetes/src/main/java/org/eclipse/che/workspace/infrastructure/kubernetes/KubernetesInfraModule.java b/infrastructures/kubernetes/src/main/java/org/eclipse/che/workspace/infrastructure/kubernetes/KubernetesInfraModule.java index c773d9bce7..e1636584e2 100644 --- a/infrastructures/kubernetes/src/main/java/org/eclipse/che/workspace/infrastructure/kubernetes/KubernetesInfraModule.java +++ b/infrastructures/kubernetes/src/main/java/org/eclipse/che/workspace/infrastructure/kubernetes/KubernetesInfraModule.java @@ -51,6 +51,7 @@ import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.RemoveNames import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator.CredentialsSecretConfigurator; import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator.NamespaceConfigurator; import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator.PreferencesConfigMapConfigurator; +import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator.SshKeysConfigurator; import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator.UserPermissionConfigurator; import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator.UserPreferencesConfigurator; import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator.UserProfileConfigurator; @@ -116,6 +117,7 @@ public class KubernetesInfraModule extends AbstractModule { namespaceConfigurators.addBinding().to(WorkspaceServiceAccountConfigurator.class); namespaceConfigurators.addBinding().to(UserProfileConfigurator.class); namespaceConfigurators.addBinding().to(UserPreferencesConfigurator.class); + namespaceConfigurators.addBinding().to(SshKeysConfigurator.class); bind(KubernetesNamespaceService.class); diff --git a/infrastructures/kubernetes/src/main/java/org/eclipse/che/workspace/infrastructure/kubernetes/namespace/configurator/SshKeysConfigurator.java b/infrastructures/kubernetes/src/main/java/org/eclipse/che/workspace/infrastructure/kubernetes/namespace/configurator/SshKeysConfigurator.java new file mode 100644 index 0000000000..a946715992 --- /dev/null +++ b/infrastructures/kubernetes/src/main/java/org/eclipse/che/workspace/infrastructure/kubernetes/namespace/configurator/SshKeysConfigurator.java @@ -0,0 +1,206 @@ +/* + * Copyright (c) 2012-2021 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator; + +import static com.google.common.base.Strings.isNullOrEmpty; +import static java.lang.String.format; +import static java.util.stream.Collectors.toList; +import static org.eclipse.che.workspace.infrastructure.kubernetes.Constants.DEV_WORKSPACE_MOUNT_LABEL; +import static org.eclipse.che.workspace.infrastructure.kubernetes.Constants.DEV_WORKSPACE_MOUNT_PATH_ANNOTATION; +import static org.eclipse.che.workspace.infrastructure.kubernetes.Constants.DEV_WORKSPACE_WATCH_SECRET_LABEL; +import static org.eclipse.che.workspace.infrastructure.kubernetes.namespace.KubernetesObjectUtil.isValidConfigMapKeyName; + +import com.google.common.annotations.VisibleForTesting; +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.Secret; +import io.fabric8.kubernetes.api.model.SecretBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import jakarta.validation.constraints.NotNull; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import javax.inject.Inject; +import org.eclipse.che.api.core.ServerException; +import org.eclipse.che.api.ssh.server.SshManager; +import org.eclipse.che.api.ssh.server.model.impl.SshPairImpl; +import org.eclipse.che.api.ssh.shared.model.SshPair; +import org.eclipse.che.api.workspace.server.spi.InfrastructureException; +import org.eclipse.che.api.workspace.server.spi.NamespaceResolutionContext; +import org.eclipse.che.workspace.infrastructure.kubernetes.KubernetesClientFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * This class mounts existing user SSH Keys into a special Kubernetes Secret on user-s namespace. + */ +public class SshKeysConfigurator implements NamespaceConfigurator { + + public static final String SSH_KEY_SECRET_NAME = "che-git-ssh-key"; + private static final String SSH_KEYS_WILL_NOT_BE_MOUNTED_MESSAGE = + "Ssh keys %s have invalid names and can't be mounted to namespace %s."; + + private static final String SSH_BASE_CONFIG_PATH = "/etc/ssh/"; + + private final SshManager sshManager; + + private final KubernetesClientFactory clientFactory; + + private static final Logger LOG = LoggerFactory.getLogger(SshKeysConfigurator.class); + + public static final Pattern VALID_DOMAIN_PATTERN = + Pattern.compile("^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$"); + + @Inject + public SshKeysConfigurator(SshManager sshManager, KubernetesClientFactory clientFactory) { + this.sshManager = sshManager; + this.clientFactory = clientFactory; + } + + @Override + public void configure(NamespaceResolutionContext namespaceResolutionContext, String namespaceName) + throws InfrastructureException { + + var client = clientFactory.create(); + List vcsSshPairs = getVcsSshPairs(namespaceResolutionContext); + + List invalidSshKeyNames = + vcsSshPairs + .stream() + .filter(keyPair -> !isValidSshKeyPair(keyPair)) + .map(SshPairImpl::getName) + .collect(toList()); + + if (!invalidSshKeyNames.isEmpty()) { + String message = + format( + SSH_KEYS_WILL_NOT_BE_MOUNTED_MESSAGE, invalidSshKeyNames.toString(), namespaceName); + LOG.warn(message); + // filter + vcsSshPairs = vcsSshPairs.stream().filter(this::isValidSshKeyPair).collect(toList()); + } + + if (vcsSshPairs.size() == 0) { + // nothing to provision + return; + } + doProvisionSshKeys(vcsSshPairs, client, namespaceName); + } + + /** + * Return list of keys related to the VCS (Version Control Systems), Git, SVN and etc. Usually + * managed by user + * + * @param context NamespaceResolutionContext + * @return list of ssh pairs + */ + private List getVcsSshPairs(NamespaceResolutionContext context) + throws InfrastructureException { + List sshPairs; + try { + sshPairs = sshManager.getPairs(context.getUserId(), "vcs"); + } catch (ServerException e) { + String message = format("Unable to get SSH Keys. Cause: %s", e.getMessage()); + LOG.warn(message); + throw new InfrastructureException(e); + } + return sshPairs; + } + + @VisibleForTesting + boolean isValidSshKeyPair(SshPairImpl keyPair) { + return isValidConfigMapKeyName(keyPair.getName()) + && VALID_DOMAIN_PATTERN.matcher(keyPair.getName()).matches(); + } + + private void doProvisionSshKeys( + List sshPairs, KubernetesClient client, String namespaceName) { + + StringBuilder sshConfigData = new StringBuilder(); + Map data = new HashMap<>(); + + for (SshPair sshPair : sshPairs) { + sshConfigData.append(buildConfig(sshPair.getName())); + if (!isNullOrEmpty(sshPair.getPrivateKey())) { + data.put( + sshPair.getName(), + Base64.getEncoder().encodeToString(sshPair.getPrivateKey().getBytes())); + data.put( + sshPair.getName() + ".pub", + Base64.getEncoder().encodeToString(sshPair.getPublicKey().getBytes())); + } + } + data.put("ssh_config", Base64.getEncoder().encodeToString(sshConfigData.toString().getBytes())); + Secret secret = + new SecretBuilder() + .addToData(data) + .withType("generic") + .withMetadata(buildMetadata()) + .build(); + + client.secrets().inNamespace(namespaceName).createOrReplace(secret); + } + + private ObjectMeta buildMetadata() { + return new ObjectMetaBuilder() + .withName(SSH_KEY_SECRET_NAME) + .withLabels( + Map.of( + DEV_WORKSPACE_MOUNT_LABEL, "true", + DEV_WORKSPACE_WATCH_SECRET_LABEL, "true")) + .withAnnotations(Map.of(DEV_WORKSPACE_MOUNT_PATH_ANNOTATION, SSH_BASE_CONFIG_PATH)) + .build(); + } + + /** + * Returns the ssh configuration entry which includes host, identity file location and Host Key + * checking policy + * + *

Example of provided configuration: + * + *

+   * host github.com
+   * IdentityFile /.ssh/private/github-com/private
+   * StrictHostKeyChecking = no
+   * 
+ * + * or + * + *
+   * host *
+   * IdentityFile /.ssh/private/default-123456/private
+   * StrictHostKeyChecking = no
+   * 
+ * + * @param name the of key given during generate for vcs service we will consider it as host of + * version control service (e.g. github.com, gitlab.com and etc) if name starts from + * "default-{anyString}" it will be replaced on wildcard "*" host name. Name with format + * "default-{anyString}" will be generated on client side by Theia SSH Plugin, if user doesn't + * provide own name. Details see here: + * https://github.com/eclipse/che/issues/13494#issuecomment-512761661. Note: behavior can be + * improved in 7.x releases after 7.0.0 + * @return the ssh configuration which include host, identity file location and Host Key checking + * policy + */ + private String buildConfig(@NotNull String name) { + String host = name.startsWith("default-") ? "*" : name; + return "host " + + host + + "\nIdentityFile " + + SSH_BASE_CONFIG_PATH + + name + + "\nStrictHostKeyChecking = no" + + "\n\n"; + } +} diff --git a/infrastructures/kubernetes/src/test/java/org/eclipse/che/workspace/infrastructure/kubernetes/namespace/configurator/SshKeysConfiguratorTest.java b/infrastructures/kubernetes/src/test/java/org/eclipse/che/workspace/infrastructure/kubernetes/namespace/configurator/SshKeysConfiguratorTest.java new file mode 100644 index 0000000000..29efc57d1a --- /dev/null +++ b/infrastructures/kubernetes/src/test/java/org/eclipse/che/workspace/infrastructure/kubernetes/namespace/configurator/SshKeysConfiguratorTest.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2012-2021 Red Hat, Inc. + * This program and the accompanying materials are made + * available under the terms of the Eclipse Public License 2.0 + * which is available at https://www.eclipse.org/legal/epl-2.0/ + * + * SPDX-License-Identifier: EPL-2.0 + * + * Contributors: + * Red Hat, Inc. - initial API and implementation + */ +package org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator; + +import static org.eclipse.che.workspace.infrastructure.kubernetes.Constants.DEV_WORKSPACE_MOUNT_LABEL; +import static org.eclipse.che.workspace.infrastructure.kubernetes.Constants.DEV_WORKSPACE_WATCH_SECRET_LABEL; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; + +import io.fabric8.kubernetes.api.model.Secret; +import io.fabric8.kubernetes.client.server.mock.KubernetesServer; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.eclipse.che.api.core.NotFoundException; +import org.eclipse.che.api.core.ServerException; +import org.eclipse.che.api.ssh.server.SshManager; +import org.eclipse.che.api.ssh.server.model.impl.SshPairImpl; +import org.eclipse.che.api.workspace.server.spi.InfrastructureException; +import org.eclipse.che.api.workspace.server.spi.NamespaceResolutionContext; +import org.eclipse.che.workspace.infrastructure.kubernetes.KubernetesClientFactory; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.testng.MockitoTestNGListener; +import org.testng.annotations.AfterMethod; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Listeners; +import org.testng.annotations.Test; + +@Listeners(MockitoTestNGListener.class) +public class SshKeysConfiguratorTest { + + private static final String USER_ID = "user-id"; + private static final String USER_NAME = "user-name"; + private static final String USER_NAMESPACE = "user-namespace"; + + @Mock private KubernetesClientFactory clientFactory; + @Mock private SshManager sshManager; + + @InjectMocks private SshKeysConfigurator sshKeysConfigurator; + + private KubernetesServer kubernetesServer; + private NamespaceResolutionContext context; + + private final SshPairImpl sshPair = + new SshPairImpl(USER_ID, "vcs", "github.com", "public-key", "private-key"); + + @BeforeMethod + public void setUp() throws InfrastructureException, NotFoundException, ServerException { + context = new NamespaceResolutionContext(null, USER_ID, USER_NAME); + kubernetesServer = new KubernetesServer(true, true); + kubernetesServer.before(); + + when(sshManager.getPairs(USER_ID, "vcs")).thenReturn(Collections.singletonList(sshPair)); + when(clientFactory.create()).thenReturn(kubernetesServer.getClient()); + } + + @AfterMethod + public void cleanUp() { + kubernetesServer.getClient().secrets().inNamespace(USER_NAMESPACE).delete(); + kubernetesServer.after(); + } + + @Test + public void shouldCreateSSHKeysSecret() throws InfrastructureException { + sshKeysConfigurator.configure(context, USER_NAMESPACE); + List secrets = + kubernetesServer + .getClient() + .secrets() + .inNamespace(USER_NAMESPACE) + .withLabels( + Map.of( + DEV_WORKSPACE_MOUNT_LABEL, "true", + DEV_WORKSPACE_WATCH_SECRET_LABEL, "true")) + .list() + .getItems(); + assertEquals(secrets.size(), 1); + assertEquals(secrets.get(0).getMetadata().getName(), "che-git-ssh-key"); + assertEquals(secrets.get(0).getData().size(), 3); + assertEquals( + new String(Base64.getDecoder().decode(secrets.get(0).getData().get("github.com"))), + "private-key"); + assertEquals( + new String(Base64.getDecoder().decode(secrets.get(0).getData().get("github.com.pub"))), + "public-key"); + assertEquals( + new String(Base64.getDecoder().decode(secrets.get(0).getData().get("ssh_config"))), + "host github.com\n" + + "IdentityFile /etc/ssh/github.com\n" + + "StrictHostKeyChecking = no\n\n"); + } + + @Test + public void shouldNotCreateSSHKeysSecretFromBadSecret() throws Exception { + SshPairImpl sshPairLocal = + new SshPairImpl(USER_ID, "vcs", "%sd$$$", "public-key", "private-key"); + when(sshManager.getPairs(USER_ID, "vcs")).thenReturn(List.of(sshPairLocal)); + sshKeysConfigurator.configure(context, USER_NAMESPACE); + List secrets = + kubernetesServer + .getClient() + .secrets() + .inNamespace(USER_NAMESPACE) + .withLabels( + Map.of( + DEV_WORKSPACE_MOUNT_LABEL, "true", + DEV_WORKSPACE_WATCH_SECRET_LABEL, "true")) + .list() + .getItems(); + assertEquals(secrets.size(), 0); + } +} diff --git a/infrastructures/openshift/src/main/java/org/eclipse/che/workspace/infrastructure/openshift/OpenShiftInfraModule.java b/infrastructures/openshift/src/main/java/org/eclipse/che/workspace/infrastructure/openshift/OpenShiftInfraModule.java index 17e6932c5e..488abb5519 100644 --- a/infrastructures/openshift/src/main/java/org/eclipse/che/workspace/infrastructure/openshift/OpenShiftInfraModule.java +++ b/infrastructures/openshift/src/main/java/org/eclipse/che/workspace/infrastructure/openshift/OpenShiftInfraModule.java @@ -56,6 +56,7 @@ import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.KubernetesN import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator.CredentialsSecretConfigurator; import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator.NamespaceConfigurator; import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator.PreferencesConfigMapConfigurator; +import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator.SshKeysConfigurator; import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator.UserPreferencesConfigurator; import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.configurator.UserProfileConfigurator; import org.eclipse.che.workspace.infrastructure.kubernetes.namespace.pvc.CommonPVCStrategy; @@ -125,6 +126,7 @@ public class OpenShiftInfraModule extends AbstractModule { namespaceConfigurators.addBinding().to(PreferencesConfigMapConfigurator.class); namespaceConfigurators.addBinding().to(OpenShiftWorkspaceServiceAccountConfigurator.class); namespaceConfigurators.addBinding().to(OpenShiftStopWorkspaceRoleConfigurator.class); + namespaceConfigurators.addBinding().to(SshKeysConfigurator.class); bind(KubernetesNamespaceService.class);