From ca466f563910529b84072d6536e7a3a88a2bb8fe Mon Sep 17 00:00:00 2001 From: TdlQ <12106009+TdlQ@users.noreply.github.com> Date: Tue, 16 Sep 2025 17:12:45 +0200 Subject: [PATCH 01/27] GUACAMOLE-2137: Change: take TokenFilter's modifier into account to retrieve value --- .../java/org/apache/guacamole/token/TokenFilter.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/token/TokenFilter.java b/guacamole-ext/src/main/java/org/apache/guacamole/token/TokenFilter.java index 673e134d90..99eab585e0 100644 --- a/guacamole-ext/src/main/java/org/apache/guacamole/token/TokenFilter.java +++ b/guacamole-ext/src/main/java/org/apache/guacamole/token/TokenFilter.java @@ -129,8 +129,11 @@ public void setToken(String name, String value) { * The value of the token with the given name, or null if no such * token exists. */ - public String getToken(String name) { - return tokenValues.get(name); + public String getToken(String name, String modifier) { + if (modifier != null && tokenValues.containsKey(name + ":" + modifier)) + return tokenValues.get(name + ":" + modifier); + else + return tokenValues.get(name); } /** @@ -226,7 +229,7 @@ private String filter(String input, boolean strict) // Pull token value String tokenName = tokenMatcher.group(TOKEN_NAME_GROUP); - String tokenValue = getToken(tokenName); + String tokenValue = getToken(tokenName, modifier); // If token is unknown, interpretation depends on whether // strict mode is enabled From 5acdfa5c775eeb268f9f8cd75547f16588fba167 Mon Sep 17 00:00:00 2001 From: TdlQ <12106009+TdlQ@users.noreply.github.com> Date: Tue, 16 Sep 2025 17:16:59 +0200 Subject: [PATCH 02/27] GUACAMOLE-2137: Add: module guacamole-vault-hv (Hashicorp Vault) --- .../modules/guacamole-vault-dist/pom.xml | 7 + .../src/main/assembly/dist.xml | 8 + .../modules/guacamole-vault-hv/.ratignore | 0 .../modules/guacamole-vault-hv/pom.xml | 92 +++++ .../vault/hv/GuacamoleExceptionSupplier.java | 47 +++ .../vault/hv/HvAuthenticationProvider.java | 47 +++ .../hv/HvAuthenticationProviderModule.java | 81 +++++ .../vault/hv/conf/HvAttributeService.java | 155 ++++++++ .../vault/hv/conf/HvConfigurationService.java | 204 +++++++++++ .../guacamole/vault/hv/secret/HvClient.java | 270 ++++++++++++++ .../vault/hv/secret/HvClientFactory.java | 44 +++ .../vault/hv/secret/HvSecretService.java | 335 ++++++++++++++++++ .../vault/hv/secret/HvTimedSecretData.java | 33 ++ .../guacamole/vault/hv/user/HvConnection.java | 68 ++++ .../vault/hv/user/HvConnectionGroup.java | 107 ++++++ .../guacamole/vault/hv/user/HvDirectory.java | 90 +++++ .../vault/hv/user/HvDirectoryService.java | 172 +++++++++ .../guacamole/vault/hv/user/HvUser.java | 126 +++++++ .../vault/hv/user/HvUserFactory.java | 40 +++ .../src/main/resources/guac-manifest.json | 16 + .../src/main/resources/translations/en.json | 23 ++ extensions/guacamole-vault/pom.xml | 1 + 22 files changed, 1966 insertions(+) create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/.ratignore create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/pom.xml create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/GuacamoleExceptionSupplier.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProvider.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProviderModule.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvAttributeService.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientFactory.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvTimedSecretData.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnection.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnectionGroup.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectory.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectoryService.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUser.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUserFactory.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/guac-manifest.json create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/translations/en.json diff --git a/extensions/guacamole-vault/modules/guacamole-vault-dist/pom.xml b/extensions/guacamole-vault/modules/guacamole-vault-dist/pom.xml index 57a0449417..bc9f1dd9b6 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-dist/pom.xml +++ b/extensions/guacamole-vault/modules/guacamole-vault-dist/pom.xml @@ -49,6 +49,13 @@ ${revision} + + + org.apache.guacamole + guacamole-vault-hv + 1.6.0 + + diff --git a/extensions/guacamole-vault/modules/guacamole-vault-dist/src/main/assembly/dist.xml b/extensions/guacamole-vault/modules/guacamole-vault-dist/src/main/assembly/dist.xml index 6f41e52782..c974390d1a 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-dist/src/main/assembly/dist.xml +++ b/extensions/guacamole-vault/modules/guacamole-vault-dist/src/main/assembly/dist.xml @@ -41,6 +41,14 @@ + + + hv + + org.apache.guacamole:guacamole-vault-hv + + + diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/.ratignore b/extensions/guacamole-vault/modules/guacamole-vault-hv/.ratignore new file mode 100644 index 0000000000..e69de29bb2 diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/pom.xml b/extensions/guacamole-vault/modules/guacamole-vault-hv/pom.xml new file mode 100644 index 0000000000..c2905d025d --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/pom.xml @@ -0,0 +1,92 @@ + + + + + 4.0.0 + org.apache.guacamole + guacamole-vault-hv + jar + 1.6.0 + guacamole-vault-hv + http://guacamole.apache.org/ + + + org.apache.guacamole + guacamole-vault + 1.6.0 + ../../ + + + + 1.9.25 + + + + + + + org.apache.guacamole + guacamole-ext + provided + + + + + org.apache.guacamole + guacamole-vault-base + 1.6.0 + + + + com.fasterxml.jackson.core + jackson-databind + 2.19.0 + + + + + org.jetbrains.kotlin + kotlin-reflect + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + org.jetbrains.kotlin + kotlin-stdlib-jdk8 + ${kotlin.version} + + + + + org.bouncycastle + bc-fips + 2.1.0 + + + + + diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/GuacamoleExceptionSupplier.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/GuacamoleExceptionSupplier.java new file mode 100644 index 0000000000..cb4f1a4f62 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/GuacamoleExceptionSupplier.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.guacamole.vault.hv; + +import org.apache.guacamole.GuacamoleException; + +/** + * A class that is basically equivalent to the standard Supplier class in + * Java, except that the get() function can throw GuacamoleException, which + * is impossible with any of the standard Java lambda type classes, since + * none of them can handle checked exceptions + * + * @param + * The type of object which will be returned as a result of calling + * get(). + */ +public interface GuacamoleExceptionSupplier { + + /** + * Returns a value of the declared type. + * + * @return + * A value of the declared type. + * + * @throws GuacamoleException + * If an error occurs while attemping to calculate the return value. + */ + public T get() throws GuacamoleException; + +} \ No newline at end of file diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProvider.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProvider.java new file mode 100644 index 0000000000..08bbb2da95 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProvider.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.guacamole.vault.hv; + +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.vault.VaultAuthenticationProvider; + +/** + * VaultAuthenticationProvider implementation which reads secrets from + * Hashicorp Vault + */ +public class HvAuthenticationProvider extends VaultAuthenticationProvider { + + /** + * Creates a new HvKeyVaultAuthenticationProvider which reads secrets + * from a configured Hashicorp Vault. + * + * @throws GuacamoleException + * If configuration details cannot be read from guacamole.properties. + */ + public HvAuthenticationProvider() throws GuacamoleException { + super(new HvAuthenticationProviderModule()); + } + + @Override + public String getIdentifier() { + return "hashicorp-vault"; + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProviderModule.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProviderModule.java new file mode 100644 index 0000000000..289fcad697 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProviderModule.java @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.guacamole.vault.hv; + +import com.google.inject.assistedinject.FactoryModuleBuilder; +import java.security.Security; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.vault.VaultAuthenticationProviderModule; +import org.apache.guacamole.vault.conf.VaultAttributeService; +import org.apache.guacamole.vault.conf.VaultConfigurationService; +import org.apache.guacamole.vault.hv.conf.HvAttributeService; +import org.apache.guacamole.vault.hv.conf.HvConfigurationService; +import org.apache.guacamole.vault.hv.secret.HvClient; +import org.apache.guacamole.vault.hv.secret.HvClientFactory; +import org.apache.guacamole.vault.hv.secret.HvSecretService; +import org.apache.guacamole.vault.hv.user.HvConnectionGroup; +import org.apache.guacamole.vault.hv.user.HvDirectoryService; +import org.apache.guacamole.vault.hv.user.HvUser; +import org.apache.guacamole.vault.hv.user.HvUserFactory; +import org.apache.guacamole.vault.secret.VaultSecretService; +import org.apache.guacamole.vault.user.VaultDirectoryService; +import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; + +/** + * Guice module which configures injections specific to Hashicorp Vault + * support. + */ +public class HvAuthenticationProviderModule + extends VaultAuthenticationProviderModule { + + /** + * Creates a new HvAuthenticationProviderModule which + * configures dependency injection for the Hashicorp Vault + * authentication provider and related services. + * + * @throws GuacamoleException + * If configuration details in guacamole.properties cannot be parsed. + */ + public HvAuthenticationProviderModule() throws GuacamoleException { + Security.addProvider(new BouncyCastleFipsProvider()); + } + + @Override + protected void configureVault() { + + // Bind services specific to Hashicorp Vault + bind(HvAttributeService.class); + bind(VaultAttributeService.class).to(HvAttributeService.class); + bind(VaultConfigurationService.class).to(HvConfigurationService.class); + bind(VaultSecretService.class).to(HvSecretService.class); + bind(VaultDirectoryService.class).to(HvDirectoryService.class); + + // Bind factory for creating HV Clients + install(new FactoryModuleBuilder() + .implement(HvClient.class, HvClient.class) + .build(HvClientFactory.class)); + + // Bind factory for creating HvUsers + install(new FactoryModuleBuilder() + .implement(HvUser.class, HvUser.class) + .build(HvUserFactory.class)); + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvAttributeService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvAttributeService.java new file mode 100644 index 0000000000..671653e3ec --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvAttributeService.java @@ -0,0 +1,155 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.guacamole.vault.hv.conf; + +import com.google.inject.Singleton; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.form.BooleanField; +import org.apache.guacamole.form.Form; +import org.apache.guacamole.form.TextField; +import org.apache.guacamole.language.TranslatableGuacamoleClientException; +import org.apache.guacamole.vault.conf.VaultAttributeService; + +@Singleton +public class HvAttributeService implements VaultAttributeService { + + /** + * The name of the attribute which can contain a HV configuration blob + * associated with either a connection group or user. + */ + public static final String HV_CONFIGURATION_ATTRIBUTE = "hv-config"; + + /** + * The HV configuration attribute contains sensitive information, so it + * should not be exposed through the directory. Instead, if a value is + * set on the attributes of an object, the following value will be exposed + * in its place, and correspondingly the underlying value will not be + * changed if this value is provided to an update call. + */ + public static final String HV_ATTRIBUTE_PLACEHOLDER_VALUE = "**********"; + + /** + * All attributes related to configuring the HV vault on a + * per-connection-group or per-user basis. + */ + public static final Form HV_CONFIGURATION_FORM = new Form( + "hv-config", + Arrays.asList(new TextField(HV_CONFIGURATION_ATTRIBUTE)) + ); + + /** + * All HV-specific attributes for users, connections, or connection groups, + * organized by form. + */ + public static final Collection
HV_ATTRIBUTES = Collections.unmodifiableCollection(Arrays.asList(HV_CONFIGURATION_FORM)); + + /** + * The name of the attribute which can controls whether a HV user configuration + * is enabled on a connection-by-connection basis. + */ + public static final String HV_USER_CONFIG_ENABLED_ATTRIBUTE = "hv-user-config-enabled"; + + /** + * The string value used by HV attributes to represent the boolean value "true". + */ + public static final String TRUTH_VALUE = "true"; + + /** + * All attributes related to configuring the HV vault on a per-connection basis. + */ + public static final Form HV_CONNECTION_FORM = new Form( + "hv-config", + Arrays.asList(new BooleanField(HV_USER_CONFIG_ENABLED_ATTRIBUTE, TRUTH_VALUE)) + ); + + /** + * All HV-specific attributes for connections, organized by form. + */ + public static final Collection HV_CONNECTION_ATTRIBUTES = Collections.unmodifiableCollection(Arrays.asList(HV_CONNECTION_FORM)); + + @Override + public Collection getConnectionAttributes() { + return HV_CONNECTION_ATTRIBUTES; + } + + @Override + public Collection getUserAttributes() { + return HV_ATTRIBUTES; + } + + @Override + public Collection getUserPreferenceAttributes() { + return getUserAttributes(); + } + + @Override + public Collection getConnectionGroupAttributes() { + return HV_ATTRIBUTES; + } + + /** + * Sanitize the value of the provided HV config attribute. If the provided + * config value is non-empty, it will be replaced with the placeholder + * value to avoid leaking sensitive information. If the value is empty, it + * will be replaced by `null`. + * + * @param hvAttributeValue + * The HV configuration attribute value to sanitize. + * + * @return + * The sanitized HV configuration attribute value, stripped of any + * sensitive information. + */ + public static String sanitizeHvAttributeValue(String hvAttributeValue) { + + // Any non-empty values may contain sensitive information, and should + // be replaced by the safe placeholder value + if (hvAttributeValue != null && !hvAttributeValue.trim().isEmpty()) + return HV_ATTRIBUTE_PLACEHOLDER_VALUE; + + // If the configuration value is empty, expose a null value + else + return null; + + } + + public static Map processAttributes( + Map attributes) throws GuacamoleException { + + // Get the value of the HV config attribute in the provided map + String hvConfigValue = attributes.get(HvAttributeService.HV_CONFIGURATION_ATTRIBUTE); + + // If the placeholder value was provided, do not update the attribute + if (HvAttributeService.HV_ATTRIBUTE_PLACEHOLDER_VALUE.equals(hvConfigValue)) { + // Remove the attribute from the map so it won't be updated + attributes = new HashMap<>(attributes); + attributes.remove(HvAttributeService.HV_CONFIGURATION_ATTRIBUTE); + } + + return attributes; + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java new file mode 100644 index 0000000000..718323e19c --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java @@ -0,0 +1,204 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.guacamole.vault.hv.conf; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import javax.annotation.Nonnull; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.GuacamoleServerException; +import org.apache.guacamole.environment.Environment; +import org.apache.guacamole.properties.BooleanGuacamoleProperty; +import org.apache.guacamole.properties.StringGuacamoleProperty; +import org.apache.guacamole.vault.conf.VaultConfigurationService; + +@Singleton +public class HvConfigurationService extends VaultConfigurationService { + + /** + * Property name of the URL of the vault specified in the base64 configuration blob. + */ + public static final String PARAM_NAME_VAULT_URL = "vault_url"; + + /** + * Property name of the authentication token for the vault specified in the + * base64 configuration blob. + */ + public static final String PARAM_NAME_VAULT_TOKEN = "vault_token"; + + /** + * Property name of the maximum time that cached data is considered valid. + */ + public static final String PARAM_NAME_CACHE_LIFETIME = "cache_lifetime"; + + /** + * The Guacamole server environment. + */ + @Inject + private Environment environment; + + /** + * The name of the file which contains the YAML mapping of connection + * parameter token to secrets within Hashicorp Vault. + */ + private static final String TOKEN_MAPPING_FILENAME = "hv-token-mapping.yml"; + + /** + * The name of the properties file containing Guacamole configuration + * properties whose values are the names of corresponding secrets within + * Hashicorp Vault. + */ + private static final String PROPERTIES_FILENAME = "guacamole.properties.hv"; + + /** + * The base64-encoded configuration information. + */ + private static final StringGuacamoleProperty HV_CONFIG = new StringGuacamoleProperty() { + @Override + public String getName() { + return "hv-config"; + } + }; + + /** + * Whether unverified server certificates should be accepted. + */ + private static final BooleanGuacamoleProperty ALLOW_UNVERIFIED_CERT = new BooleanGuacamoleProperty() { + @Override + public String getName() { + return "hv-allow-unverified-cert"; + } + }; + + /** + * Whether users should be able to supply their own HV configurations. + */ + private static final BooleanGuacamoleProperty ALLOW_USER_CONFIG = new BooleanGuacamoleProperty() { + @Override + public String getName() { + return "hv-allow-user-config"; + } + }; + + /** + * Creates a new HvConfigurationService which reads the configuration + * from "hv-token-mapping.yml" and properties from + * "guacamole.properties.hv". The token mapping is a YAML file which lists + * each connection parameter token and the name of the secret from which + * the value for that token should be read, while the properties file is an + * alternative to guacamole.properties where each property value is the + * name of a secret containing the actual value. + */ + public HvConfigurationService() { + super(TOKEN_MAPPING_FILENAME, PROPERTIES_FILENAME); + } + + /** + * Return whether user-level HV configs should be enabled. If this + * flag is set to true, users can edit their own HV configs, as can + * admins. If set to false, no existing user-specific HV configuration + * will be exposed through the UI or used when looking up secrets. + * + * @return + * true if user-specific HV configuration is enabled, false otherwise. + * + * @throws GuacamoleException + * If the value specified within guacamole.properties cannot be + * parsed. + */ + public boolean getAllowUserConfig() throws GuacamoleException { + return environment.getProperty(ALLOW_USER_CONFIG, false); + } + + // Not used + @Override + public boolean getSplitWindowsUsernames() throws GuacamoleException { + return false; + } + + // Not used + @Override + public boolean getMatchUserRecordsByDomain() throws GuacamoleException { + return false; + } + + /** + * Return the globally-defined base-64-encoded JSON HV configuration blob + * as a string. + * + * @return + * The globally-defined base-64-encoded JSON HV configuration blob + * as a string. + * + * @throws GuacamoleException + * If the value specified within guacamole.properties cannot be + * parsed or does not exist. + */ + @Nonnull + @SuppressWarnings("null") + public String getHvConfig() throws GuacamoleException { + + // This will always return a non-null value; an exception would be + // thrown if the required value is not set + return environment.getRequiredProperty(HV_CONFIG); + } + + /** + * Given a base64-encoded JSON HV configuration, parse and return a + * KeyValueStorage object. + * + * @param value + * The base64-encoded JSON HV configuration to parse. + * + * @return + * The KeyValueStorage that is a result of the parsing operation + * + * @throws GuacamoleException + * If the provided value is not valid base-64 encoded JSON HV configuration. + */ + public static Map parseHvConfig(String value) throws GuacamoleException { + try { + Map config = new HashMap<>(); + String valueDecoded = new String(Base64.getDecoder().decode(value), StandardCharsets.UTF_8); + + ObjectMapper mapper = new ObjectMapper(); + JsonNode jsonNode = mapper.readTree(valueDecoded); + jsonNode.properties().forEach(entry -> { + config.put(entry.getKey(), entry.getValue().asText()); + }); + + return config; + } + catch (IOException e) { + throw new GuacamoleServerException("Invalid JSON configuration for Hashicorp Vault.", e); + } + catch (IllegalArgumentException e) { + throw new GuacamoleServerException("Invalid base64 configuration for Hashicorp Vault.", e); + } + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java new file mode 100644 index 0000000000..b887e1b4e5 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java @@ -0,0 +1,270 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.guacamole.vault.hv.secret; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.inject.Inject; +import com.google.inject.assistedinject.Assisted; +import com.google.inject.assistedinject.AssistedInject; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.Future; +import javax.annotation.Nullable; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.vault.hv.GuacamoleExceptionSupplier; +import org.apache.guacamole.vault.hv.conf.HvConfigurationService; +import org.apache.guacamole.vault.hv.secret.HvTimedSecretData; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Client which retrieves records from Hashicorp Vault. + */ +public class HvClient { + + /** + * Name of the HTTP header for the token, as specified in documentation: + * https://developer.hashicorp.com/vault/docs/auth/token + */ + static final String HASHICORP_VAULT_HTTP_HEADER_TOKEN = "X-Vault-Token"; + + /** + * API version. + */ + static final String HASHICORP_VAULT_HTTP_VERSION = "/v1/"; + + /** + * Name of the Guacamole token to resolve on a Hashicorp Vault (secret path + * is set in the token modifier). + */ + static final String HASHICORP_VAULT_TOKEN_PREFIX = "HASHIVAULT:"; + + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(HvClient.class); + + /** + * HttpClient for this class. + */ + private final HttpClient httpClient; + + /** + * ObjectMapper for this class. + */ + private final ObjectMapper objectMapper; + + /** + * The HV configuration associated with this client instance. + */ + private final Map hvConfig; + + /** + * The maximum amount of time that an entry will be stored in the cache + * before being refreshed, in milliseconds. + */ + private long cacheLifetime; + + /** + * All records retrieved from Hashicorp Vault, where each key is the + * UID of the corresponding record. The contents of this Map are + * automatically updated. + */ + private final ConcurrentMap cachedSecrets = new ConcurrentHashMap<>(); + + /** + * All in-flight HTTP requests to Vault, it prevents multiple queries + * on the same path. + */ + private final ConcurrentMap> inFlightRequests = new ConcurrentHashMap<>(); + + /** + * Create a new HV client based around the provided HV configuration and + * API timeout setting. + * + * @param hvConfig + * The HV configuration to use when retrieving properties from HV. + */ + @AssistedInject + public HvClient(@Assisted Map hvConfig) { + this.hvConfig = hvConfig; + this.httpClient = HttpClient.newHttpClient(); + this.objectMapper = new ObjectMapper(); + + this.cacheLifetime = 60000; + if (hvConfig.containsKey(HvConfigurationService.PARAM_NAME_CACHE_LIFETIME)) { + String strCacheLifetime = hvConfig.get(HvConfigurationService.PARAM_NAME_CACHE_LIFETIME); + try { + this.cacheLifetime = Long.parseLong(strCacheLifetime); + } + catch (NumberFormatException e) { + logger.warn("Invalid {} in HV config: {}", HvConfigurationService.PARAM_NAME_CACHE_LIFETIME, strCacheLifetime); + } + } + } + + /** + * Returns the value of the secret stored within Hashicorp Vault. + * + * @param notation + * The HV notation of the secret to retrieve. + * + * @return + * A Future which completes with the value of the secret represented by + * the given HV notation, or null if there is no such secret. + * + * @throws GuacamoleException + * If the requested secret cannot be retrieved or the HV notation + * is invalid. + */ + public Future getSecret(String notation) throws GuacamoleException { + return getSecret(notation, null); + } + + /** + * Returns the value of the secret stored within Hashicorp Vault. + * + * @param notation + * The HV notation of the secret to retrieve. + * + * @param fallbackFunction + * A function to invoke in order to produce a Future for return, + * if the requested secret is not found. If the provided Function + * is null, it will not be run. + * + * @return + * A Future which completes with the value of the secret represented by + * the given HV notation, or empty string if there is no such secret to + * remove the token. + * + * @throws GuacamoleException + * If the requested secret cannot be retrieved or the HV notation + * is invalid. + */ + public Future getSecret(String notation, + @Nullable GuacamoleExceptionSupplier> fallbackFunction) + throws GuacamoleException { + + // If it's not an HV token, fail + if (!notation.startsWith(HASHICORP_VAULT_TOKEN_PREFIX)) + throw new GuacamoleException("Invalid token HV notation: " + notation); + + /* + * HASHIVAULT:path/to/secret <-- the Guacamole token name and its modifier + * ^^^^^^^ <-- this is the path + * ^^^^^^ <-- this is the secret (or key in HV terms) + */ + int lastSlashIndex = notation.lastIndexOf('/'); + if (lastSlashIndex == -1) + lastSlashIndex = HASHICORP_VAULT_TOKEN_PREFIX.length(); + + String path = notation.substring(HASHICORP_VAULT_TOKEN_PREFIX.length(), lastSlashIndex); + String secret = notation.substring(lastSlashIndex + 1); + + // Try to get the whole secret from cache and return key + HvTimedSecretData cachedSecret = cachedSecrets.get(path); + if (cachedSecret != null && System.currentTimeMillis() < cachedSecret.dateCreated + cacheLifetime) { + try { + JsonNode secretNode = cachedSecret.jsonNode.get("data").get("data").get(secret); + if (secretNode == null) { + logger.warn("Could not find {}/{}", path, secret); + return CompletableFuture.completedFuture(""); + } + return CompletableFuture.completedFuture(secretNode.asText()); + } + catch (Exception e) { + throw new GuacamoleException("Failed to extract secret from cached JSON for " + notation, e); + } + } + + // Cache miss, either get an existing in-flight request or create a new one + CompletableFuture futureResponse = inFlightRequests.computeIfAbsent(path, k -> { + return CompletableFuture.supplyAsync(() -> { + try { + // Perform the Vault query + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(hvConfig.get(HvConfigurationService.PARAM_NAME_VAULT_URL) + HASHICORP_VAULT_HTTP_VERSION + path)) + .header(HASHICORP_VAULT_HTTP_HEADER_TOKEN, hvConfig.get(HvConfigurationService.PARAM_NAME_VAULT_TOKEN)) + .GET() + .build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + JsonNode jsonNode = objectMapper.readTree(response.body()); + + return jsonNode; + + } + catch (Exception e) { + logger.warn("Vault query failed for {} with {}", path, e); + throw new CompletionException("Vault query failed for " + path, e); + } + }); + }); + + return futureResponse.whenComplete((jsonNode, ex) -> { + // Put newly received JSON data into the cache + if (ex == null && jsonNode != null) { + HvTimedSecretData secretToCache = new HvTimedSecretData(); + secretToCache.dateCreated = System.currentTimeMillis(); + secretToCache.jsonNode = jsonNode; + cachedSecrets.put(path, secretToCache); + } + + // Now that the cache is filled, this in-flight request is obsolete and must be removed + inFlightRequests.remove(path); + + }).thenApply(jsonNode -> { + /* + * Extract and return the secret + * If there's no data.data, it's weird so fail with an exception + */ + try { + JsonNode secretNode = jsonNode.get("data").get("data").get(secret); + if (secretNode == null) { + logger.warn("Could not find {}/{}", path, secret); + return ""; + } + return secretNode.asText(); + } + catch (Exception e) { + throw new CompletionException("Failed to parse JSON response for path " + path, e); + } + + }).exceptionally(e -> { + // Make sure that the exception is a GuacamoleException + Throwable cause = e.getCause(); + String errorMessage = (cause != null) ? cause.getMessage() : "Unknown error"; + + if (cause instanceof GuacamoleException) + throw new CompletionException(cause); + + throw new CompletionException( + new GuacamoleException("Vault query failed for " + path + ": " + errorMessage, cause)); + }); + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientFactory.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientFactory.java new file mode 100644 index 0000000000..4de0ae0d7d --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientFactory.java @@ -0,0 +1,44 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.guacamole.vault.hv.secret; + +import java.util.Map; +import javax.annotation.Nonnull; + +/** + * Factory for creating HvClient instances. + */ +public interface HvClientFactory { + + /** + * Returns a new instance of a HvClient instance associated with + * the provided HV configuration options and API interval. + * + * @param hvConfigOptions + * The HV config options to use when constructing the HvClient + * object. + * + * @return + * A new HvClient instance associated with the provided HV config + * options. + */ + HvClient create(@Nonnull Map hvConfigOptions); + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java new file mode 100644 index 0000000000..cfd91ea033 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java @@ -0,0 +1,335 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.guacamole.vault.hv.secret; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import com.google.common.base.Objects; +import com.google.inject.Inject; +import com.google.inject.Singleton; + +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.Future; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.annotation.Nonnull; + +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.net.auth.Attributes; +import org.apache.guacamole.net.auth.Connectable; +import org.apache.guacamole.net.auth.Connection; +import org.apache.guacamole.net.auth.ConnectionGroup; +import org.apache.guacamole.net.auth.Directory; +import org.apache.guacamole.net.auth.User; +import org.apache.guacamole.net.auth.UserContext; +import org.apache.guacamole.protocol.GuacamoleConfiguration; +import org.apache.guacamole.token.TokenFilter; +import org.apache.guacamole.vault.hv.GuacamoleExceptionSupplier; +import org.apache.guacamole.vault.hv.conf.HvAttributeService; +import org.apache.guacamole.vault.hv.conf.HvConfigurationService; +import org.apache.guacamole.vault.hv.user.HvDirectory; +import org.apache.guacamole.vault.secret.VaultSecretService; +import org.apache.guacamole.vault.secret.WindowsUsername; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Service which retrieves secrets from Hashicorp Vault. + * The configuration used to connect to HV can be set at a global + * level using guacamole.properties, or using a connection group + * attribute. + */ +@Singleton +public class HvSecretService implements VaultSecretService { + + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(VaultSecretService.class); + + /** + * Service for retrieving configuration information. + */ + @Inject + private HvConfigurationService confService; + + /** + * Factory for creating HV client instances. + */ + @Inject + private HvClientFactory hvClientFactory; + + /** + * A map of base-64 encoded JSON HV config blobs to associated HV client instances. + * A distinct HV client will exist for every HV config. + */ + private final ConcurrentMap hvClientMap = new ConcurrentHashMap<>(); + + /** + * Create and return a HV client for the provided HV config if not already + * present in the client map, otherwise return the existing client entry. + * + * @param hvConfig + * The base-64 encoded JSON HV config blob associated with the client entry. + * If an associated entry does not already exist, it will be created using + * this configuration. + * + * @return + * A HV client for the provided HV config if not already present in the + * client map, otherwise the existing client entry. + * + * @throws GuacamoleException + * If an error occurs while creating the HV client. + */ + private HvClient getClient(@Nonnull String hvConfigBase64) + throws GuacamoleException { + + // If a client already exists for the provided config, use it + HvClient hvClient = hvClientMap.get(hvConfigBase64); + if (hvClient != null) + return hvClient; + + // Create and store a new HV client instance for the provided HV config blob + Map hvConfig = confService.parseHvConfig(hvConfigBase64); + hvClient = hvClientFactory.create(hvConfig); + HvClient prevClient = hvClientMap.putIfAbsent(hvConfigBase64, hvClient); + + // If the client was already set before this thread got there, use the existing one + return prevClient != null ? prevClient : hvClient; + } + + @Override + public String canonicalize(String nameComponent) { + try { + + // As HV notation is essentially a URL, encode all components + // using standard URL escaping + return URLEncoder.encode(nameComponent, "UTF-8"); + + } + catch (UnsupportedEncodingException e) { + throw new UnsupportedOperationException("Unexpected lack of UTF-8 support.", e); + } + } + + @Override + public Future getValue( UserContext userContext, + Connectable connectable, String name) throws GuacamoleException { + + // Attempt to find a HV config for this connection or group + String hvConfig = getConnectionGroupHvConfig(userContext, connectable); + + return getClient(hvConfig).getSecret(name, new GuacamoleExceptionSupplier>() { + + @Override + public Future get() throws GuacamoleException { + + // Get the user-supplied HV config, if allowed by config and + // set by the user + String userHvConfig = getUserHVConfig(userContext, connectable); + + // If the user config happens to be the same as admin-defined one, + // don't bother trying again + if (userHvConfig != null && !Objects.equal(userHvConfig, hvConfig)) + return getClient(userHvConfig).getSecret(name); + + return CompletableFuture.completedFuture(null); + } + + }); + } + + @Override + public Future getValue(String name) throws GuacamoleException { + // Use the default HV configuration from guacamole.properties + return getClient(confService.getHvConfig()).getSecret(name); + } + + /** + * Search for a HV configuration attribute, recursing up the connection group tree + * until a connection group with the appropriate attribute is found. If the HV config + * is found, it will be returned. If not, the default value from the config file will + * be returned. + * + * @param userContext + * The userContext associated with the connection or connection group. + * + * @param connectable + * A connection or connection group for which the tokens are being replaced. + * + * @return + * The value of the HV configuration attribute if found in the tree, the default + * HV config blob defined in guacamole.properties otherwise. + * + * @throws GuacamoleException + * If an error occurs while attempting to retrieve the HV config attribute, or if + * no HV config is found in the connection group tree, and the value is also not + * defined in the config file. + */ + @Nonnull + private String getConnectionGroupHvConfig(UserContext userContext, + Connectable connectable) throws GuacamoleException { + + // Check to make sure it's a usable type before proceeding + if (!(connectable instanceof Connection) && !(connectable instanceof ConnectionGroup)) { + logger.warn( + "Unsupported Connectable type: {}; skipping HV config lookup.", + connectable.getClass() + ); + + // Use the default value if searching is impossible + return confService.getHvConfig(); + } + + // For connections, start searching the parent group for the HV config + // For connection groups, start searching the group directly + String parentIdentifier = (connectable instanceof Connection) + ? ((Connection) connectable).getParentIdentifier() + : ((ConnectionGroup) connectable).getIdentifier(); + + // Keep track of all group identifiers seen while recursing up the tree + // in case there's a cycle - if the same identifier is ever seen twice, + // the search is over. + Set observedIdentifiers = new HashSet<>(); + observedIdentifiers.add(parentIdentifier); + + // Use the unwrapped connection group directory to avoid HV config + // value sanitization + Directory connectionGroupDirectory = ( + (HvDirectory) userContext.getConnectionGroupDirectory() + ).getUnderlyingDirectory(); + + while (true) { + // Fetch the parent group, if one exists + ConnectionGroup group = connectionGroupDirectory.get(parentIdentifier); + if (group == null) + break; + + // If the current connection group has the HV configuration attribute + // set to a non-empty value, return immediately + String hvConfig = group.getAttributes().get(HvAttributeService.HV_CONFIGURATION_ATTRIBUTE); + if (hvConfig != null && !hvConfig.trim().isEmpty()) + return hvConfig; + + // Otherwise, keep searching up the tree until an appropriate configuration is found + parentIdentifier = group.getParentIdentifier(); + + // If the parent is a group that's already been seen, this is a cycle, so there's no + // need to search any further + if (!observedIdentifiers.add(parentIdentifier)) + break; + } + + + // If no HV configuration was ever found, use the default value + return confService.getHvConfig(); + } + + /** + * Returns true if user-level HV configuration is enabled for the given + * Connectable, false otherwise. + * + * @param connectable + * The connectable to check for whether user-level HV configs are + * enabled. + * + * @return + * True if user-level HV configuration is enabled for the given + * Connectable, false otherwise. + */ + private boolean isHvUserConfigEnabled(Connectable connectable) { + + // User-level config is enabled IFF the appropriate attribute is set to true + if (connectable instanceof Attributes) + return HvAttributeService.TRUTH_VALUE.equals(((Attributes) connectable).getAttributes().get( + HvAttributeService.HV_USER_CONFIG_ENABLED_ATTRIBUTE)); + + // If there's no attributes to check, the user config cannot be enabled + return false; + + } + + /** + * Return the HV config blob for the current user IFF user HV configs + * are enabled globally, and are enabled for the given connectable. If no + * HV config exists for the given user or HV configs are not enabled, + * null will be returned. + * + * @param userContext + * The user context from which the current user should be fetched. + * + * @param connectable + * The connectable to which the connection is being established. This + * is the connection which will be checked to see if user HV configs + * are enabled. + * + * @return + * The base64 encoded HV config blob for the current user if one + * exists, and if user HV configs are enabled globally and for the + * provided connectable. + * + * @throws GuacamoleException + * If an error occurs while attempting to fetch the HV config. + */ + private String getUserHVConfig(UserContext userContext, + Connectable connectable) throws GuacamoleException { + + return null; + } + + /* + * Scan the configuration parameters and fill the token map with all + * supported HV matches. + */ + @Override + public Map> getTokens(UserContext userContext, + Connectable connectable, GuacamoleConfiguration config, + TokenFilter filter) throws GuacamoleException { + + Map> tokens = new HashMap<>(); + Map parameters = config.getParameters(); + + String hvConfigBase64 = getConnectionGroupHvConfig(userContext, connectable); + HvClient client = getClient(hvConfigBase64); + Pattern tokenPattern = Pattern.compile("\\$\\{(" + client.HASHICORP_VAULT_TOKEN_PREFIX + "[^}]+)\\}"); + + for (Map.Entry entry : parameters.entrySet()) { + Matcher tokenMatcher = tokenPattern.matcher(entry.getValue()); + while (tokenMatcher.find()) { + String notation = tokenMatcher.group(1); + tokens.put(notation, client.getSecret(notation)); + } + } + + return tokens; + + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvTimedSecretData.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvTimedSecretData.java new file mode 100644 index 0000000000..b509b07837 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvTimedSecretData.java @@ -0,0 +1,33 @@ + +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.guacamole.vault.hv.secret; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * Record to link JSON data to the time it was obtained from server. + * Might be simplified in the future to: + * record HvTimedSecretData(long dateCreated, JsonNode jsonNode) {}; + */ +public final class HvTimedSecretData { + public long dateCreated; + public JsonNode jsonNode; +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnection.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnection.java new file mode 100644 index 0000000000..2a1eb1f52d --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnection.java @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.guacamole.vault.hv.user; + +import com.google.common.collect.Maps; +import java.util.Map; +import org.apache.guacamole.net.auth.Connection; +import org.apache.guacamole.net.auth.DelegatingConnection; +import org.apache.guacamole.vault.hv.conf.HvAttributeService; + +/** + * A Connection that explicitly adds a blank entry for any defined HV + * connection attributes. This ensures that any such field will always + * be displayed to the user when editing a connection through the UI. + */ +public class HvConnection extends DelegatingConnection { + + /** + * Create a new Vault connection wrapping the provided Connection record. Any + * attributes defined in the provided connection attribute forms will have empty + * values automatically populated when getAttributes() is called. + * + * @param connection + * The connection record to wrap. + */ + HvConnection(Connection connection) { + super(connection); + } + + /** + * Return the underlying wrapped connection record. + * + * @return + * The wrapped connection record. + */ + Connection getUnderlyingConnection() { + return getDelegateConnection(); + } + + @Override + public Map getAttributes() { + + // Make a copy of the existing map + Map attributes = Maps.newHashMap(super.getAttributes()); + + // Add the user-config-enabled configuration attribute + attributes.putIfAbsent(HvAttributeService.HV_USER_CONFIG_ENABLED_ATTRIBUTE, null); + return attributes; + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnectionGroup.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnectionGroup.java new file mode 100644 index 0000000000..f406677025 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnectionGroup.java @@ -0,0 +1,107 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.guacamole.vault.hv.user; + +import com.google.common.collect.Maps; +import java.util.HashMap; +import java.util.Map; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.net.auth.ConnectionGroup; +import org.apache.guacamole.net.auth.DelegatingConnectionGroup; +import org.apache.guacamole.vault.hv.conf.HvAttributeService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A HV-specific connection group implementation that always exposes + * the HV_CONFIGURATION_ATTRIBUTE attribute, even when no value is set. + * The value of the attribute will be sanitized if non-empty. This ensures + * that the attribute will always show up in the UI, even for connection + * groups that don't already have it set, and that any sensitive information + * in the attribute value will not be exposed. + */ +public class HvConnectionGroup extends DelegatingConnectionGroup { + + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(HvConnectionGroup.class); + + /** + * Create a new HvConnectionGroup wrapping the provided ConnectionGroup record. + * + * @param connectionGroup + * The ConnectionGroup record to wrap. + */ + HvConnectionGroup(ConnectionGroup connectionGroup) { + super(connectionGroup); + } + + /** + * Return the underlying wrapped connection group record. + * + * @return + * The wrapped connection group record. + */ + ConnectionGroup getUnderlyingConnectionGroup() { + return getDelegateConnectionGroup(); + } + + /** + * Return the underlying ConnectionGroup that's wrapped by this HvConnectionGroup. + * + * @return + * The underlying ConnectionGroup that's wrapped by this HvConnectionGroup. + */ + ConnectionGroup getUnderlyConnectionGroup() { + return getDelegateConnectionGroup(); + } + + @Override + public Map getAttributes() { + + // Make a copy of the existing map + Map attributes = Maps.newHashMap(super.getAttributes()); + + // Sanitize the HV configuration attribute, and ensure the attribute + // is always present + attributes.put( + HvAttributeService.HV_CONFIGURATION_ATTRIBUTE, + HvAttributeService.sanitizeHvAttributeValue( + attributes.get(HvAttributeService.HV_CONFIGURATION_ATTRIBUTE) + ) + ); + + return attributes; + } + + @Override + public void setAttributes(Map attributes) { + try { + super.setAttributes( + HvAttributeService.processAttributes(attributes) + ); + } + catch (GuacamoleException e) { + logger.warn("HvConnectionGroup setAttributes failed"); + } + } + +} \ No newline at end of file diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectory.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectory.java new file mode 100644 index 0000000000..eeb244eb4a --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectory.java @@ -0,0 +1,90 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.guacamole.vault.hv.user; + +import java.util.Collection; +import java.util.stream.Collectors; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.net.auth.DelegatingDirectory; +import org.apache.guacamole.net.auth.Directory; +import org.apache.guacamole.net.auth.Identifiable; + +/** + * A HV-specific version of DecoratingDirectory that exposes the underlying + * directory for when it's needed. + */ +public abstract class HvDirectory + extends DelegatingDirectory { + + /** + * Create a new HvDirectory, delegating to the provided directory. + * + * @param directory + * The directory to delegate to. + */ + public HvDirectory(Directory directory) { + super(directory); + } + + /** + * Returns the underlying directory that this DecoratingDirectory is + * delegating to. + * + * @return + * The underlying directory. + */ + public Directory getUnderlyingDirectory() { + return getDelegateDirectory(); + } + + /** + * Process and return a potentially-modified version of the object + * with the same identifier in the wrapped directory. + * + * @param object + * The object from the underlying directory. + * + * @return + * A potentially-modified version of the object with the same + * identifier in the wrapped directory. + */ + protected abstract ObjectType wrap(ObjectType object); + + @Override + public ObjectType get(String identifier) throws GuacamoleException { + + // Process and return the object from the wrapped directory + return wrap(super.get(identifier)); + + } + + @Override + public Collection getAll(Collection identifiers) + throws GuacamoleException { + + // Process and return each object from the wrapped directory + return super.getAll(identifiers).stream() + .map(superObject -> wrap(superObject)) + .filter(wrappedObject -> wrappedObject != null) + .collect(Collectors.toList()); + + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectoryService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectoryService.java new file mode 100644 index 0000000000..c84f43effd --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectoryService.java @@ -0,0 +1,172 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.guacamole.vault.hv.user; + +import com.google.inject.Inject; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.net.auth.Connection; +import org.apache.guacamole.net.auth.ConnectionGroup; +import org.apache.guacamole.net.auth.DecoratingDirectory; +import org.apache.guacamole.net.auth.Directory; +import org.apache.guacamole.net.auth.User; +import org.apache.guacamole.vault.hv.conf.HvAttributeService; +import org.apache.guacamole.vault.hv.user.HvConnectionGroup; +import org.apache.guacamole.vault.user.VaultDirectoryService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A HV-specific vault directory service that wraps the connection group directory + * to sanitize sensitive data from exposed settings. + */ +public class HvDirectoryService extends VaultDirectoryService { + + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(HvDirectoryService.class); + + /** + * A factory for constructing new HvUser instances. + */ + @Inject + private HvUserFactory hvUserFactory; + + /** + * Service for retrieving any custom attributes defined for the + * current vault implementation and processing of said attributes. + */ + @Inject + private HvAttributeService attributeService; + + @Override + public Directory getConnectionDirectory(Directory underlyingDirectory) throws GuacamoleException { + + return new DecoratingDirectory(underlyingDirectory) { + + @Override + protected Connection decorate(Connection connection) throws GuacamoleException { + return new HvConnection(connection); + } + + @Override + protected Connection undecorate(Connection connection) throws GuacamoleException { + return ((HvConnection) connection).getUnderlyingConnection(); + } + + }; + } + + @Override + public Directory getConnectionGroupDirectory(Directory underlyingDirectory) throws GuacamoleException { + // A ConnectionGroup directory that will intercept add and update calls to + // validate HV configurations, and translate one-time-tokens, if possible, + // as well as ensuring that all ConnectionGroups returned include the + // HV_CONFIGURATION_ATTRIBUTE attribute, so it will be available in the UI. + // The value of the HV_CONFIGURATION_ATTRIBUTE will be sanitized if set. + return new HvDirectory(underlyingDirectory) { + + @Override + public void add(ConnectionGroup connectionGroup) throws GuacamoleException { + + // Process attribute values before saving + connectionGroup.setAttributes(connectionGroup.getAttributes()); + + super.add(connectionGroup); + } + + @Override + public void update(ConnectionGroup connectionGroup) throws GuacamoleException { + + // Unwrap the existing ConnectionGroup + if (connectionGroup instanceof HvConnectionGroup) + connectionGroup = ((HvConnectionGroup) connectionGroup).getUnderlyingConnectionGroup(); + + // Process attribute values before saving + connectionGroup.setAttributes(connectionGroup.getAttributes()); + + super.update(connectionGroup); + + } + + @Override + protected ConnectionGroup wrap(ConnectionGroup object) { + + // Do not process the ConnectionGroup further if it does not exist + if (object == null) + return null; + + // Sanitize values when a ConnectionGroup is fetched from the directory + return new HvConnectionGroup(object); + + } + + }; + } + + @Override + public Directory getUserDirectory(Directory underlyingDirectory) throws GuacamoleException { + // A User directory that will intercept add and update calls to + // validate HV configurations, and translate one-time-tokens, if possible + // Additionally, this directory will will decorate all users with a + // HvUser wrapper to ensure that all defined HV fields will be exposed + // in the user attributes. The value of the HV_CONFIGURATION_ATTRIBUTE + // will be sanitized if set. + return new HvDirectory(underlyingDirectory) { + + @Override + public void add(User user) throws GuacamoleException { + + // Process attribute values before saving + user.setAttributes(user.getAttributes()); + + super.add(user); + } + + @Override + public void update(User user) throws GuacamoleException { + + // Unwrap the existing user + if (user instanceof HvUser) + user = ((HvUser) user).getUnderlyingUser(); + + // Process attribute values before saving + user.setAttributes(user.getAttributes()); + + super.update(user); + + } + + @Override + protected User wrap(User object) { + + // Do not process the user further if it does not exist + if (object == null) + return null; + + // Sanitize values when a user is fetched from the directory + return hvUserFactory.create(object); + + } + + }; + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUser.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUser.java new file mode 100644 index 0000000000..f5dc7f242d --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUser.java @@ -0,0 +1,126 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.guacamole.vault.hv.user; + +import com.google.common.collect.Maps; +import com.google.inject.Inject; +import com.google.inject.assistedinject.Assisted; +import com.google.inject.assistedinject.AssistedInject; +import java.util.Map; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.net.auth.DelegatingUser; +import org.apache.guacamole.net.auth.User; +import org.apache.guacamole.vault.hv.conf.HvAttributeService; +import org.apache.guacamole.vault.hv.conf.HvConfigurationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A HV-specific user implementation that exposes the + * HV_CONFIGURATION_ATTRIBUTE attribute even if no value is set. but only + * if user-specific HV configuration is enabled. The value of the attribute + * will be sanitized if non-empty. This ensures that the attribute will always + * show up in the UI when the feature is enabled, even for users that don't + * already have it set, and that any sensitive information in the attribute + * value will not be exposed. + */ +public class HvUser extends DelegatingUser { + + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(HvUser.class); + + /** + * Service for retrieving HV configuration details. + */ + @Inject + private HvConfigurationService configurationService; + + /** + * Create a new Hvuser wrapping the provided User record. + * + * @param user + * The User record to wrap. + */ + @AssistedInject + HvUser(@Assisted User user) { + super(user); + } + + /** + * Return the underlying wrapped user record. + * + * @return + * The wrapped user record. + */ + User getUnderlyingUser() { + return getDelegateUser(); + } + + @Override + public Map getAttributes() { + + // Make a copy of the existing map + Map attributes = Maps.newHashMap(super.getAttributes()); + + // Figure out if user-level HV config is enabled + boolean userHvConfigEnabled = false; + try { + userHvConfigEnabled = configurationService.getAllowUserConfig(); + } + catch (GuacamoleException e) { + + logger.warn( + "Disabling user HV config due to exception: {}" + , e.getMessage()); + logger.debug("Error looking up if user HV config is enabled.", e); + + } + + // If user-specific HV configuration is not enabled, do not expose the + // attribute at all + if (!userHvConfigEnabled) + attributes.remove(HvAttributeService.HV_CONFIGURATION_ATTRIBUTE); + + else + // Sanitize the HV configuration attribute, and ensure the attribute + // is always present + attributes.put( + HvAttributeService.HV_CONFIGURATION_ATTRIBUTE, + HvAttributeService.sanitizeHvAttributeValue( + attributes.get(HvAttributeService.HV_CONFIGURATION_ATTRIBUTE))); + + return attributes; + } + + @Override + public void setAttributes(Map attributes) { + try { + super.setAttributes( + HvAttributeService.processAttributes(attributes) + ); + } + catch (GuacamoleException e) { + logger.warn("HvUser setAttributes failed"); + } + } + +} \ No newline at end of file diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUserFactory.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUserFactory.java new file mode 100644 index 0000000000..e27c84ad19 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUserFactory.java @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.guacamole.vault.hv.user; + +import org.apache.guacamole.net.auth.User; + +/** + * Factory for creating HV-specific users, which wrap an underlying User. + */ +public interface HvUserFactory { + + /** + * Returns a new instance of a HvUser, wrapping the provided underlying User. + * + * @param user + * The underlying User that should be wrapped. + * + * @return + * A new instance of a HvUser, wrapping the provided underlying User. + */ + HvUser create(User user); + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/guac-manifest.json b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/guac-manifest.json new file mode 100644 index 0000000000..11a0e3dfda --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/guac-manifest.json @@ -0,0 +1,16 @@ +{ + + "guacamoleVersion" : "1.6.0", + + "name" : "Hashicorp Vault", + "namespace" : "hashicorp-vault", + + "authProviders" : [ + "org.apache.guacamole.vault.hv.HvAuthenticationProvider" + ], + + "translations" : [ + "translations/en.json" + ] + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/translations/en.json b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/translations/en.json new file mode 100644 index 0000000000..d66bd07faa --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/translations/en.json @@ -0,0 +1,23 @@ +{ + + "CONNECTION_ATTRIBUTES" : { + "FIELD_HEADER_HV_USER_CONFIG_ENABLED" : "Allow user-provided HV configuration", + "SECTION_HEADER_HV_CONFIG" : "Hashicorp Vault" + }, + + "CONNECTION_GROUP_ATTRIBUTES" : { + "ERROR_INVALID_HV_CONFIG_BLOB" : "The provided base64-encoded HV configuration blob is not valid.", + "FIELD_HEADER_HV_CONFIG" : "HV Service Configuration", + "SECTION_HEADER_HV_CONFIG" : "Hashicorp Vault" + }, + + "DATA_SOURCE_HASHICORP_VAULT" : { + "NAME" : "Hashicorp Vault" + }, + + "USER_ATTRIBUTES" : { + "FIELD_HEADER_HV_CONFIG" : "HV Service Configuration", + "SECTION_HEADER_HV_CONFIG" : "Hashicorp Vault" + } + +} diff --git a/extensions/guacamole-vault/pom.xml b/extensions/guacamole-vault/pom.xml index 14313ff7a4..dd1ac8da97 100644 --- a/extensions/guacamole-vault/pom.xml +++ b/extensions/guacamole-vault/pom.xml @@ -45,6 +45,7 @@ modules/guacamole-vault-ksm + modules/guacamole-vault-hv From aa5f8989f38f2a6d4d8b2cb8b940f2e7271f9925 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Thu, 28 May 2026 22:00:10 +0200 Subject: [PATCH 03/27] GUACAMOLE-2137: Combine the pull requests #1116, #1143 and #1214 - It supports both OpenBao and Hashicorp Vault - Uses url like tokens of the form "vault:////" - Uses the path-help function of the vault to determine the vault type - Uses spring-vault to communicate with the vault - Adds support for both KV_1 and KV_2 Key-Value secret engines - Adds support for LDAP secret engine and static, dynamic and service accounts - Adds support of the SSH secret engine including both SSH one-time passwords and signed user certificates - Adds supports for the database secret engine, allowing Guacamole itself to obtain its username, password and if the database is configured with additional static values, the URI of the database server and the database itself - Adds the possiblity of including sub-tokens with the Vault tokens (ex: vault://kv1/users{GUAC_USERNAME}/password) - Gets the connectionGroup and User fallback functions in #1116 to actually work - Doesn't use a Base64 configuration string, but real Guacamole configuration options - Doesn't use a sanitize function on TextFields of the Form, but rather PasswordField types --- doc/licenses/apache-sshd/NOTICE | 16 + doc/licenses/apache-sshd/dep-coordinates.txt | 3 + doc/licenses/net-i2p-crypto/LICENSE | 6 + .../net-i2p-crypto/dep-coordinates.txt | 1 + .../spring-framework/dep-coordinates.txt | 2 + doc/licenses/spring-vault/LICENSE | 4 + doc/licenses/spring-vault/dep-coordinates.txt | 1 + .../modules/guacamole-vault-dist/pom.xml | 2 +- .../modules/guacamole-vault-hv/pom.xml | 50 +- .../hv/HvAuthenticationProviderModule.java | 12 +- .../vault/hv/conf/HvAttributeService.java | 70 +-- .../vault/hv/conf/HvConfigurationService.java | 450 +++++++++++--- .../guacamole/vault/hv/secret/HvClient.java | 561 +++++++++++++++--- .../vault/hv/secret/HvClientFactory.java | 3 +- .../vault/hv/secret/HvSecretService.java | 501 ++++++++++++++-- .../guacamole/vault/hv/secret/HvSshKeys.java | 126 ++++ .../vault/hv/secret/HvTimedSecretData.java | 33 -- .../hv/secret/HvTunnelEventListener.java | 51 ++ .../vault/hv/user/HvConnectionGroup.java | 23 +- .../guacamole/vault/hv/user/HvUser.java | 34 +- .../hv/vault/FileTokenAuthentication.java | 68 +++ .../hv/vault/TtlAwareSessionManager.java | 495 ++++++++++++++++ .../vault/UsernamePasswordAuthentication.java | 130 ++++ ...UsernamePasswordAuthenticationOptions.java | 96 +++ .../resource-templates/guac-manifest.json | 23 + .../src/main/resources/guac-manifest.json | 16 - .../src/main/resources/translations/de.json | 28 + .../src/main/resources/translations/en.json | 29 +- .../src/main/resources/translations/es.json | 28 + .../src/main/resources/translations/fr.json | 28 + .../apache/guacamole/token/TokenFilter.java | 3 +- 31 files changed, 2534 insertions(+), 359 deletions(-) create mode 100644 doc/licenses/apache-sshd/NOTICE create mode 100644 doc/licenses/apache-sshd/dep-coordinates.txt create mode 100644 doc/licenses/net-i2p-crypto/LICENSE create mode 100644 doc/licenses/net-i2p-crypto/dep-coordinates.txt create mode 100644 doc/licenses/spring-vault/LICENSE create mode 100644 doc/licenses/spring-vault/dep-coordinates.txt create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSshKeys.java delete mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvTimedSecretData.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvTunnelEventListener.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/FileTokenAuthentication.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/TtlAwareSessionManager.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/UsernamePasswordAuthentication.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/UsernamePasswordAuthenticationOptions.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resource-templates/guac-manifest.json delete mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/guac-manifest.json create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/translations/de.json create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/translations/es.json create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/translations/fr.json diff --git a/doc/licenses/apache-sshd/NOTICE b/doc/licenses/apache-sshd/NOTICE new file mode 100644 index 0000000000..c308d97bbd --- /dev/null +++ b/doc/licenses/apache-sshd/NOTICE @@ -0,0 +1,16 @@ +# Name: Apache Mina SSHD +# URL: https://mina.apache.org/sshd-project +# From: 'Apache Software Foundation' (https://www.apache.org/) +# License: Apache v2.0 +# Source: https://raw.githubusercontent.com/apache/mina-sshd/refs/tags/sshd-${VERSION}/NOTICE.txt +# +# --- BEGIN LICENSE FILE [2.17.1] --- +Apache MINA SSHD +Copyright 2008-2021 The Apache Software Foundation + +This product includes software developed at +The Apache Software Foundation (http://www.apache.org/). + +Please refer to each LICENSE..txt file for the +license terms of the components that Apache MINA depends on. +# --- END LICENSE FILE [2.17.1] --- diff --git a/doc/licenses/apache-sshd/dep-coordinates.txt b/doc/licenses/apache-sshd/dep-coordinates.txt new file mode 100644 index 0000000000..7a550d0123 --- /dev/null +++ b/doc/licenses/apache-sshd/dep-coordinates.txt @@ -0,0 +1,3 @@ +org.apache.sshd:sshd-common:jar:* +org.apache.sshd:sshd-core:jar:* + diff --git a/doc/licenses/net-i2p-crypto/LICENSE b/doc/licenses/net-i2p-crypto/LICENSE new file mode 100644 index 0000000000..9bd647914f --- /dev/null +++ b/doc/licenses/net-i2p-crypto/LICENSE @@ -0,0 +1,6 @@ +# Name: EdDSA Java Implementation +# Version: 0.3.0 +# From: str4d (original author) +# Project: https://github.com/str4d/ed25519-java +# Maven Coordinates: net.i2p.crypto:eddsa +# License: CC0 1.0 Universal (Public Domain Dedication) diff --git a/doc/licenses/net-i2p-crypto/dep-coordinates.txt b/doc/licenses/net-i2p-crypto/dep-coordinates.txt new file mode 100644 index 0000000000..506e28e461 --- /dev/null +++ b/doc/licenses/net-i2p-crypto/dep-coordinates.txt @@ -0,0 +1 @@ +net.i2p.crypto:eddsa:jar:* diff --git a/doc/licenses/spring-framework/dep-coordinates.txt b/doc/licenses/spring-framework/dep-coordinates.txt index 2e107e9902..8cc4518918 100644 --- a/doc/licenses/spring-framework/dep-coordinates.txt +++ b/doc/licenses/spring-framework/dep-coordinates.txt @@ -4,3 +4,5 @@ org.springframework:spring-beans:jar:* org.springframework:spring-context:jar:* org.springframework:spring-core:jar:* org.springframework:spring-expression:jar:* +org.springframework:spring-jcl:jar:5.3.29 +org.springframework:spring-web:jar:5.3.29 diff --git a/doc/licenses/spring-vault/LICENSE b/doc/licenses/spring-vault/LICENSE new file mode 100644 index 0000000000..8aa697fd2c --- /dev/null +++ b/doc/licenses/spring-vault/LICENSE @@ -0,0 +1,4 @@ +# Name: Spring Vault +# URL: https://spring.io/projects/spring-vault +# From: 'Spring' (https://spring.io/) +# License: Apache v2.0 diff --git a/doc/licenses/spring-vault/dep-coordinates.txt b/doc/licenses/spring-vault/dep-coordinates.txt new file mode 100644 index 0000000000..5828edd06d --- /dev/null +++ b/doc/licenses/spring-vault/dep-coordinates.txt @@ -0,0 +1 @@ +org.springframework.vault:spring-vault-core:jar:* diff --git a/extensions/guacamole-vault/modules/guacamole-vault-dist/pom.xml b/extensions/guacamole-vault/modules/guacamole-vault-dist/pom.xml index bc9f1dd9b6..be8a5c0cec 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-dist/pom.xml +++ b/extensions/guacamole-vault/modules/guacamole-vault-dist/pom.xml @@ -53,7 +53,7 @@ org.apache.guacamole guacamole-vault-hv - 1.6.0 + ${revision} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/pom.xml b/extensions/guacamole-vault/modules/guacamole-vault-hv/pom.xml index c2905d025d..35410b742c 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/pom.xml +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/pom.xml @@ -26,14 +26,13 @@ org.apache.guacamole guacamole-vault-hv jar - 1.6.0 guacamole-vault-hv http://guacamole.apache.org/ org.apache.guacamole guacamole-vault - 1.6.0 + ${revision} ../../ @@ -47,14 +46,13 @@ org.apache.guacamole guacamole-ext - provided org.apache.guacamole guacamole-vault-base - 1.6.0 + ${revision} @@ -80,13 +78,49 @@ ${kotlin.version} - + - org.bouncycastle - bc-fips - 2.1.0 + org.springframework.vault + spring-vault-core + 2.3.4 + + + org.apache.sshd + sshd-common + 2.17.1 + + + org.slf4j + jcl-over-slf4j + + + + + + + net.i2p.crypto + eddsa + 0.3.0 + + + + + com.github.ben-manes.caffeine + caffeine + 2.9.3 + + + org.checkerframework + checker-qual + + + com.google.errorprone + error_prone_annotations + + + diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProviderModule.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProviderModule.java index 289fcad697..b539039408 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProviderModule.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProviderModule.java @@ -20,7 +20,6 @@ package org.apache.guacamole.vault.hv; import com.google.inject.assistedinject.FactoryModuleBuilder; -import java.security.Security; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.vault.VaultAuthenticationProviderModule; import org.apache.guacamole.vault.conf.VaultAttributeService; @@ -30,13 +29,13 @@ import org.apache.guacamole.vault.hv.secret.HvClient; import org.apache.guacamole.vault.hv.secret.HvClientFactory; import org.apache.guacamole.vault.hv.secret.HvSecretService; +import org.apache.guacamole.vault.hv.secret.HvTunnelEventListener; import org.apache.guacamole.vault.hv.user.HvConnectionGroup; import org.apache.guacamole.vault.hv.user.HvDirectoryService; import org.apache.guacamole.vault.hv.user.HvUser; import org.apache.guacamole.vault.hv.user.HvUserFactory; import org.apache.guacamole.vault.secret.VaultSecretService; import org.apache.guacamole.vault.user.VaultDirectoryService; -import org.bouncycastle.jcajce.provider.BouncyCastleFipsProvider; /** * Guice module which configures injections specific to Hashicorp Vault @@ -53,9 +52,7 @@ public class HvAuthenticationProviderModule * @throws GuacamoleException * If configuration details in guacamole.properties cannot be parsed. */ - public HvAuthenticationProviderModule() throws GuacamoleException { - Security.addProvider(new BouncyCastleFipsProvider()); - } + public HvAuthenticationProviderModule() throws GuacamoleException { } @Override protected void configureVault() { @@ -64,7 +61,7 @@ protected void configureVault() { bind(HvAttributeService.class); bind(VaultAttributeService.class).to(HvAttributeService.class); bind(VaultConfigurationService.class).to(HvConfigurationService.class); - bind(VaultSecretService.class).to(HvSecretService.class); + bind(VaultSecretService.class).to(HvSecretService.class).asEagerSingleton(); bind(VaultDirectoryService.class).to(HvDirectoryService.class); // Bind factory for creating HV Clients @@ -76,6 +73,9 @@ protected void configureVault() { install(new FactoryModuleBuilder() .implement(HvUser.class, HvUser.class) .build(HvUserFactory.class)); + + // Static Injection of the Listener to get TunnelConnectEvent and TunnelCloseEvent + requestStaticInjection(HvTunnelEventListener.class); } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvAttributeService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvAttributeService.java index 671653e3ec..70cbd3ffd6 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvAttributeService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvAttributeService.java @@ -31,6 +31,7 @@ import org.apache.guacamole.form.BooleanField; import org.apache.guacamole.form.Form; import org.apache.guacamole.form.TextField; +import org.apache.guacamole.form.PasswordField; import org.apache.guacamole.language.TranslatableGuacamoleClientException; import org.apache.guacamole.vault.conf.VaultAttributeService; @@ -38,10 +39,28 @@ public class HvAttributeService implements VaultAttributeService { /** - * The name of the attribute which can contain a HV configuration blob + * The name of the attribute which can contain a HV URI * associated with either a connection group or user. */ - public static final String HV_CONFIGURATION_ATTRIBUTE = "hv-config"; + public static final String HV_URI_ATTRIBUTE = "hv-uri"; + + /** + * The name of the attribute which can contain a HV token + * associated with either a connection group or user. + */ + public static final String HV_TOKEN_ATTRIBUTE = "hv-token"; + + /** + * The name of the attribute which can contain a HV username + * associated with either a connection group or user. + */ + public static final String HV_USERNAME_ATTRIBUTE = "hv-username"; + + /** + * The name of the attribute which can contain a HV password + * associated with either a connection group or user. + */ + public static final String HV_PASSWORD_ATTRIBUTE = "hv-password"; /** * The HV configuration attribute contains sensitive information, so it @@ -58,7 +77,11 @@ public class HvAttributeService implements VaultAttributeService { */ public static final Form HV_CONFIGURATION_FORM = new Form( "hv-config", - Arrays.asList(new TextField(HV_CONFIGURATION_ATTRIBUTE)) + Arrays.asList(new TextField(HV_URI_ATTRIBUTE), + new PasswordField(HV_TOKEN_ATTRIBUTE), + new TextField(HV_USERNAME_ATTRIBUTE), + new PasswordField(HV_PASSWORD_ATTRIBUTE) + ) ); /** @@ -111,43 +134,22 @@ public Collection getConnectionGroupAttributes() { return HV_ATTRIBUTES; } - /** - * Sanitize the value of the provided HV config attribute. If the provided - * config value is non-empty, it will be replaced with the placeholder - * value to avoid leaking sensitive information. If the value is empty, it - * will be replaced by `null`. - * - * @param hvAttributeValue - * The HV configuration attribute value to sanitize. - * - * @return - * The sanitized HV configuration attribute value, stripped of any - * sensitive information. - */ - public static String sanitizeHvAttributeValue(String hvAttributeValue) { - - // Any non-empty values may contain sensitive information, and should - // be replaced by the safe placeholder value - if (hvAttributeValue != null && !hvAttributeValue.trim().isEmpty()) - return HV_ATTRIBUTE_PLACEHOLDER_VALUE; - - // If the configuration value is empty, expose a null value - else - return null; - - } - public static Map processAttributes( Map attributes) throws GuacamoleException { + attributes = new HashMap<>(attributes); - // Get the value of the HV config attribute in the provided map - String hvConfigValue = attributes.get(HvAttributeService.HV_CONFIGURATION_ATTRIBUTE); + // If the placeholder value was provided, do not update the attribute + String hvTokenValue = attributes.get(HvAttributeService.HV_TOKEN_ATTRIBUTE); + if (HvAttributeService.HV_ATTRIBUTE_PLACEHOLDER_VALUE.equals(hvTokenValue)) { + // Remove the attribute from the map so it won't be updated + attributes.remove(HvAttributeService.HV_TOKEN_ATTRIBUTE); + } // If the placeholder value was provided, do not update the attribute - if (HvAttributeService.HV_ATTRIBUTE_PLACEHOLDER_VALUE.equals(hvConfigValue)) { + String hvPasswordValue = attributes.get(HvAttributeService.HV_PASSWORD_ATTRIBUTE); + if (HvAttributeService.HV_ATTRIBUTE_PLACEHOLDER_VALUE.equals(hvPasswordValue)) { // Remove the attribute from the map so it won't be updated - attributes = new HashMap<>(attributes); - attributes.remove(HvAttributeService.HV_CONFIGURATION_ATTRIBUTE); + attributes.remove(HvAttributeService.HV_PASSWORD_ATTRIBUTE); } return attributes; diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java index 718323e19c..1f212f454a 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java @@ -25,85 +25,195 @@ import com.google.inject.Singleton; import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.util.Base64; +import java.net.URI; import java.util.HashMap; import java.util.Map; +import java.util.Objects; import javax.annotation.Nonnull; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.GuacamoleServerException; import org.apache.guacamole.environment.Environment; import org.apache.guacamole.properties.BooleanGuacamoleProperty; +import org.apache.guacamole.properties.IntegerGuacamoleProperty; import org.apache.guacamole.properties.StringGuacamoleProperty; +import org.apache.guacamole.properties.URIGuacamoleProperty; import org.apache.guacamole.vault.conf.VaultConfigurationService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; @Singleton public class HvConfigurationService extends VaultConfigurationService { /** - * Property name of the URL of the vault specified in the base64 configuration blob. + * Logger for this class. */ - public static final String PARAM_NAME_VAULT_URL = "vault_url"; + private static final Logger logger = LoggerFactory.getLogger(VaultConfigurationService.class); /** - * Property name of the authentication token for the vault specified in the - * base64 configuration blob. + * The default cache lifetime in milliseconds. */ - public static final String PARAM_NAME_VAULT_TOKEN = "vault_token"; + public static final int DEFAULT_CACHE_LIFETIME = 5000; /** - * Property name of the maximum time that cached data is considered valid. + * The default request timeout in milliseconds. */ - public static final String PARAM_NAME_CACHE_LIFETIME = "cache_lifetime"; + public static final int DEFAULT_REQUEST_TIMEOUT = 5000; /** - * The Guacamole server environment. + * The default connection timeout in milliseconds. */ - @Inject - private Environment environment; + public static final int DEFAULT_CONNECTION_TIMEOUT = 10000; + + /** + * The default ssh connection tiemout in seconds. + */ + public static final int DEFAULT_SSH_CONNECTION_TIMEOUT = 1800; + + /** + * The default vault token renewal delay in milliseconds. Expiring + * tokens will be renewed at least this delay before expiration + */ + public static final int DEFAULT_TOKEN_RENEWAL_DELAY = 10000; + + /** + * The default ssh certificate type. + */ + public static final String DEFAULT_SSH_TYPE = "ed25519"; + + /** + * The default of whether to accept user configurations or not. + */ + public static final Boolean DEFAULT_ALLOW_USER_CONFIG = false; /** * The name of the file which contains the YAML mapping of connection * parameter token to secrets within Hashicorp Vault. */ - private static final String TOKEN_MAPPING_FILENAME = "hv-token-mapping.yml"; + private static final String TOKEN_MAPPING_FILENAME = "vault-token-mapping.yml"; /** * The name of the properties file containing Guacamole configuration * properties whose values are the names of corresponding secrets within * Hashicorp Vault. */ - private static final String PROPERTIES_FILENAME = "guacamole.properties.hv"; + private static final String PROPERTIES_FILENAME = "guacamole.properties.vlt"; /** - * The base64-encoded configuration information. + * The URI of the hashicorp or OpenBao vault to use. */ - private static final StringGuacamoleProperty HV_CONFIG = new StringGuacamoleProperty() { + private static final URIGuacamoleProperty VAULT_URI = + new URIGuacamoleProperty() { + @Override - public String getName() { - return "hv-config"; - } + public String getName() { return "vault-uri"; } }; /** - * Whether unverified server certificates should be accepted. + * The authentication token to use to access the vault. */ - private static final BooleanGuacamoleProperty ALLOW_UNVERIFIED_CERT = new BooleanGuacamoleProperty() { + private static final StringGuacamoleProperty VAULT_TOKEN = + new StringGuacamoleProperty() { + @Override - public String getName() { - return "hv-allow-unverified-cert"; - } + public String getName() { return "vault-token"; } + }; + + /** + * The authentication username to use to access the vault in place of the token + */ + private static final StringGuacamoleProperty VAULT_USERNAME = + new StringGuacamoleProperty() { + + @Override + public String getName() { return "vault-username"; } + }; + + /** + * The authentication password to use to access the vault in place of the token + */ + private static final StringGuacamoleProperty VAULT_PASSWORD = + new StringGuacamoleProperty() { + + @Override + public String getName() { return "vault-password"; } + }; + /** + * The maximum time that the cached data is considered valid in ms. + */ + private static final IntegerGuacamoleProperty VAULT_CACHE_LIFETIME = + new IntegerGuacamoleProperty() { + + @Override + public String getName() { return "vault-cache-lifetime"; } + }; + + /** + * The maximum time that a request to the vault server can take in ms. + */ + private static final IntegerGuacamoleProperty VAULT_REQUEST_TIMEOUT = + new IntegerGuacamoleProperty() { + + @Override + public String getName() { return "vault-request-timeout"; } + }; + + /** + * The maximum time that a connection to the vault server can take in ms. + */ + private static final IntegerGuacamoleProperty VAULT_CONNECTION_TIMEOUT = + new IntegerGuacamoleProperty() { + + @Override + public String getName() { return "vault-connection-timeout"; } + }; + + /** + * The maximum time that an ssh signed certificate is considered to be valid. + */ + private static final IntegerGuacamoleProperty VAULT_SSH_CONNECTION_TIMEOUT = + new IntegerGuacamoleProperty() { + + @Override + public String getName() { return "vault-ssh-connection-timeout"; } + }; + + /** + * The renewal delay for expiring Vault tokens in ms. Tokens will be renewed + * prior to expiration by this delay + */ + private static final IntegerGuacamoleProperty VAULT_TOKEN_RENEWAL_DELAY = + new IntegerGuacamoleProperty() { + + @Override + public String getName() { return "vault-token-renewal-delay"; } + }; + + /** + * The type of ssh certificates that will be generated + */ + private static final StringGuacamoleProperty VAULT_SSH_TYPE = + new StringGuacamoleProperty() { + + @Override + public String getName() { return "vault-ssh-type"; } }; /** * Whether users should be able to supply their own HV configurations. */ - private static final BooleanGuacamoleProperty ALLOW_USER_CONFIG = new BooleanGuacamoleProperty() { + private static final BooleanGuacamoleProperty VAULT_ALLOW_USER_CONFIG = new BooleanGuacamoleProperty() { @Override public String getName() { - return "hv-allow-user-config"; + return "vault-allow-user-config"; } }; + /** + * The Guacamole server environment. + */ + @Inject + private Environment environment; + /** * Creates a new HvConfigurationService which reads the configuration * from "hv-token-mapping.yml" and properties from @@ -118,87 +228,277 @@ public HvConfigurationService() { } /** - * Return whether user-level HV configs should be enabled. If this - * flag is set to true, users can edit their own HV configs, as can - * admins. If set to false, no existing user-specific HV configuration - * will be exposed through the UI or used when looking up secrets. + * The URI of the hashicorp or OpenBao vault to use. * * @return - * true if user-specific HV configuration is enabled, false otherwise. + * The Hashicorp or OpenBao server URI (e.g., "http://localhost:8200"). * * @throws GuacamoleException - * If the value specified within guacamole.properties cannot be - * parsed. + * If the property is not defined in guacamole.properties or + * guacamole.properties can not be parsed. */ - public boolean getAllowUserConfig() throws GuacamoleException { - return environment.getProperty(ALLOW_USER_CONFIG, false); + public URI getVaultUri() throws GuacamoleException { + return environment.getRequiredProperty(VAULT_URI); } - // Not used - @Override - public boolean getSplitWindowsUsernames() throws GuacamoleException { - return false; + /** + * The authentication token to use to access the vault. + * + * @return + * The Hashicorp or OpenBao authentication token. + * + * @throws GuacamoleException + * If the property is not defined in guacamole.properties or + * guacamole.properties can not be parsed. + */ + public String getVaultToken() throws GuacamoleException { + return environment.getProperty(VAULT_TOKEN); } - // Not used - @Override - public boolean getMatchUserRecordsByDomain() throws GuacamoleException { - return false; + /** + * The authentication Username to use to access the vault. + * + * @return + * The Hashicorp or OpenBao authentication Username + * + * @throws GuacamoleException + * If the property is not defined in guacamole.properties or + * guacamole.properties can not be parsed. + */ + public String getVaultUsername() throws GuacamoleException { + return environment.getProperty(VAULT_USERNAME); } /** - * Return the globally-defined base-64-encoded JSON HV configuration blob - * as a string. + * The authentication Password to use to access the vault. + * + * @return String + * The Hashicorp or OpenBao authentication Password + * + * @throws GuacamoleException + * If the property is not defined in guacamole.properties or + * guacamole.properties can not be parsed. + */ + public String getVaultPassword() throws GuacamoleException { + return environment.getProperty(VAULT_PASSWORD); + } + /** + * The maximum time that the cached data is considered valid in + * milliseconds. * * @return - * The globally-defined base-64-encoded JSON HV configuration blob - * as a string. + * The cache lifetime in milliseconds. * * @throws GuacamoleException - * If the value specified within guacamole.properties cannot be - * parsed or does not exist. + * If guacamole.properties can not be parsed. */ - @Nonnull - @SuppressWarnings("null") - public String getHvConfig() throws GuacamoleException { + public int getVaultCacheLifetime() throws GuacamoleException { + return environment.getProperty(VAULT_CACHE_LIFETIME, DEFAULT_CACHE_LIFETIME); + } - // This will always return a non-null value; an exception would be - // thrown if the required value is not set - return environment.getRequiredProperty(HV_CONFIG); + /** + * The maximum time that a request to the vault server can take in + * milliseconds. + * + * @return + * The request timeout in milliseconds. + * + * @throws GuacamoleException + * If guacamole.properties can not be parsed. + */ + public int getRequestTimeout() throws GuacamoleException { + return environment.getProperty(VAULT_REQUEST_TIMEOUT, DEFAULT_REQUEST_TIMEOUT); } /** - * Given a base64-encoded JSON HV configuration, parse and return a - * KeyValueStorage object. + * The maximum time that a connection to the vault server can take in + * milliseconds. * - * @param value - * The base64-encoded JSON HV configuration to parse. + * @return + * The connection timeout in milliseconds. + * + * @throws GuacamoleException + * If guacamole.properties can not be parsed. + */ + public int getConnectionTimeout() throws GuacamoleException { + return environment.getProperty(VAULT_CONNECTION_TIMEOUT, DEFAULT_CONNECTION_TIMEOUT); + } + + /** + * The renewal delay, in milliseconds of expiring token. A token will be renewed + * prior to it expiration by this delay * * @return - * The KeyValueStorage that is a result of the parsing operation + * The renewal delay in milliseconds. * * @throws GuacamoleException - * If the provided value is not valid base-64 encoded JSON HV configuration. + * If guacamole.properties can not be parsed. */ - public static Map parseHvConfig(String value) throws GuacamoleException { - try { - Map config = new HashMap<>(); - String valueDecoded = new String(Base64.getDecoder().decode(value), StandardCharsets.UTF_8); + public int getTokenRenewalDelay() throws GuacamoleException { + return environment.getProperty(VAULT_TOKEN_RENEWAL_DELAY, DEFAULT_TOKEN_RENEWAL_DELAY); + } - ObjectMapper mapper = new ObjectMapper(); - JsonNode jsonNode = mapper.readTree(valueDecoded); - jsonNode.properties().forEach(entry -> { - config.put(entry.getKey(), entry.getValue().asText()); - }); + /** + * The type of SSH certificates are will be generated. Must be either + * 'rsa' for 4096-bit RSA keys or 'ed25519'. + * + * @return + * The ssh type to use. + * + * @throws GuacamoleException + * If guacamole.properties can not be parsed. + */ + public String getSshType() throws GuacamoleException { + String type = environment.getProperty(VAULT_SSH_TYPE, DEFAULT_SSH_TYPE); + if (! type.equals("rsa") & ! type.equals("ed25519")) { + throw new GuacamoleException("Only ssh certificate types 'rsa' (4096-bit) and 'ed25519' are supported"); + } + return type; + } - return config; + /** + * The maximum time that a signed SSH certificate is considered valid in + * milliseconds. + * + * @return + * The ssh connection timeout in milliseconds. + * + * @throws GuacamoleException + * If guacamole.properties can not be parsed. + */ + public int getSshConnectionTimeout() throws GuacamoleException { + return environment.getProperty(VAULT_SSH_CONNECTION_TIMEOUT, DEFAULT_SSH_CONNECTION_TIMEOUT); + } + + /** + * Return whether user-level HV configs should be enabled. If this + * flag is set to true, users vaults can be used to supply secrets. + * If set to false, no existing user-specific HV configuration + * will be exposed through the UI or used when looking up secrets. + * + * @return + * true if user-specific HV configuration is enabled, false otherwise. + * + * @throws GuacamoleException + * If the value specified within guacamole.properties cannot be + * parsed. + */ + public boolean getAllowUserConfig() throws GuacamoleException { + return environment.getProperty(VAULT_ALLOW_USER_CONFIG, DEFAULT_ALLOW_USER_CONFIG); + } + + @Override + public boolean getSplitWindowsUsernames() throws GuacamoleException { + // Not needed for Hashicorp/OpenBao - return false + return false; + } + + @Override + public boolean getMatchUserRecordsByDomain() throws GuacamoleException { + // Not needed for Hashicorp/OpenBao - return false + return false; + } + + /** + * Class containing the configuration associated with a client instance + */ + public class VaultInfo { + + public final URI Uri; + public final String Token; + public final String Username; + public final String Password; + public final int ConnectionTimeout; + public final int RequestTimeout; + public final int TokenRenewalDelay; + public final int CacheLifetime; + public final String SshType; + public final int SshConnectionTimeout; + + public VaultInfo(URI Uri, + String Token, + String Username, + String Password) { + this.Uri = Uri; + this.Token = Token; + this.Username = Username; + this.Password = Password; + + int ConnectionTimeout; + try { + ConnectionTimeout = getConnectionTimeout(); + } + catch (GuacamoleException e) { + logger.warn("Error parsing ConnectTimeout, using default: {}", e.getMessage()); + ConnectionTimeout = DEFAULT_CONNECTION_TIMEOUT; + } + this.ConnectionTimeout = ConnectionTimeout; + int RequestTimeout; + try { + RequestTimeout = getRequestTimeout(); + } + catch (GuacamoleException e) { + logger.warn("Error parsing RequestTimeout, using default: {}", e.getMessage()); + RequestTimeout = DEFAULT_REQUEST_TIMEOUT; + } + this.RequestTimeout = RequestTimeout; + int TokenRenewalDelay; + try { + TokenRenewalDelay = getTokenRenewalDelay(); + } + catch (GuacamoleException e) { + logger.warn("Error parsing TokenRenewalDelay, using default: {}", e.getMessage()); + TokenRenewalDelay = DEFAULT_TOKEN_RENEWAL_DELAY; + } + this.TokenRenewalDelay = TokenRenewalDelay; + int CacheLifetime; + try { + CacheLifetime = getVaultCacheLifetime(); + } + catch (GuacamoleException e) { + logger.warn("Error parsing CacheLifetime, using default: {}", e.getMessage()); + CacheLifetime = DEFAULT_CACHE_LIFETIME; + } + this.CacheLifetime = CacheLifetime; + String SshType; + try { + SshType = getSshType(); + } + catch (GuacamoleException e) { + logger.warn("Error parsing SshType, using default: {}", e.getMessage()); + SshType = DEFAULT_SSH_TYPE; + } + this.SshType = SshType; + int SshConnectionTimeout; + try { + SshConnectionTimeout = getSshConnectionTimeout(); + } + catch (GuacamoleException e) { + logger.warn("Error parsing SshType, using default: {}", e.getMessage()); + SshConnectionTimeout = DEFAULT_SSH_CONNECTION_TIMEOUT; + } + this.SshConnectionTimeout = SshConnectionTimeout; } - catch (IOException e) { - throw new GuacamoleServerException("Invalid JSON configuration for Hashicorp Vault.", e); + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + VaultInfo that = (VaultInfo) o; + // If Token non null it is used for the connection + if (Token == null || Token.trim().isEmpty()) + return Objects.equals(Uri, that.Uri) && + Objects.equals(Token, that.Token); + else + return Objects.equals(Uri, that.Uri) && + Objects.equals(Username, that.Username) && + Objects.equals(Password, that.Password); } - catch (IllegalArgumentException e) { - throw new GuacamoleServerException("Invalid base64 configuration for Hashicorp Vault.", e); + + @Override + public int hashCode() { + // Only hash the values that can be different + return Objects.hash(Uri, Token, Username, Password); } } - } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java index b887e1b4e5..0ec6307706 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java @@ -21,15 +21,23 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.RemovalCause; import com.google.inject.Inject; import com.google.inject.assistedinject.Assisted; import com.google.inject.assistedinject.AssistedInject; import java.io.IOException; import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Duration; +import java.time.Instant; +import java.util.HashMap; import java.util.Map; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; @@ -38,8 +46,22 @@ import javax.annotation.Nullable; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.vault.hv.GuacamoleExceptionSupplier; -import org.apache.guacamole.vault.hv.conf.HvConfigurationService; -import org.apache.guacamole.vault.hv.secret.HvTimedSecretData; +import org.apache.guacamole.vault.hv.conf.HvConfigurationService.VaultInfo; +import org.apache.guacamole.vault.hv.vault.FileTokenAuthentication; +import org.apache.guacamole.vault.hv.vault.UsernamePasswordAuthentication; +import org.apache.guacamole.vault.hv.vault.UsernamePasswordAuthenticationOptions; +import org.apache.guacamole.vault.hv.vault.TtlAwareSessionManager; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.vault.authentication.ClientAuthentication; +import org.springframework.vault.authentication.TokenAuthentication; +import org.springframework.vault.client.VaultEndpoint; +import org.springframework.vault.core.VaultKeyValueOperations; +import org.springframework.vault.core.VaultTemplate; +import org.springframework.vault.VaultException; +import org.springframework.vault.support.VaultResponse; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.DefaultUriBuilderFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -49,21 +71,20 @@ public class HvClient { /** - * Name of the HTTP header for the token, as specified in documentation: - * https://developer.hashicorp.com/vault/docs/auth/token + * Name of the Guacamole token to resolve on a Hashicorp Vault (secret path + * is set in the token modifier). */ - static final String HASHICORP_VAULT_HTTP_HEADER_TOKEN = "X-Vault-Token"; + static final String VAULT_TOKEN_PREFIX = "vault://"; /** - * API version. + * The path prefix for the path-help REST API in the Vault */ - static final String HASHICORP_VAULT_HTTP_VERSION = "/v1/"; + static final String VAULT_PATH_HELP = "/sys/internal/ui/mounts/"; /** - * Name of the Guacamole token to resolve on a Hashicorp Vault (secret path - * is set in the token modifier). + * Name temporary entry in the ldap sessions for session being cosntructed */ - static final String HASHICORP_VAULT_TOKEN_PREFIX = "HASHIVAULT:"; + static final String VAULT_LDAP_SESSION = "checkin"; /** * Logger for this class. @@ -71,80 +92,248 @@ public class HvClient { private static final Logger logger = LoggerFactory.getLogger(HvClient.class); /** - * HttpClient for this class. + * ObjectMapper for this class. */ - private final HttpClient httpClient; + private final ObjectMapper objectMapper; /** - * ObjectMapper for this class. + * All in-flight HTTP requests to Vault, it prevents multiple queries + * on the same path. */ - private final ObjectMapper objectMapper; + private final ConcurrentMap> inFlightRequests = new ConcurrentHashMap<>(); /** - * The HV configuration associated with this client instance. + * Cache of secrets recently fetched */ - private final Map hvConfig; + private final Cache cache; /** - * The maximum amount of time that an entry will be stored in the cache - * before being refreshed, in milliseconds. + * Vault template that will be used with all of the mount paths */ - private long cacheLifetime; + private final VaultTemplate vaultTemplate; /** - * All records retrieved from Hashicorp Vault, where each key is the - * UID of the corresponding record. The contents of this Map are - * automatically updated. + * A HashMap of the checked out LDAP Sessions */ - private final ConcurrentMap cachedSecrets = new ConcurrentHashMap<>(); + private final Map ldapSessions = new HashMap<>(); /** - * All in-flight HTTP requests to Vault, it prevents multiple queries - * on the same path. + * The HV configuration associated with this client instance. */ - private final ConcurrentMap> inFlightRequests = new ConcurrentHashMap<>(); + private final VaultInfo vaultInfo; /** * Create a new HV client based around the provided HV configuration and * API timeout setting. * - * @param hvConfig + * @param vaultInfo * The HV configuration to use when retrieving properties from HV. */ @AssistedInject - public HvClient(@Assisted Map hvConfig) { - this.hvConfig = hvConfig; - this.httpClient = HttpClient.newHttpClient(); + public HvClient(@Assisted VaultInfo vaultInfo) { + this.vaultInfo = vaultInfo; this.objectMapper = new ObjectMapper(); - this.cacheLifetime = 60000; - if (hvConfig.containsKey(HvConfigurationService.PARAM_NAME_CACHE_LIFETIME)) { - String strCacheLifetime = hvConfig.get(HvConfigurationService.PARAM_NAME_CACHE_LIFETIME); - try { - this.cacheLifetime = Long.parseLong(strCacheLifetime); + VaultEndpoint endpoint = VaultEndpoint.from(vaultInfo.Uri.resolve("v1")); + + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(vaultInfo.ConnectionTimeout); + requestFactory.setReadTimeout(vaultInfo.RequestTimeout); + + RestTemplate restTemplate = new RestTemplate(requestFactory); + restTemplate.setUriTemplateHandler(new DefaultUriBuilderFactory( + vaultInfo.Uri.resolve("v1").toString())); + ClientAuthentication authentication; + + if (vaultInfo.Token != null) { + if (isTokenReadableFile(vaultInfo.Token)) { + authentication = + new FileTokenAuthentication(vaultInfo.Token); } - catch (NumberFormatException e) { - logger.warn("Invalid {} in HV config: {}", HvConfigurationService.PARAM_NAME_CACHE_LIFETIME, strCacheLifetime); + else { + authentication = new TokenAuthentication(vaultInfo.Token); } } + else if (vaultInfo.Username != null && vaultInfo.Password != null) { + UsernamePasswordAuthenticationOptions options = + UsernamePasswordAuthenticationOptions.builder() + .username(vaultInfo.Username) + .password(vaultInfo.Password) + .build(); + + authentication = + new UsernamePasswordAuthentication(options, endpoint, restTemplate); + } + else { + logger.error("Either a vault token or Username/Password must be supplied"); + authentication = new TokenAuthentication("s.ThisTokenWontWork"); + } + + // Create a task scheduler for our token renewal + ThreadPoolTaskScheduler taskscheduler = new ThreadPoolTaskScheduler(); + taskscheduler.setPoolSize(1); + taskscheduler.setThreadNamePrefix("vault-renewal-"); + taskscheduler.initialize(); + + // Session manager to automatically renew tokens before expiration + TtlAwareSessionManager sessionManager = new TtlAwareSessionManager(authentication, + restTemplate, taskscheduler, vaultInfo.TokenRenewalDelay); + + this.vaultTemplate = new VaultTemplate(endpoint, requestFactory, sessionManager); + + + // Initialize the cache with maximum size of 1MB, and cache expiry with + // forced cleanup + // FIXME I'd really like to do something like "Array.fill(v, '\0');" in + // the removal listener to ensure that passwords are no longer in memory. + // However, both spring-core-vault and Guacamole store these values + // elsewhere as immutable String values, So even if I stored them in the + // cache as char[] copies of the password would be elsewhere as String + // values in memory.. A VaultConverter function could deal with the + // spring-vault-core part of the problem, but not Gaucamole. + logger.debug("Initialize Cache with expiry of {}, {} seconds", + vaultInfo.CacheLifetime, Duration.ofMillis(vaultInfo.CacheLifetime).toSeconds()); + this.cache = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofMillis(vaultInfo.CacheLifetime)) + .maximumSize(1_000_000) + .build(); } /** - * Returns the value of the secret stored within Hashicorp Vault. + * Function to detect if the the token is in fact a readable file + * rather than a token string * - * @param notation - * The HV notation of the secret to retrieve. + * @param token + * The string with the token returned from configService * * @return - * A Future which completes with the value of the secret represented by - * the given HV notation, or null if there is no such secret. + * True is the token is a readable file + */ + private static boolean isTokenReadableFile(String token) { + try { + Path path = Paths.get(token); + return Files.isRegularFile(path) && Files.isReadable(path); + } + catch (Exception e) { + return false; + } + } + + /** + * Return the secret engine type and mount path using the internal Vault + * path-help functionality, no need for access to /sys/mounts which might + * be a security risk * - * @throws GuacamoleException - * If the requested secret cannot be retrieved or the HV notation - * is invalid. + * @param path + * The path to test for the secret engine type + * + * @return + * A Map with the secret engine type and its mount path + */ + public JsonNode getSecretsEngine(String path) { + JsonNode cacheResponse = cache.getIfPresent(VAULT_PATH_HELP + path); + if (cacheResponse != null) { + return cacheResponse; + } + + VaultResponse response = vaultTemplate.read(VAULT_PATH_HELP + path); + Map data = response.getData(); + String type = String.valueOf(data.get("type")); + + if (type.equals("kv")) { + // Need to detect if type 1 or type 2 Key/Value engine + if (data.get("options") instanceof Map) { + Map options = (Map) data.get("options"); + if ("2".equals(String.valueOf(options.get("version")))) { + type = "kv_2"; + } + else { + type = "kv_1"; + } + } + else { + // No options, assume kv_1 + type = "kv_1"; + } + } + JsonNode map = objectMapper.valueToTree(Map.of("type", type, + "path", String.valueOf(data.get("path")))); + cache.put(VAULT_PATH_HELP + path, map); + + return map; + } + + /** + * Contains information about the checked out LDAP sessions + */ + private static class LDAPSessionInfo { + final String checkInPath; + final String username; + final Boolean initialized; + final Instant created; + + public LDAPSessionInfo(String checkInPath, String username) { + this.checkInPath = checkInPath; + this.username = username; + this.initialized = false; + this.created = Instant.now(); + } + + public LDAPSessionInfo(String checkInPath, String username, Boolean initialized) { + this.checkInPath = checkInPath; + this.username = username; + this.initialized = initialized; + this.created = Instant.now(); + } + } + + /** + * The LDAP session interface checks out session that then can not be + * used till they are checked in. In getTokens we have the problem that + * we don't have access to the tunnel ID and so can't identify it. + * Guacamole is also not guarenteed to generate a TunnelCloseEvent. + * + * So this function is fragile and relies on the fact that the TunnelConnectEvent + * will be running a few tens of milliseconds after the getTokens command to + * limit the risk of confusing two connection. There is still a small risk + * of error here. + * + * @param id + * The id of the tunnel ldap session + * + * @param tunnelId + * The tunnel ID to store if we are treating a TunnelConnectEvent + * + * @return + * Returns true if our client treats this element */ - public Future getSecret(String notation) throws GuacamoleException { - return getSecret(notation, null); + public Boolean treatLdapSession(String id, @Nullable String tunnelId) { + LDAPSessionInfo session = ldapSessions.get(id); + if (session != null) { + if (tunnelId != null) { + logger.debug("Storing connection ID: {}", tunnelId); + ldapSessions.put(tunnelId, new LDAPSessionInfo(session.checkInPath, session.username, true)); + } + else if (session.initialized) { + // FIXME : We don't always receive the TunnelCloseEvent + // So this checkin function only kinda works + logger.debug("Removing stored LDAP session: {}", id); + vaultTemplate.write(session.checkInPath, Map.of("service_account_names", session.username)); + } + ldapSessions.remove(id); + + // Do some clean up of the active LDAP sessions. If a session hasn't been + // checked in after 2 hours, just drop it from the hashMap. As the TTL of + // the vault is already 2 hours don't need to check it in. Don't really + // care if the value hang around in our hashmap so don't need a dedicated + // task for this + ldapSessions.entrySet().removeIf(e -> Instant.now().isAfter(e.getValue().created.plusSeconds(7200))); + + return true; + } + else { + return false; + } } /** @@ -153,45 +342,47 @@ public Future getSecret(String notation) throws GuacamoleException { * @param notation * The HV notation of the secret to retrieve. * - * @param fallbackFunction - * A function to invoke in order to produce a Future for return, - * if the requested secret is not found. If the provided Function - * is null, it will not be run. + * @param username + * The connection username, that must be non null for SSH certificate + * generation. + * + * @param key + * A pseudo-unique key to use to stored cached secrets, to keep secrets + * associated with the same connection together, even if they vault token + * itself is not unique. * * @return * A Future which completes with the value of the secret represented by - * the given HV notation, or empty string if there is no such secret to - * remove the token. + * the given HV notation, or null if there is no such secret. * * @throws GuacamoleException * If the requested secret cannot be retrieved or the HV notation * is invalid. */ - public Future getSecret(String notation, - @Nullable GuacamoleExceptionSupplier> fallbackFunction) - throws GuacamoleException { + public CompletableFuture getSecret(String notation, String username, String key) throws GuacamoleException { // If it's not an HV token, fail - if (!notation.startsWith(HASHICORP_VAULT_TOKEN_PREFIX)) - throw new GuacamoleException("Invalid token HV notation: " + notation); + if (!notation.startsWith(VAULT_TOKEN_PREFIX)) + throw new GuacamoleException("Invalid token Vault notation: " + notation); /* - * HASHIVAULT:path/to/secret <-- the Guacamole token name and its modifier + * vault://path/to/secret <-- the Guacamole token name and its modifier * ^^^^^^^ <-- this is the path * ^^^^^^ <-- this is the secret (or key in HV terms) */ int lastSlashIndex = notation.lastIndexOf('/'); if (lastSlashIndex == -1) - lastSlashIndex = HASHICORP_VAULT_TOKEN_PREFIX.length(); + lastSlashIndex = VAULT_TOKEN_PREFIX.length(); - String path = notation.substring(HASHICORP_VAULT_TOKEN_PREFIX.length(), lastSlashIndex); + String path = notation.substring(VAULT_TOKEN_PREFIX.length(), lastSlashIndex); String secret = notation.substring(lastSlashIndex + 1); - // Try to get the whole secret from cache and return key - HvTimedSecretData cachedSecret = cachedSecrets.get(path); - if (cachedSecret != null && System.currentTimeMillis() < cachedSecret.dateCreated + cacheLifetime) { + JsonNode cachedSecrets = cache.getIfPresent(key); + + if (cachedSecrets != null) { + logger.debug("Using cached data for token : {}", notation); try { - JsonNode secretNode = cachedSecret.jsonNode.get("data").get("data").get(secret); + JsonNode secretNode = cachedSecrets.get(secret); if (secretNode == null) { logger.warn("Could not find {}/{}", path, secret); return CompletableFuture.completedFuture(""); @@ -204,23 +395,39 @@ public Future getSecret(String notation, } // Cache miss, either get an existing in-flight request or create a new one - CompletableFuture futureResponse = inFlightRequests.computeIfAbsent(path, k -> { + CompletableFuture futureResponse = inFlightRequests.computeIfAbsent(key, k -> { return CompletableFuture.supplyAsync(() -> { try { - // Perform the Vault query - HttpRequest request = HttpRequest.newBuilder() - .uri(URI.create(hvConfig.get(HvConfigurationService.PARAM_NAME_VAULT_URL) + HASHICORP_VAULT_HTTP_VERSION + path)) - .header(HASHICORP_VAULT_HTTP_HEADER_TOKEN, hvConfig.get(HvConfigurationService.PARAM_NAME_VAULT_TOKEN)) - .GET() - .build(); - HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - JsonNode jsonNode = objectMapper.readTree(response.body()); + String type = getSecretsEngine(path).get("type").asText(); + String mountPath = getSecretsEngine(path).get("path").asText(); + String newpath = path.substring(mountPath.length()); + logger.debug("Vault {}, {}, {}, {}", type, mountPath, path, secret); + JsonNode jsonNode; + switch (type) { + case "ssh": + jsonNode = getValueSSH(mountPath, newpath, username); + break; + case "ldap": + jsonNode = getValueLDAP(mountPath, newpath); + break; + case "database": + jsonNode = getValueDB(mountPath, newpath); + break; + case "kv_1": + jsonNode = getValueKV(mountPath, newpath, VaultKeyValueOperations.KeyValueBackend.KV_1); + break; + case "kv_2": + jsonNode = getValueKV(mountPath, newpath, VaultKeyValueOperations.KeyValueBackend.KV_2); + break; + default: + throw new IllegalArgumentException("Unknown secret engine for the token: '" + type +"'"); + } return jsonNode; - } + catch (Exception e) { - logger.warn("Vault query failed for {} with {}", path, e); + logger.warn("Vault query failed for {} with {}", path, e.getMessage()); throw new CompletionException("Vault query failed for " + path, e); } }); @@ -229,32 +436,23 @@ public Future getSecret(String notation, return futureResponse.whenComplete((jsonNode, ex) -> { // Put newly received JSON data into the cache if (ex == null && jsonNode != null) { - HvTimedSecretData secretToCache = new HvTimedSecretData(); - secretToCache.dateCreated = System.currentTimeMillis(); - secretToCache.jsonNode = jsonNode; - cachedSecrets.put(path, secretToCache); + logger.debug("Putting data to cache : {}", notation); + cache.put(key, jsonNode); } // Now that the cache is filled, this in-flight request is obsolete and must be removed - inFlightRequests.remove(path); + inFlightRequests.remove(key); }).thenApply(jsonNode -> { /* * Extract and return the secret - * If there's no data.data, it's weird so fail with an exception */ - try { - JsonNode secretNode = jsonNode.get("data").get("data").get(secret); - if (secretNode == null) { - logger.warn("Could not find {}/{}", path, secret); - return ""; - } - return secretNode.asText(); - } - catch (Exception e) { - throw new CompletionException("Failed to parse JSON response for path " + path, e); + JsonNode secretNode = jsonNode.get(secret); + if (secretNode == null) { + logger.warn("Could not find {}/{}", path, secret); + return ""; } - + return secretNode.asText(); }).exceptionally(e -> { // Make sure that the exception is a GuacamoleException Throwable cause = e.getCause(); @@ -267,4 +465,175 @@ public Future getSecret(String notation, new GuacamoleException("Vault query failed for " + path + ": " + errorMessage, cause)); }); } + + /** + * Retrieves a value from a key-value secret engine of a vault. + * + * @param mountPath + * The mountPath of the key-value secret engine on the vault server. + * + * @param path + * The path of the secret record + * + * @param type + * The type of key-value store. Either "kv_1" or "kv_2" + * + * @return + * The values associated with the path. + * + * @throws GuacamoleException + * If the secrets cannot be retrieved from the Vault. + */ + private JsonNode getValueKV(String mountPath, String path, VaultKeyValueOperations.KeyValueBackend type) throws VaultException { + VaultKeyValueOperations kvOperations = vaultTemplate.opsForKeyValue(mountPath, type); + + // Get the values on the path and cache them + VaultResponse response = kvOperations.get(path); + + if (response == null || response.getData() == null) + { + throw new VaultException("Value not found in Vault for path: " + path); + } + + return objectMapper.valueToTree(response.getData()); + } + + /** + * Retrieves a an ssh one-time password or signed SSH certificate + * + * @param mountPath + * The mountPath of the SSH secret engine on the vault server. + * + * @param path + * The path of the secret record representing the SSH role + * + * @param username + * The connection username that must be non null for SSH certificate + * generation. + * + * @return + * The values associated with the path. + * + * @throws GuacamoleException + * If the secrets cannot be retrieved from the Vault. + */ + private JsonNode getValueSSH(String mountPath, String path, String username) throws VaultException { + if (path.startsWith("creds/")) { + if (username == null || username.isEmpty()) { + throw new VaultException("The username can not be empty for SSH signed certificates"); + } + + VaultResponse response = + vaultTemplate.write(mountPath + path, Map.of("ip", "0.0.0.0")); + + if (response == null || response.getData() == null) { + throw new VaultException("No response from Vault SSH engine"); + } + Map retval = response.getData(); + retval.put("password", retval.get("key")); + + return objectMapper.valueToTree(retval); + } + else if (path.startsWith("sign/")) { + HvSshKeys sshKeys = new HvSshKeys(vaultInfo.SshType); + Map request = Map.of( + "public_key", sshKeys.publicSsh, + "valid_principals", username, + "extensions", Map.of("permit-pty", ""), + "ttl", vaultInfo.SshConnectionTimeout); + + VaultResponse vaultResponse = vaultTemplate.write(mountPath + path, request); + + if (vaultResponse == null || vaultResponse.getData() == null) { + throw new VaultException("No response from Vault SSH engine"); + } + + String signedCert = (String) vaultResponse.getData().get("signed_key"); + + if (signedCert == null) { + throw new VaultException("Vault did not return a signed SSH certificate"); + } + + return objectMapper.valueToTree(Map.of("private", sshKeys.privateSshPem, + "public", signedCert, + "unsigned", sshKeys.publicSsh)); + } + else { + throw new VaultException("Unknown SSH type on path: " + mountPath + path); + } + } + + /** + * Retrieves a username or password from an LDAP secret engine. The type of + * account supported might be static, dynamic or service accounts + * + * @param mountPath + * The mountPath of the LDAP secret engine on the vault server. + * + * @param path + * The path of the secret record representing the LDAP role + * + * @return + * The values associated with the path. + * + * @throws GuacamoleException + * If the secrets cannot be retrieved from the Vault. + */ + private JsonNode getValueLDAP(String mountPath, String path) throws VaultException { + VaultResponse response; + if (path.startsWith("static") || path.startsWith("creds/")) { + response = vaultTemplate.read(mountPath + path); + } + else if (path.startsWith("library/")) { + response = vaultTemplate.write(mountPath + path + "/check-out", Map.of("ttl", "2h")); + } + else { + throw new VaultException("Unknown LDAP type on path: " + mountPath + path); + } + + if (response == null || response.getData() == null) { + throw new VaultException("No response from LDAP secrets engine"); + } + + Map retval = response.getData(); + if (path.startsWith("library/")) { + String username = String.valueOf(retval.get("service_account_name")); + retval.put("username", username); + + // Register a listener to check-in the account for a TunnelClose Event + logger.info("Caching session : {}", username); + LDAPSessionInfo info = new LDAPSessionInfo(mountPath + path + "/check-in", username); + ldapSessions.put(VAULT_LDAP_SESSION, info); + } + + return objectMapper.valueToTree(retval); + } + + /** + * Retrieves a username or password from a Database secret engine. + * Before version 1.10 the data could only have username/password + * After there can be static preconfigured fields that the vault tokens + * might access. + * + * @param mountPath + * The mountPath of the database secret engine on the vault server. + * + * @param path + * The path of the secret record representing the LDAP role + * + * @return + * The values associated with the path. + * + * @throws GuacamoleException + * If the secrets cannot be retrieved from the Vault. + */ + private JsonNode getValueDB(String mountPath, String path) throws VaultException { + VaultResponse response = vaultTemplate.read(mountPath + path); + + if (response == null || response.getData() == null) { + throw new VaultException("No response from Database secrets engine"); + } + + return objectMapper.valueToTree(response.getData()); + } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientFactory.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientFactory.java index 4de0ae0d7d..a1b9c1f296 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientFactory.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientFactory.java @@ -21,6 +21,7 @@ import java.util.Map; import javax.annotation.Nonnull; +import org.apache.guacamole.vault.hv.conf.HvConfigurationService.VaultInfo; /** * Factory for creating HvClient instances. @@ -39,6 +40,6 @@ public interface HvClientFactory { * A new HvClient instance associated with the provided HV config * options. */ - HvClient create(@Nonnull Map hvConfigOptions); + HvClient create(@Nonnull VaultInfo vaultInfo); } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java index cfd91ea033..59545d95bc 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java @@ -19,28 +19,26 @@ package org.apache.guacamole.vault.hv.secret; -import java.nio.charset.StandardCharsets; -import java.util.Base64; - import com.google.common.base.Objects; import com.google.inject.Inject; import com.google.inject.Singleton; - import java.io.UnsupportedEncodingException; +import java.net.URI; import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.HashMap; import java.util.HashSet; import java.util.Map; import java.util.Set; +import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.Future; +import java.util.function.Function; import java.util.regex.Matcher; import java.util.regex.Pattern; - import javax.annotation.Nonnull; - import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.net.auth.Attributes; import org.apache.guacamole.net.auth.Connectable; @@ -49,11 +47,14 @@ import org.apache.guacamole.net.auth.Directory; import org.apache.guacamole.net.auth.User; import org.apache.guacamole.net.auth.UserContext; +import org.apache.guacamole.net.event.TunnelCloseEvent; +import org.apache.guacamole.net.event.TunnelConnectEvent; import org.apache.guacamole.protocol.GuacamoleConfiguration; import org.apache.guacamole.token.TokenFilter; import org.apache.guacamole.vault.hv.GuacamoleExceptionSupplier; import org.apache.guacamole.vault.hv.conf.HvAttributeService; import org.apache.guacamole.vault.hv.conf.HvConfigurationService; +import org.apache.guacamole.vault.hv.conf.HvConfigurationService.VaultInfo; import org.apache.guacamole.vault.hv.user.HvDirectory; import org.apache.guacamole.vault.secret.VaultSecretService; import org.apache.guacamole.vault.secret.WindowsUsername; @@ -72,25 +73,53 @@ public class HvSecretService implements VaultSecretService { /** * Logger for this class. */ - private static final Logger logger = LoggerFactory.getLogger(VaultSecretService.class); + private static final Logger logger = LoggerFactory.getLogger(HvSecretService.class); /** * Service for retrieving configuration information. */ - @Inject private HvConfigurationService confService; /** * Factory for creating HV client instances. */ - @Inject private HvClientFactory hvClientFactory; /** - * A map of base-64 encoded JSON HV config blobs to associated HV client instances. - * A distinct HV client will exist for every HV config. + * Public constructor for Guice, so that we can instantiate the existing + * Vault clients early, to avoid problems with expiring tokens + * + * @param confService + * Service for retrieving configuration information + * + * @param hvClientFactory + * Factory for creating HV client instances */ - private final ConcurrentMap hvClientMap = new ConcurrentHashMap<>(); + @Inject + public HvSecretService(HvConfigurationService confService, HvClientFactory hvClientFactory) { + this.confService = confService; + this.hvClientFactory = hvClientFactory; + + // Instantiate the HvClient early to start Token renewal of main Vault account. + // FIXME: Don't have access to the root ConnectionGroup here, so can't instantiate + // the per ConnectionGroup vaults, which MUST have a non expiring means of + // authentication to avoid issues + try { + HvClient client = getClient(confService.new VaultInfo(confService.getVaultUri(), + confService.getVaultToken(), + confService.getVaultUsername(), + confService.getVaultPassword())); + } + catch (GuacamoleException e) { + logger.error("Can't initialize HvClient : {}", e.getMessage()); + } + } + + /** + * A map of HV VaultInfo configurations to associated HV client instances. + * A distinct HV client will exist for every VaultInfo. + */ + static private final ConcurrentMap hvClientMap = new ConcurrentHashMap<>(); /** * Create and return a HV client for the provided HV config if not already @@ -108,23 +137,31 @@ public class HvSecretService implements VaultSecretService { * @throws GuacamoleException * If an error occurs while creating the HV client. */ - private HvClient getClient(@Nonnull String hvConfigBase64) + private HvClient getClient(@Nonnull VaultInfo vaultInfo) throws GuacamoleException { - // If a client already exists for the provided config, use it - HvClient hvClient = hvClientMap.get(hvConfigBase64); + HvClient hvClient = hvClientMap.get(vaultInfo); if (hvClient != null) return hvClient; // Create and store a new HV client instance for the provided HV config blob - Map hvConfig = confService.parseHvConfig(hvConfigBase64); - hvClient = hvClientFactory.create(hvConfig); - HvClient prevClient = hvClientMap.putIfAbsent(hvConfigBase64, hvClient); + hvClient = hvClientFactory.create(vaultInfo); + HvClient prevClient = hvClientMap.putIfAbsent(vaultInfo, hvClient); // If the client was already set before this thread got there, use the existing one return prevClient != null ? prevClient : hvClient; } + /** + * As vault notation is essentially a URL, encode all components + * using standard URL escaping. + * + * @param nameComponent + * The token to be canonicalized + * + * @return + * The canonicalized token + */ @Override public String canonicalize(String nameComponent) { try { @@ -139,39 +176,213 @@ public String canonicalize(String nameComponent) { } } - @Override - public Future getValue( UserContext userContext, - Connectable connectable, String name) throws GuacamoleException { + /** + * Before going further replace the arguments "{GUAC_USERNAME}","{USERNAME}", + * "{HOSTNAME}", "{GATEWAY_USERNAME}" and "{GATEWAY_HOSTNAME}" in the token with + * values supplied in the parameters. + * + * @param token + * A token that might or might not include sub-tokens to be replaced + * + * @param userContext + * The user context from which the connectable originated. + * + * @param config + * The configuration of the Guacamole connection for which tokens are + * being generated. This configuration may be empty or partial, + * depending on the underlying implementation. + * + * @return + * A token with the sub-tokens included eplaced. + */ + private String prepareToken(String token, UserContext userContext, GuacamoleConfiguration config, TokenFilter filter) { + // FIXME There is an edge case for tokens like "vault://ldap/$${USER}/{USER}/password" + // both here and below. This seems a pretty unlikely case, so don't treat. + String guac_username = userContext == null ? "" : userContext.self().getIdentifier(); + if (guac_username != null && !guac_username.isEmpty() + && token.contains("{GUAC_USERNAME}") && ! token.contains("$${GUAC_USERNAME}")) { + token = token.replace("{GUAC_USERNAME}", filter.filter(guac_username)); - // Attempt to find a HV config for this connection or group - String hvConfig = getConnectionGroupHvConfig(userContext, connectable); + } + String hostname = config.getParameter("hostname"); + if (hostname != null && !hostname.isEmpty() && !hostname.contains("${") + && token.contains("{HOSTNAME}") && ! token.contains("$${HOSTNAME}")) { + token = token.replace("{HOSTNAME}", filter.filter(hostname)); - return getClient(hvConfig).getSecret(name, new GuacamoleExceptionSupplier>() { + } + String username = config.getParameter("username"); + if (username != null && !username.isEmpty() && !username.contains("${") + && token.contains("{USERNAME}") && ! token.contains("$${USERNAME}")) { + token = token.replace("{USERNAME}", filter.filter(username)); - @Override - public Future get() throws GuacamoleException { + } + String gatewayHostname = config.getParameter("gateway-hostname"); + if (gatewayHostname != null && !gatewayHostname.isEmpty() && !gatewayHostname.contains("${") + && token.contains("{GATEWAY}") && ! token.contains("$${GATEWAY}")) { + token = token.replace("{GATEWAY}", filter.filter(gatewayHostname)); - // Get the user-supplied HV config, if allowed by config and - // set by the user - String userHvConfig = getUserHVConfig(userContext, connectable); + } + String gatewayUsername = config.getParameter("gateway-username"); + if (gatewayUsername != null && !gatewayUsername.isEmpty() && !gatewayUsername.contains("${") + && token.contains("{GATEWAY_USER}") && ! token.contains("$${GATEWAY_USER}")) { + token = token.replace("{GATEWAY_USER}", filter.filter(gatewayUsername)); - // If the user config happens to be the same as admin-defined one, - // don't bother trying again - if (userHvConfig != null && !Objects.equal(userHvConfig, hvConfig)) - return getClient(userHvConfig).getSecret(name); + } - return CompletableFuture.completedFuture(null); - } + return token; + } + + /** + * Returns a Future which eventually completes with the value of the secret + * having the given name. If no such secret exists, the Future will be + * completed with null. The secrets retrieved from this method are independent + * of the context of the particular connection being established, or any + * associated user context. + * + * @param userContext + * The user context from which the connectable originated. + * + * @param connectable + * The connection or connection group for which the tokens are being replaced. + * + * @param token + * The name of the secret to retrieve. + * + * @return + * A Future which completes with value of the secret having the given + * name. If no such secret exists, the Future will be completed with + * null. If an error occurs asynchronously which prevents retrieval of + * the secret, that error will be exposed through an ExecutionException + * when an attempt is made to retrieve the value from the Future. + * + * @throws GuacamoleException + * If the secret cannot be retrieved due to an error. + */ + @Override + public Future getValue( UserContext userContext, + Connectable connectable, String name) throws GuacamoleException { + GuacamoleConfiguration config; + if (connectable instanceof Connection) { + config = ((Connection) connectable).getConfiguration(); + } + else { + config = new GuacamoleConfiguration(); + } - }); + // Create an application wide client + VaultInfo vaultInfo = confService.new VaultInfo(confService.getVaultUri(), + confService.getVaultToken(), + confService.getVaultUsername(), + confService.getVaultPassword()); + HvClient client = getClient(vaultInfo); + + // Create a connection group client + HvClient connectionClient = getConnectionGroupHvClient(userContext, connectable); + + // Configure per user client if configured + HvClient userClient = getUserHvClient(userContext, connectable); + + // Use a key including GUAC_USERNAME, to at least prevent a user stealing the + // session of another due to timing issues. The ssh certificates of keys + // of token from vault-token-mapping.yml must have an explicit username associated + // with it + final String username = config.getParameter("username"); + final String guac_username = userContext.self().getIdentifier(); + final String finalName = prepareToken(name.replaceFirst(":(LOWER|UPPER|OPTIONAL)$", ""), + userContext, config, new TokenFilter()); + final String key = guac_username + "-" + username + "-" + + finalName.substring(0, finalName.lastIndexOf('/')); + + return client.getSecret(finalName, username, key) + .handle((value, ex) -> { + if (ex == null) { + return CompletableFuture.completedFuture(value); + } + + if (connectionClient != null) { + try { + return connectionClient.getSecret(finalName, username, key); + } catch (GuacamoleException e) { + return CompletableFuture.failedFuture(e); + } + } + + return CompletableFuture.failedFuture(ex); + }) + .thenCompose(f -> f) + .handle((value, ex) -> { + if (ex == null) { + return CompletableFuture.completedFuture(value); + } + + if (userClient != null) { + try { + return userClient.getSecret(finalName, username, key); + } catch (GuacamoleException e) { + return CompletableFuture.failedFuture(e); + } + } + + return CompletableFuture.completedFuture(null); + }) + .thenCompose(f -> f); } + /** + * Returns a Future which eventually completes with the value of the secret + * having the given name. If no such secret exists, the Future will be + * completed with null. The secrets retrieved from this method are independent + * of the context of the particular connection being established, or any + * associated user context. + * + * @param token + * The name of the secret to retrieve. + * + * @return + * A Future which completes with value of the secret having the given + * name. If no such secret exists, the Future will be completed with + * null. If an error occurs asynchronously which prevents retrieval of + * the secret, that error will be exposed through an ExecutionException + * when an attempt is made to retrieve the value from the Future. + * + * @throws GuacamoleException + * If the secret cannot be retrieved due to an error. + */ @Override public Future getValue(String name) throws GuacamoleException { + // Ensure modifiers are removed + name = name.replaceFirst(":(LOWER|UPPER|OPTIONAL)$", ""); + // Use the default HV configuration from guacamole.properties - return getClient(confService.getHvConfig()).getSecret(name); + VaultInfo vaultInfo = confService.new VaultInfo(confService.getVaultUri(), + confService.getVaultToken(), + confService.getVaultUsername(), + confService.getVaultPassword()); + return getClient(vaultInfo).getSecret(name, "", name.substring(0, name.lastIndexOf('/'))); } + /** + * Returns true if the VaultInfo configuration seems valid + * + * @param vaultInfo + * The VaultInfo variable to test + * + * @return + * True is the value in non null, URI is set and at least one of + * token or username/password id set + */ + private Boolean isVaultInfoValid(VaultInfo vaultInfo) { + if (vaultInfo == null || vaultInfo.Uri == null || vaultInfo.Uri.toString().trim().isEmpty()) + return false; + if (vaultInfo.Token != null && !vaultInfo.Token.trim().isEmpty()) + return true; + if (vaultInfo.Username == null || vaultInfo.Username.trim().isEmpty()) + return false; + if (vaultInfo.Password != null && !vaultInfo.Password.trim().isEmpty()) + return true; + return false; + } + /** * Search for a HV configuration attribute, recursing up the connection group tree * until a connection group with the appropriate attribute is found. If the HV config @@ -185,8 +396,8 @@ public Future getValue(String name) throws GuacamoleException { * A connection or connection group for which the tokens are being replaced. * * @return - * The value of the HV configuration attribute if found in the tree, the default - * HV config blob defined in guacamole.properties otherwise. + * The value of the HV configuration attributes if found in the tree, the default + * HV config defined in guacamole.properties otherwise. * * @throws GuacamoleException * If an error occurs while attempting to retrieve the HV config attribute, or if @@ -194,7 +405,7 @@ public Future getValue(String name) throws GuacamoleException { * defined in the config file. */ @Nonnull - private String getConnectionGroupHvConfig(UserContext userContext, + private HvClient getConnectionGroupHvClient(UserContext userContext, Connectable connectable) throws GuacamoleException { // Check to make sure it's a usable type before proceeding @@ -205,7 +416,10 @@ private String getConnectionGroupHvConfig(UserContext userContext, ); // Use the default value if searching is impossible - return confService.getHvConfig(); + return getClient(confService.new VaultInfo(confService.getVaultUri(), + confService.getVaultToken(), + confService.getVaultUsername(), + confService.getVaultPassword())); } // For connections, start searching the parent group for the HV config @@ -232,11 +446,22 @@ private String getConnectionGroupHvConfig(UserContext userContext, if (group == null) break; - // If the current connection group has the HV configuration attribute + // If the current connection group has HV configuration attributes // set to a non-empty value, return immediately - String hvConfig = group.getAttributes().get(HvAttributeService.HV_CONFIGURATION_ATTRIBUTE); - if (hvConfig != null && !hvConfig.trim().isEmpty()) - return hvConfig; + Map hvConfig = group.getAttributes(); + + if (hvConfig.get(HvAttributeService.HV_URI_ATTRIBUTE) == null) + break; + + VaultInfo vaultInfo = confService.new VaultInfo( + URI.create(hvConfig.get(HvAttributeService.HV_URI_ATTRIBUTE)), + hvConfig.get(HvAttributeService.HV_TOKEN_ATTRIBUTE), + hvConfig.get(HvAttributeService.HV_USERNAME_ATTRIBUTE), + hvConfig.get(HvAttributeService.HV_PASSWORD_ATTRIBUTE) + ); + + if (isVaultInfoValid(vaultInfo)) + return getClient(vaultInfo); // Otherwise, keep searching up the tree until an appropriate configuration is found parentIdentifier = group.getParentIdentifier(); @@ -247,9 +472,11 @@ private String getConnectionGroupHvConfig(UserContext userContext, break; } - // If no HV configuration was ever found, use the default value - return confService.getHvConfig(); + return getClient(confService.new VaultInfo(confService.getVaultUri(), + confService.getVaultToken(), + confService.getVaultUsername(), + confService.getVaultPassword())); } /** @@ -290,23 +517,78 @@ private boolean isHvUserConfigEnabled(Connectable connectable) { * is the connection which will be checked to see if user HV configs * are enabled. * - * @return - * The base64 encoded HV config blob for the current user if one - * exists, and if user HV configs are enabled globally and for the - * provided connectable. + * @return + * The value of the user HV configuration attributes if found in the tree, + * the default HV config defined in guacamole.properties otherwise. * * @throws GuacamoleException * If an error occurs while attempting to fetch the HV config. */ - private String getUserHVConfig(UserContext userContext, + private HvClient getUserHvClient(UserContext userContext, Connectable connectable) throws GuacamoleException { + + // If user HV configs are enabled globally, and for the given connectable, + // return the user-specific HV config, if one exists + if (confService.getAllowUserConfig() && isHvUserConfigEnabled(connectable)) { + + // Get the underlying user, to avoid the KSM config sanitization + User self = (((HvDirectory) userContext.getUserDirectory()) + .getUnderlyingDirectory().get(userContext.self().getIdentifier())); + + // If the current user has HV configuration attributes + // set to a non-empty value, return immediately + Map hvConfig = self.getAttributes(); + + if (hvConfig.get(HvAttributeService.HV_URI_ATTRIBUTE) != null) { + VaultInfo vaultInfo = confService.new VaultInfo( + URI.create(hvConfig.get(HvAttributeService.HV_URI_ATTRIBUTE)), + hvConfig.get(HvAttributeService.HV_TOKEN_ATTRIBUTE), + hvConfig.get(HvAttributeService.HV_USERNAME_ATTRIBUTE), + hvConfig.get(HvAttributeService.HV_PASSWORD_ATTRIBUTE) + ); + + if (isVaultInfoValid(vaultInfo)) { + logger.debug("Using User Vault configuration"); + return getClient(vaultInfo); + } + } + } return null; } /* - * Scan the configuration parameters and fill the token map with all - * supported HV matches. + * Returns a map of token names to corresponding Futures which eventually + * complete with the value of that token, where each token is dynamically + * defined based on connection parameters. If a vault implementation allows + * for predictable secrets based on the parameters of a connection, this + * function should be implemented to provide automatic tokens for those + * secrets and remove the need for manual mapping via YAML. + * + * @param userContext + * The user context from which the connectable originated. + * + * @param connectable + * The connection or connection group for which the tokens are being replaced. + * + * @param config + * The configuration of the Guacamole connection for which tokens are + * being generated. This configuration may be empty or partial, + * depending on the underlying implementation. + * + * @param filter + * A TokenFilter instance that applies any tokens already available to + * be applied to the configuration of the Guacamole connection. These + * tokens will consist of tokens already supplied to connect(). + * + * @return + * A map of token names to their corresponding future values, where + * each token and value may be dynamically determined based on the + * connection configuration. + * + * @throws GuacamoleException + * If an error occurs producing the tokens and values required for the + * given configuration. */ @Override public Map> getTokens(UserContext userContext, @@ -316,20 +598,123 @@ public Map> getTokens(UserContext userContext, Map> tokens = new HashMap<>(); Map parameters = config.getParameters(); - String hvConfigBase64 = getConnectionGroupHvConfig(userContext, connectable); - HvClient client = getClient(hvConfigBase64); - Pattern tokenPattern = Pattern.compile("\\$\\{(" + client.HASHICORP_VAULT_TOKEN_PREFIX + "[^}]+)\\}"); + // Create an application wide client + VaultInfo vaultInfo = confService.new VaultInfo(confService.getVaultUri(), + confService.getVaultToken(), + confService.getVaultUsername(), + confService.getVaultPassword()); + HvClient client = getClient(vaultInfo); + + // Create a connection group client + HvClient connectionClient = getConnectionGroupHvClient(userContext, connectable); + + // Configure per user client if configured + HvClient userClient = getUserHvClient(userContext, connectable); + + // Remove optional token parameter modifier and match only our own tokens + Pattern tokenPattern = Pattern.compile("\\$\\{(" + client.VAULT_TOKEN_PREFIX + + "(?:[^{}:]|:(?!(?:LOWER|UPPER|OPTIONAL)(?=\\}))|\\{(?:[^{}]|\\{[^{}]*\\})*\\})+" + + ")(:(?:(LOWER|UPPER|OPTIONAL))(?=\\}))?\\}"); + + // To keep the tokens for the same connection associated with each other in the + // cache, for tokens that might create a confusion, we cache them with a shared + // key + String key = UUID.randomUUID().toString(); + + // Resolve any tokens in the username for use in possible ssh certificate + String username = filter.filter(config.getParameter("username")); for (Map.Entry entry : parameters.entrySet()) { Matcher tokenMatcher = tokenPattern.matcher(entry.getValue()); while (tokenMatcher.find()) { String notation = tokenMatcher.group(1); - tokens.put(notation, client.getSecret(notation)); + String finalName = prepareToken(notation, userContext, config, filter); + tokens.put(notation, client.getSecret(finalName, username, key) + .handle((value, ex) -> { + if (ex == null) { + return CompletableFuture.completedFuture(value); + } + + if (connectionClient != null) { + try { + return connectionClient.getSecret(finalName, username, key); + } catch (GuacamoleException e) { + return CompletableFuture.failedFuture(e); + } + } + + return CompletableFuture.failedFuture(ex); + }) + .thenCompose(Function.identity()) + .handle((value, ex) -> { + if (ex == null) { + return CompletableFuture.completedFuture(value); + } + + if (userClient != null) { + try { + return userClient.getSecret(finalName, username, key); + } catch (GuacamoleException e) { + return CompletableFuture.failedFuture(e); + } + } + + return CompletableFuture.completedFuture(null); + }) + .thenCompose(Function.identity())); } } + // Don't print secret values even at debug level. Still needed for testing + // so keep it in comments. Please note the v.get() will cause this code to + // wait for completion of the Future values. + //logger.debug("Returning {} Vault tokens:", tokens.size()); + //tokens.forEach((k, v) -> { + // try { + // logger.debug(" {} : {}", k, v.get()); + // } catch (Exception e) { + // logger.debug(" {} => ERROR: {}", k, e.getMessage()); + // }}); + + // Simpler, innocuous debugging message + logger.debug("Returning {} Vault tokens: {}", tokens.size(), tokens.keySet()); + return tokens; } + /** + * The LDAP session interface checks out sessions that then can not be + * used till they are checked in. In getTokens we have the problem that + * we don't have access to the tunnel ID and so can't identify it. + * Guacamole is also not guarenteed to generate a TunnelCloseEvent. + * + * So this function is fragile and relies on the fact that the TunnelConnectEvent + * will be running a few tens of milliseconds after the getTokens command to + * limit the risk of confusing two connections. There is still a small risk + * of error here. This needs a better solution + * + * @param event + * A TunnelConnectEvent or TunnelCloseEvent + */ + static public void treatLdapSession(Object event) { + if (event instanceof TunnelConnectEvent) { + String id = ((TunnelConnectEvent) event).getTunnel().getUUID().toString(); + for (Map.Entry entry : hvClientMap.entrySet()) { + HvClient client = entry.getValue(); + if (client.treatLdapSession(client.VAULT_LDAP_SESSION, id)) { + break; + } + } + } + else { + String id = ((TunnelCloseEvent) event).getTunnel().getUUID().toString(); + for (Map.Entry entry : hvClientMap.entrySet()) { + HvClient client = entry.getValue(); + if (client.treatLdapSession(id, null)) { + break; + } + } + } + } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSshKeys.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSshKeys.java new file mode 100644 index 0000000000..120d900bb8 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSshKeys.java @@ -0,0 +1,126 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.guacamole.vault.hv.secret; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import org.apache.guacamole.vault.hv.conf.HvConfigurationService; +import org.apache.sshd.common.keyprovider.KeyPairProvider; +import org.apache.sshd.common.config.keys.writer.openssh.OpenSSHKeyPairResourceWriter; +import org.apache.sshd.common.config.keys.PublicKeyEntry; +import org.apache.sshd.common.util.security.SecurityUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class HvSshKeys { + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(HvSshKeys.class); + + /** + * The PEM encoded private SSH key + */ + public final String privateSshPem; + + /** + * The OpenSSH encoded public SSH key + */ + public final String publicSsh; + + /** + * Class instantiation to return generated SSH keys. Default type + */ + public HvSshKeys() { + this(HvConfigurationService.DEFAULT_SSH_TYPE); + } + + /** + * Class instantiation to return generated SSH keys + * + * @param type + * The type of ssh key to generate. Can be "rsa" or "ed25519" only. + * Generated RSA keys are 4096 bit only + */ + public HvSshKeys(String type) { + KeyPair keyPair; + + if ("rsa".equals(type)) { + keyPair = generateRsa(); + } + else if ("ed25519".equals(type)) { + keyPair = generateEd25519WithFallback(); + } + else { + throw new IllegalArgumentException("Unrecognized SSH encryption : "+ type); + } + + try { + this.publicSsh = PublicKeyEntry.toString(keyPair.getPublic()); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + OpenSSHKeyPairResourceWriter writer = + new OpenSSHKeyPairResourceWriter(); + writer.writePrivateKey(keyPair, null, null, baos); + this.privateSshPem = + baos.toString(StandardCharsets.UTF_8); + } + catch (Exception e) { + throw new IllegalStateException("Failed to serialize SSH keypair: " + e.getMessage(), e); + } + } + + /** + * Generate a ed25519 key-pair + * + * @return + * A java.security.KeyPair containing the ed25519 key pair or RSA if failure + */ + private KeyPair generateEd25519WithFallback() { + try { + KeyPairGenerator keyPairGenerator = + SecurityUtils.getKeyPairGenerator("EdDSA"); + return keyPairGenerator.generateKeyPair(); + } + catch (Exception e) { + logger.warn("Ed25519 not available via SSHD EdDSA. Falling back to RSA : {}", e.getMessage()); + return generateRsa(); + } + } + + /** + * Generate a 4096-bit RSA key-pair + * + * @return + * A java.security.KeyPair containing the RSA key pair + */ + private KeyPair generateRsa() { + try { + KeyPairGenerator keyPairGenerator = + SecurityUtils.getKeyPairGenerator("RSA"); + keyPairGenerator.initialize(4096); + return keyPairGenerator.generateKeyPair(); + } + catch (Exception e) { + throw new IllegalStateException("Failed to generate RSA SSH keypair", e); + } + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvTimedSecretData.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvTimedSecretData.java deleted file mode 100644 index b509b07837..0000000000 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvTimedSecretData.java +++ /dev/null @@ -1,33 +0,0 @@ - -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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 org.apache.guacamole.vault.hv.secret; - -import com.fasterxml.jackson.databind.JsonNode; - -/** - * Record to link JSON data to the time it was obtained from server. - * Might be simplified in the future to: - * record HvTimedSecretData(long dateCreated, JsonNode jsonNode) {}; - */ -public final class HvTimedSecretData { - public long dateCreated; - public JsonNode jsonNode; -} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvTunnelEventListener.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvTunnelEventListener.java new file mode 100644 index 0000000000..fdeb7855e9 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvTunnelEventListener.java @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.guacamole.vault.hv.secret; + +import com.google.inject.Inject; +import com.google.inject.Provider; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.net.event.listener.Listener; +import org.apache.guacamole.net.event.TunnelCloseEvent; +import org.apache.guacamole.net.event.TunnelConnectEvent; +import org.apache.guacamole.vault.hv.secret.HvSecretService; + +public class HvTunnelEventListener implements Listener { + + /** + * Default constructor for ProviderFactory + */ + public HvTunnelEventListener() { } + + /** + * The LDAP session interface checks out session that then can not be + * used till they are checked in. Use a TunnelConnectEvent listener to + * add a checkin task to the sessions + * + * @param event + * A tunnel close event + */ + @Override + public void handleEvent(Object event) throws GuacamoleException { + if (event instanceof TunnelConnectEvent || event instanceof TunnelCloseEvent) { + HvSecretService.treatLdapSession(event); + } + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnectionGroup.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnectionGroup.java index f406677025..1c57b4c561 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnectionGroup.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnectionGroup.java @@ -80,15 +80,22 @@ public Map getAttributes() { // Make a copy of the existing map Map attributes = Maps.newHashMap(super.getAttributes()); - // Sanitize the HV configuration attribute, and ensure the attribute - // is always present attributes.put( - HvAttributeService.HV_CONFIGURATION_ATTRIBUTE, - HvAttributeService.sanitizeHvAttributeValue( - attributes.get(HvAttributeService.HV_CONFIGURATION_ATTRIBUTE) - ) + HvAttributeService.HV_URI_ATTRIBUTE, + attributes.get(HvAttributeService.HV_URI_ATTRIBUTE) ); - + attributes.put( + HvAttributeService.HV_TOKEN_ATTRIBUTE, + attributes.get(HvAttributeService.HV_TOKEN_ATTRIBUTE) + ); + attributes.put( + HvAttributeService.HV_USERNAME_ATTRIBUTE, + attributes.get(HvAttributeService.HV_USERNAME_ATTRIBUTE) + ); + attributes.put( + HvAttributeService.HV_PASSWORD_ATTRIBUTE, + attributes.get(HvAttributeService.HV_PASSWORD_ATTRIBUTE) + ); return attributes; } @@ -104,4 +111,4 @@ public void setAttributes(Map attributes) { } } -} \ No newline at end of file +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUser.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUser.java index f5dc7f242d..c4bd7d1dfa 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUser.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUser.java @@ -97,17 +97,31 @@ public Map getAttributes() { // If user-specific HV configuration is not enabled, do not expose the // attribute at all - if (!userHvConfigEnabled) - attributes.remove(HvAttributeService.HV_CONFIGURATION_ATTRIBUTE); - - else - // Sanitize the HV configuration attribute, and ensure the attribute - // is always present + if (!userHvConfigEnabled) { + attributes.remove(HvAttributeService.HV_URI_ATTRIBUTE); + attributes.remove(HvAttributeService.HV_TOKEN_ATTRIBUTE); + attributes.remove(HvAttributeService.HV_USERNAME_ATTRIBUTE); + attributes.remove(HvAttributeService.HV_PASSWORD_ATTRIBUTE); + } + else { + attributes.put( + HvAttributeService.HV_URI_ATTRIBUTE, + attributes.get(HvAttributeService.HV_URI_ATTRIBUTE) + ); + attributes.put( + HvAttributeService.HV_TOKEN_ATTRIBUTE, + attributes.get(HvAttributeService.HV_TOKEN_ATTRIBUTE) + ); + attributes.put( + HvAttributeService.HV_USERNAME_ATTRIBUTE, + attributes.get(HvAttributeService.HV_USERNAME_ATTRIBUTE) + ); attributes.put( - HvAttributeService.HV_CONFIGURATION_ATTRIBUTE, - HvAttributeService.sanitizeHvAttributeValue( - attributes.get(HvAttributeService.HV_CONFIGURATION_ATTRIBUTE))); + HvAttributeService.HV_PASSWORD_ATTRIBUTE, + attributes.get(HvAttributeService.HV_PASSWORD_ATTRIBUTE) + ); + } return attributes; } @@ -123,4 +137,4 @@ public void setAttributes(Map attributes) { } } -} \ No newline at end of file +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/FileTokenAuthentication.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/FileTokenAuthentication.java new file mode 100644 index 0000000000..ac74c16aa3 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/FileTokenAuthentication.java @@ -0,0 +1,68 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.guacamole.vault.hv.vault; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.springframework.vault.VaultException; +import org.springframework.vault.authentication.ClientAuthentication; +import org.springframework.vault.support.VaultToken; + +public final class FileTokenAuthentication implements ClientAuthentication { + + /** + * The path to the file containing the token + */ + private final Path tokenPath; + + /** + * An instantiator for a Token Authentication class where the token + * is reread from a file on renewal requests. This allows integration + * with a VaultAgent for complex authentication methods + * + * @param String tokenPath + * A path to a readable file containing the token + */ + public FileTokenAuthentication(String tokenPath) { + this.tokenPath = Path.of(tokenPath); + } + + /* + * Returns the current token + * + * @return VaultToken + * The current vault token + */ + @Override + public VaultToken login() { + String token; + try { + token = Files.readString(tokenPath).trim(); + } + catch (IOException e) { + // This might be recoverable. So throw a VautException + throw new VaultException( + "Cannot read Vault token sink: " + tokenPath, e); + } + + return VaultToken.of(token); + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/TtlAwareSessionManager.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/TtlAwareSessionManager.java new file mode 100644 index 0000000000..d6b83a033a --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/TtlAwareSessionManager.java @@ -0,0 +1,495 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.guacamole.vault.hv.vault; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.atomic.AtomicReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.vault.VaultException; +import org.springframework.vault.authentication.ClientAuthentication; +import org.springframework.vault.authentication.LoginToken; +import org.springframework.vault.authentication.SessionManager; +import org.springframework.vault.client.VaultHttpHeaders; +import org.springframework.vault.support.VaultResponse; +import org.springframework.vault.support.VaultToken; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestOperations; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A Vault token manager that automatically handles token renewal and + * re-authentication. This manager is TTL-aware and will: + * + * - Proactively renew renewable tokens before they expire + * - Re-authenticate non-renewable tokens before they expire + * - Skip renewal for tokens with TTL=0 (non-expiring tokens) + * - Handle both LoginToken and VaultToken types. + */ +public class TtlAwareSessionManager implements SessionManager { + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(TtlAwareSessionManager.class); + + /** + * The client authentication method used to obtain new tokens. + */ + private final ClientAuthentication clientAuthentication; + + /** + * HTTP client + */ + private final RestOperations restOperations; + + /** + * Spring-managed scheduler for renewal tasks. + */ + private final TaskScheduler scheduler; + + /** + * The delay (in milliseconds) before token expiration when renewal + * should occur. + */ + private final long renewalDelayMillis; + + /** + * Reference to the currently scheduled renewal task, if any. + */ + private final AtomicReference> scheduledRenewal = + new AtomicReference<>(); + + /** + * The current token. + */ + private final AtomicReference token = new AtomicReference<>(); + + /** + * Creates a new TTL-aware Vault session manager. Need to stay with + * spring-vault 2.3.x at the moment until migration to Tomcat10 of + * Guacamole. The lifecycleAwareSessionManager of version 2.3 is + * not functional. If updating to a future version of spring-vault + * this could extend the LifecycleAwareSessionManager and delegate + * most of the work to it. Expired or non renewable tokens would still + * need to be treated here + * + * @param clientAuthentication + * The authentication method to use for obtaining tokens. + * + * @param restOperations + * A RestOperations configured for the Vault endpoint. + * + * @param scheduler + * Spring TaskScheduler used for renewal scheduling. + * + * @param renewalDelayMillis + * The delay (in milliseconds) before token expiration when renewal + * should occur. + */ + public TtlAwareSessionManager( + ClientAuthentication clientAuthentication, + RestOperations restOperations, + TaskScheduler scheduler, + long renewalDelayMillis) { + + this.clientAuthentication = clientAuthentication; + this.restOperations = restOperations; + this.scheduler = scheduler; + this.renewalDelayMillis = renewalDelayMillis; + + // Obtain the initial token and schedule its renewal + try { + VaultToken token = clientAuthentication.login(); + setSessionToken(token); + if (token != null && !token.getToken().isEmpty()) { + logger.info("TtlAwareSessionManager initialized with renewal delay of {} ms", renewalDelayMillis); + } + } + catch (RuntimeException e) { + logger.error("Non recoverable error initializing TtlAwareSessionManager : {}", e.getMessage(), e); + } + catch (Exception e) { + // Recoverable error: reschedule authentication + scheduleRenewal(null); + } + } + + /** + * Closes the session token manager and cleans up resources. + * Cancels any pending renewal tasks. + * + * Note: TaskScheduler is container-managed and must not be shut down here. + */ + public void close() { + logger.debug("Closing TtlAwareSessionManager"); + stopScheduling(); + } + + /** + * Small helper function to stop scheduling before new scheduled task + * or in case of unrecoverable errors or non-expiring tokens. + */ + private void stopScheduling() { + ScheduledFuture future = scheduledRenewal.getAndSet(null); + if (future != null) { + future.cancel(false); + } + } + + /** + * Schedules a renewal task for the given token if it has a TTL > 0. + * For renewable tokens, this will attempt to renew the token. For + * non-renewable tokens, this will re-authenticate to get a new token. + * + * @param token + * The token to schedule renewal for. + */ + private void scheduleRenewal(VaultToken token) { + + // Cancel any existing scheduled renewal + stopScheduling(); + + if (token == null || token.getToken().isEmpty()) { + // Vault not ready or available ? Try again in 10 seconds + logger.debug("Null token detected. Vault not ready ? Rescheduling authentication"); + scheduledRenewal.set(scheduler.schedule(this::renewTokenAsync, Instant.now().plusMillis(10000))); + return; + } + + try { + TokenInfo tokenInfo = getTokenInfo(token); + + // Skip scheduling if TTL is 0 (non-expiring token) + if (tokenInfo.ttlSeconds == 0) { + logger.info("Skipping renewal scheduling for non-expiring token"); + return; + } + + long delay = calculateDelayUntilRenewal(tokenInfo.ttlSeconds); + + if (delay <= 0) { + // Token is already past renewal threshold; renew immediately + logger.debug("Token is past renewal threshold, renewing immediately"); + renewTokenAsync(); + } + else { + logger.debug("Scheduling token renewal in {} ms", delay); + scheduledRenewal.set(scheduler.schedule(this::renewTokenAsync, Instant.now().plusMillis(delay))); + } + } + catch (VaultException e) { + logger.warn("Failed to get token information, attempting immediate renewal: {}", e.getMessage()); + renewTokenAsync(); + } + } + + /** + * Contains token information extracted from Vault responses. + */ + private static class TokenInfo { + final long ttlSeconds; + final boolean renewable; + + TokenInfo(long ttlSeconds, boolean renewable) { + this.ttlSeconds = ttlSeconds; + this.renewable = renewable; + } + } + + /** + * Gets token information (TTL and renewable status) from the given VaultToken. + * If the token is already a LoginToken, extracts info from it. + * Otherwise, looks up the token to get its information. + * + * @param token The VaultToken to get information for. + * + * @return TokenInfo containing TTL and renewable status. + * + * @throws VaultException If the token lookup fails. + */ + private TokenInfo getTokenInfo(VaultToken token) { + + if (token instanceof LoginToken) { + LoginToken loginToken = (LoginToken) token; + Duration leaseDuration = loginToken.getLeaseDuration(); + long ttlSeconds = leaseDuration != null ? leaseDuration.getSeconds() : 0; + boolean renewable = loginToken.isRenewable(); + + logger.debug("Token is already a LoginToken. TTL: {}, Renewable: {}", ttlSeconds, renewable); + return new TokenInfo(ttlSeconds, renewable); + } + + ResponseEntity response; + + try { + logger.debug("Looking up token information"); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Vault-Token", token.getToken()); + HttpEntity requestEntity = new HttpEntity<>(headers); + + response = restOperations.exchange("/auth/token/lookup-self", + HttpMethod.GET, requestEntity, Map.class); + } + catch (RestClientException e) { + throw new VaultException("Vault request failed: " + e.getMessage()); + } + + if (response == null || response.getBody() == null + || !(response.getBody().get("data") instanceof Map)) { + throw new VaultException("Failed to lookup token: no response data"); + } + + @SuppressWarnings("unchecked") + Map data = (Map) response.getBody().get("data"); + Number ttl = (Number) data.get("ttl"); + Boolean renewable = (Boolean) data.get("renewable"); + + long ttlSeconds = ttl != null ? ttl.longValue() : 0; + boolean isRenewable = renewable != null && renewable; + + logger.debug("Token lookup successful. TTL: {}, Renewable: {}", ttlSeconds, isRenewable); + + return new TokenInfo(ttlSeconds, isRenewable); + } + + /** + * Calculates the delay until the token should be renewed. + * + * @param ttlSeconds The token's TTL in seconds. + * + * @return The delay in milliseconds until renewal should occur. + */ + private long calculateDelayUntilRenewal(long ttlSeconds) { + long expiryTime = System.currentTimeMillis() + (ttlSeconds * 1000); + long delay = (expiryTime - renewalDelayMillis) - System.currentTimeMillis(); + + logger.debug("Calculated renewal delay: {} ms for token with TTL: {} seconds", delay, ttlSeconds); + + return delay; + } + + /** + * Asynchronously renews the current token. For renewable tokens, + * attempts to renew via the Vault API. For non-renewable tokens, + * re-authenticates to get a new token. + */ + private void renewTokenAsync() { + + try { + VaultToken currentToken = getSessionToken(); + + if (currentToken == null || currentToken.getToken().isEmpty()) { + logger.debug("No current token to renew, attempting re-authentication"); + attemptLogin(); + return; + } + + TokenInfo tokenInfo; + + try { + tokenInfo = getTokenInfo(currentToken); + } + catch (VaultException e) { + logger.debug("Token lookup fail, attempting re-authentication"); + attemptLogin(); + return; + } + catch (Exception e) { + logger.error("Non-recoverable error during token lookup: {}", e.getMessage(), e); + stopScheduling(); + return; + } + + if (tokenInfo.ttlSeconds == 0) { + logger.info("Token TTL is zero. Stop renewal for non-expiring tokens"); + stopScheduling(); + return; + } + + VaultToken newToken; + + if (tokenInfo.renewable) { + try { + logger.debug("Renewing token"); + newToken = renewToken(currentToken); + } + catch (VaultException e) { + logger.warn("Token renewal failed, re-authenticating: {}", e.getMessage()); + attemptLogin(); + return; + } + catch (Exception e) { + logger.error("Non-recoverable error during token renewal: {}", e.getMessage(), e); + stopScheduling(); + return; + } + } + else { + logger.debug("Re-authenticating for non-renewable token"); + attemptLogin(); + return; + } + + try { + tokenInfo = getTokenInfo(newToken); + } + catch (VaultException e) { + logger.debug("Token lookup fail, attempting re-authentication"); + attemptLogin(); + return; + } + + long delay = calculateDelayUntilRenewal(tokenInfo.ttlSeconds); + if (delay <= 0) { + // Token is already past renewal threshold; renew immediately + logger.info("Token is past renewal threshold during renewal, re-authenticating"); + attemptLogin(); + return; + } + + logger.debug("Successfully renewed the token"); + setSessionToken(newToken); + } + catch (Exception e) { + logger.error("Unexplained failure to renew token : {}", e.getMessage(), e); + stopScheduling(); + } + } + + /** + * Renews a renewable token via the Vault API. + * + * @param token + * The token to renew. + * + * @return + * A new LoginToken with updated TTL. + * + * @throws VaultException + * If the renewal fails, but the error is recoverable. Non + * recoverable exceptions are bubbled up. + */ + private LoginToken renewToken(VaultToken token) throws VaultException { + + VaultResponse response; + + try { + response = restOperations.postForObject("/auth/token/renew-self", + new HttpEntity<>(VaultHttpHeaders.from(token)), VaultResponse.class); + } + catch (RestClientException e) { + throw new VaultException("Vault request failed: " + e.getMessage()); + } + + if (response == null || response.getAuth() == null) { + throw new VaultException("Token renewal failed: no response data"); + } + + String renewedToken = (String) response.getAuth().get("client_token"); + + if (renewedToken == null) { + throw new VaultException("Token renewal failed: missing token in response"); + } + + Number ttl = (Number) response.getAuth().get("lease_duration"); + Boolean renewable = (Boolean) response.getAuth().get("renewable"); + + long ttlSeconds = ttl != null ? ttl.longValue() : 0; + boolean isRenewable = renewable != null && renewable; + + LoginToken newToken = LoginToken.builder() + .token(renewedToken) + .leaseDuration(Duration.ofSeconds(ttlSeconds)) + .renewable(isRenewable) + .build(); + + logger.info("Vault token renewed successfully. New TTL: {}, Renewable: {}", ttlSeconds, isRenewable); + + return newToken; + } + + /* + * Attempt a login via clientAuthentication class, and reschedule + * in case of a recoverable failure. + */ + private void attemptLogin() { + + try { + VaultToken newToken = clientAuthentication.login(); + + // If we have a VaultToken here the above might not throw a + // VaultException for an invalid token. We use getTokenInfo + // to force a VaultExpception for an invalid token, and avoid + // an infinite loop. Since we have the token info, might as + // well promote it to a LoginToken directly + TokenInfo tokenInfo = getTokenInfo(newToken); + if (!(newToken instanceof LoginToken)) { + newToken = LoginToken.builder() + .token(newToken.getToken()) + .leaseDuration(Duration.ofSeconds(tokenInfo.ttlSeconds)) + .renewable(tokenInfo.renewable) + .build(); + } + + logger.info("Vault login successful. New TTL: {}, Renewable: {}", + tokenInfo.ttlSeconds, tokenInfo.renewable); + setSessionToken(newToken); + } + catch (VaultException e) { + logger.warn("Recoverable authentication failure, rescheduling renewal : {}", e.getMessage()); + stopScheduling(); + scheduledRenewal.set(scheduler.schedule(this::renewTokenAsync, + Instant.now().plusMillis(10000))); + } + catch (Exception e) { + logger.error("Non-recoverable authentication failure: {}", e.getMessage(), e); + stopScheduling(); + } + } + + /** + * Returns the current session token. + */ + @Override + public VaultToken getSessionToken() { + return this.token.get(); + } + + /** + * Sets the current token and schedules its renewal if applicable. + * + * @param token + * Can be either a Null, VaultToken or a LoginToken + */ + public void setSessionToken(VaultToken token) { + if (token != null && !token.getToken().isEmpty()) { + this.token.set(token); + } + scheduleRenewal(token); + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/UsernamePasswordAuthentication.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/UsernamePasswordAuthentication.java new file mode 100644 index 0000000000..4954593c0a --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/UsernamePasswordAuthentication.java @@ -0,0 +1,130 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +// This is a minimal backport of this class to version 2.3.4 of spring-vault-core +// It is compatible with lease renewal using a life cycle aware session manager + +package org.apache.guacamole.vault.hv.vault; + +import java.time.Duration; +import java.util.Collections; +import java.util.Map; +import org.springframework.http.ResponseEntity; +import org.springframework.util.Assert; +import org.springframework.vault.authentication.ClientAuthentication; +import org.springframework.vault.authentication.LoginToken; +import org.springframework.vault.VaultException; +import org.springframework.vault.client.VaultEndpoint; +import org.springframework.vault.support.VaultResponse; +import org.springframework.vault.support.VaultToken; +import org.springframework.web.client.RestTemplate; + +public class UsernamePasswordAuthentication implements ClientAuthentication { + /** + * A class containing all of the configuration options for username/password authentication + */ + private final UsernamePasswordAuthenticationOptions options; + + /** + * A spring RestTemplate used to communicate with the vault server + */ + private final RestTemplate restTemplate; + + /** + * The Vault endpoint to communicate to + */ + private final VaultEndpoint endpoint; + + /** + * Contructor for the Username/Password clientAuthentication + * + * @param options + * The configuration of the username/password to use for authentication + * + * @param endpoint + * The endpoint of teh VAult to use + * + * @param restTemplate + * The spring-framework RestTemplate used to communicate with the Vault + */ + public UsernamePasswordAuthentication( + UsernamePasswordAuthenticationOptions options, + VaultEndpoint endpoint, + RestTemplate restTemplate) { + + Assert.notNull(options, "Options must not be null"); + Assert.notNull(endpoint, "VaultEndpoint must not be null"); + Assert.notNull(restTemplate, "RestTemplate must not be null"); + + this.options = options; + this.endpoint = endpoint; + this.restTemplate = restTemplate; + } + + /** + * A login method that attempts a username/password login to the Vault + * + * @return + * A LoginToken for the authenticated used for use with future operations with + * the vault + * + * @throws VaultException + * In case of a recoverable login issue, throws a VaultExecption, so tha the + * SessionManager knows to try the authtication again + */ + @Override + public VaultToken login() throws VaultException { + + String loginPath = String.format( + "%s://%s:%d/v1/auth/%s/login/%s", + endpoint.getScheme(), + endpoint.getHost(), + endpoint.getPort(), + options.getMountPath(), + options.getUsername()); + + Map body = + Collections.singletonMap("password", options.getPassword()); + + ResponseEntity response = + restTemplate.postForEntity(loginPath, body, VaultResponse.class); + + VaultResponse vaultResponse = response.getBody(); + + if (vaultResponse == null || vaultResponse.getAuth() == null) { + throw new VaultException("No auth section returned from Vault"); + } + + String token = (String) vaultResponse.getAuth().get("client_token"); + Number lease = (Number) vaultResponse.getAuth().get("lease_duration"); + Boolean renewable = (Boolean) vaultResponse.getAuth().get("renewable"); + + long leaseDuration = lease != null ? lease.longValue() : 0; + boolean isRenewable = renewable != null && renewable; + + if (token == null) { + throw new VaultException("No client_token in Vault auth response"); + } + + return LoginToken.builder().token(token) + .leaseDuration(Duration.ofSeconds(leaseDuration)) + .renewable(isRenewable) + .build(); + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/UsernamePasswordAuthenticationOptions.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/UsernamePasswordAuthenticationOptions.java new file mode 100644 index 0000000000..cffda5c19d --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/UsernamePasswordAuthenticationOptions.java @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + + // This is a minimal backport of this class to version 2.3.4 of spring-vault-core + +package org.apache.guacamole.vault.hv.vault; + +import org.springframework.util.Assert; + +public final class UsernamePasswordAuthenticationOptions { + /** + * Path of the userpass authentication method mount. + */ + private final String mountPath; + + /** + * Username of the userpass authetication method mount. + */ + private final String username; + + /** + * Password of the userpass authetication method mount. + */ + private final String password; + + + private UsernamePasswordAuthenticationOptions(Builder builder) { + this.username = builder.username; + this.password = builder.password; + this.mountPath = builder.mountPath; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + public String getMountPath() { + return mountPath; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private String username; + private String password; + private String mountPath = "userpass"; + + public Builder username(String username) { + this.username = username; + return this; + } + + public Builder password(String password) { + this.password = password; + return this; + } + + /** Optional – defaults to "userpass" */ + public Builder mountPath(String mountPath) { + this.mountPath = mountPath; + return this; + } + + public UsernamePasswordAuthenticationOptions build() { + + Assert.hasText(username, "Username must not be empty"); + Assert.hasText(password, "Password must not be empty"); + Assert.hasText(mountPath, "Mount path must not be empty"); + + return new UsernamePasswordAuthenticationOptions(this); + } + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resource-templates/guac-manifest.json b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resource-templates/guac-manifest.json new file mode 100644 index 0000000000..06bc1ee253 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resource-templates/guac-manifest.json @@ -0,0 +1,23 @@ +{ + + "guacamoleVersion" : "${project.version}", + + "name" : "Hashicorp Vault", + "namespace" : "hashicorp-vault", + + "authProviders" : [ + "org.apache.guacamole.vault.hv.HvAuthenticationProvider" + ], + + "translations" : [ + "translations/de.json", + "translations/en.json", + "translations/es.json", + "translations/fr.json" + ], + + "listeners" : [ + "org.apache.guacamole.vault.hv.secret.HvTunnelEventListener" + ] + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/guac-manifest.json b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/guac-manifest.json deleted file mode 100644 index 11a0e3dfda..0000000000 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/guac-manifest.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - - "guacamoleVersion" : "1.6.0", - - "name" : "Hashicorp Vault", - "namespace" : "hashicorp-vault", - - "authProviders" : [ - "org.apache.guacamole.vault.hv.HvAuthenticationProvider" - ], - - "translations" : [ - "translations/en.json" - ] - -} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/translations/de.json b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/translations/de.json new file mode 100644 index 0000000000..ebd2047b74 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/translations/de.json @@ -0,0 +1,28 @@ +{ + + "CONNECTION_ATTRIBUTES": { + "FIELD_HEADER_HV_USER_CONFIG_ENABLED": "Benutzerdefinierte Vault-Konfiguration erlauben", + "SECTION_HEADER_HV_CONFIG": "OpenBao oder Hashicorp Vault" + }, + + "CONNECTION_GROUP_ATTRIBUTES": { + "FIELD_HEADER_HV_URI": "Vault-Server-URL", + "FIELD_HEADER_HV_TOKEN": "Vault-Zugriffstoken oder Sink-Datei", + "FIELD_HEADER_HV_USERNAME": "Vault-Benutzername", + "FIELD_HEADER_HV_PASSWORD": "Vault-Passwort", + "SECTION_HEADER_HV_CONFIG": "OpenBao oder Hashicorp Vault" + }, + + "DATA_SOURCE_HASHICORP_VAULT": { + "NAME": "OpenBao oder Hashicorp Vault" + }, + + "USER_ATTRIBUTES": { + "FIELD_HEADER_HV_URI": "Vault-Server-URL", + "FIELD_HEADER_HV_TOKEN": "Vault-Zugriffstoken oder Sink-Datei", + "FIELD_HEADER_HV_USERNAME": "Vault-Benutzername", + "FIELD_HEADER_HV_PASSWORD": "Vault-Passwort", + "SECTION_HEADER_HV_CONFIG": "OpenBao oder Hashicorp Vault" + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/translations/en.json b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/translations/en.json index d66bd07faa..4fb8f6171e 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/translations/en.json +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/translations/en.json @@ -1,23 +1,28 @@ { - "CONNECTION_ATTRIBUTES" : { - "FIELD_HEADER_HV_USER_CONFIG_ENABLED" : "Allow user-provided HV configuration", - "SECTION_HEADER_HV_CONFIG" : "Hashicorp Vault" + "CONNECTION_ATTRIBUTES": { + "FIELD_HEADER_HV_USER_CONFIG_ENABLED": "Allow user-provided Vault configuration", + "SECTION_HEADER_HV_CONFIG": "OpenBao or Hashicorp Vault" }, - "CONNECTION_GROUP_ATTRIBUTES" : { - "ERROR_INVALID_HV_CONFIG_BLOB" : "The provided base64-encoded HV configuration blob is not valid.", - "FIELD_HEADER_HV_CONFIG" : "HV Service Configuration", - "SECTION_HEADER_HV_CONFIG" : "Hashicorp Vault" + "CONNECTION_GROUP_ATTRIBUTES": { + "FIELD_HEADER_HV_URI": "Vault Server URL", + "FIELD_HEADER_HV_TOKEN": "Vault Access Token or Sink File", + "FIELD_HEADER_HV_USERNAME": "Vault Username", + "FIELD_HEADER_HV_PASSWORD": "Vault Password", + "SECTION_HEADER_HV_CONFIG": "OpenBao or Hashicorp Vault" }, - "DATA_SOURCE_HASHICORP_VAULT" : { - "NAME" : "Hashicorp Vault" + "DATA_SOURCE_HASHICORP_VAULT": { + "NAME": "OpenBao or Hashicorp Vault" }, - "USER_ATTRIBUTES" : { - "FIELD_HEADER_HV_CONFIG" : "HV Service Configuration", - "SECTION_HEADER_HV_CONFIG" : "Hashicorp Vault" + "USER_ATTRIBUTES": { + "FIELD_HEADER_HV_URI": "Vault Server URL", + "FIELD_HEADER_HV_TOKEN": "Vault Access Token or Sink File", + "FIELD_HEADER_HV_USERNAME": "Vault Username", + "FIELD_HEADER_HV_PASSWORD": "Vault Password", + "SECTION_HEADER_HV_CONFIG": "OpenBao or Hashicorp Vault" } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/translations/es.json b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/translations/es.json new file mode 100644 index 0000000000..4a20060505 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/translations/es.json @@ -0,0 +1,28 @@ +{ + + "CONNECTION_ATTRIBUTES": { + "FIELD_HEADER_HV_USER_CONFIG_ENABLED": "Permitir configuración de Vault proporcionada por el usuario", + "SECTION_HEADER_HV_CONFIG": "OpenBao o Hashicorp Vault" + }, + + "CONNECTION_GROUP_ATTRIBUTES": { + "FIELD_HEADER_HV_URI": "URL del servidor Vault", + "FIELD_HEADER_HV_TOKEN": "Token de acceso Vault o archivo Sink", + "FIELD_HEADER_HV_USERNAME": "Nombre de usuario Vault", + "FIELD_HEADER_HV_PASSWORD": "Contraseña Vault", + "SECTION_HEADER_HV_CONFIG": "OpenBao o Hashicorp Vault" + }, + + "DATA_SOURCE_HASHICORP_VAULT": { + "NAME": "OpenBao o Hashicorp Vault" + }, + + "USER_ATTRIBUTES": { + "FIELD_HEADER_HV_URI": "URL del servidor Vault", + "FIELD_HEADER_HV_TOKEN": "Token de acceso Vault o archivo Sink", + "FIELD_HEADER_HV_USERNAME": "Nombre de usuario Vault", + "FIELD_HEADER_HV_PASSWORD": "Contraseña Vault", + "SECTION_HEADER_HV_CONFIG": "OpenBao o Hashicorp Vault" + } + +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/translations/fr.json b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/translations/fr.json new file mode 100644 index 0000000000..4bf87c03b8 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/resources/translations/fr.json @@ -0,0 +1,28 @@ +{ + + "CONNECTION_ATTRIBUTES": { + "FIELD_HEADER_HV_USER_CONFIG_ENABLED": "Autoriser la configuration Vault fournie par l'utilisateur", + "SECTION_HEADER_HV_CONFIG": "OpenBao ou Hashicorp Vault" + }, + + "CONNECTION_GROUP_ATTRIBUTES": { + "FIELD_HEADER_HV_URI": "URL du serveur Vault", + "FIELD_HEADER_HV_TOKEN": "Jeton d'accès Vault ou fichier Sink", + "FIELD_HEADER_HV_USERNAME": "Nom d'utilisateur Vault", + "FIELD_HEADER_HV_PASSWORD": "Mot de passe Vault", + "SECTION_HEADER_HV_CONFIG": "OpenBao ou Hashicorp Vault" + }, + + "DATA_SOURCE_HASHICORP_VAULT": { + "NAME": "OpenBao ou Hashicorp Vault" + }, + + "USER_ATTRIBUTES": { + "FIELD_HEADER_HV_URI": "URL du serveur Vault", + "FIELD_HEADER_HV_TOKEN": "Jeton d'accès Vault ou fichier Sink", + "FIELD_HEADER_HV_USERNAME": "Nom d'utilisateur Vault", + "FIELD_HEADER_HV_PASSWORD": "Mot de passe Vault", + "SECTION_HEADER_HV_CONFIG": "OpenBao ou Hashicorp Vault" + } + +} diff --git a/guacamole-ext/src/main/java/org/apache/guacamole/token/TokenFilter.java b/guacamole-ext/src/main/java/org/apache/guacamole/token/TokenFilter.java index 99eab585e0..58930ed553 100644 --- a/guacamole-ext/src/main/java/org/apache/guacamole/token/TokenFilter.java +++ b/guacamole-ext/src/main/java/org/apache/guacamole/token/TokenFilter.java @@ -46,7 +46,8 @@ public class TokenFilter { * escape character preceding the token, the name of the token, and the * entire token itself. */ - private final Pattern tokenPattern = Pattern.compile("(.*?)(^|.)(\\$\\{([A-Za-z0-9_]*)(\\:(.*))?\\})"); + private final Pattern tokenPattern = Pattern.compile("(.*?)(^|.)(\\$\\{((?:[^{}:]|:(?!(?:LOWER|UPPER|OPTIONAL)" + + "(?=\\}))|\\{(?:[^{}]|\\{[^{}]*\\})*\\})+)(:(?:(LOWER|UPPER|OPTIONAL))(?=\\}))?\\})"); /** * The index of the capturing group within tokenPattern which matches From fcd075d2bf935cce19913d1b5839c4742560b183 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Fri, 29 May 2026 10:10:17 +0200 Subject: [PATCH 04/27] GUACAMOLE-2137: Consistently used 'Function.identity()' in thenCompose statements. Remove unused GuacamoleExceptionSupplier --- .../vault/hv/GuacamoleExceptionSupplier.java | 47 ------------------- .../vault/hv/secret/HvSecretService.java | 4 +- 2 files changed, 2 insertions(+), 49 deletions(-) delete mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/GuacamoleExceptionSupplier.java diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/GuacamoleExceptionSupplier.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/GuacamoleExceptionSupplier.java deleted file mode 100644 index cb4f1a4f62..0000000000 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/GuacamoleExceptionSupplier.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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 org.apache.guacamole.vault.hv; - -import org.apache.guacamole.GuacamoleException; - -/** - * A class that is basically equivalent to the standard Supplier class in - * Java, except that the get() function can throw GuacamoleException, which - * is impossible with any of the standard Java lambda type classes, since - * none of them can handle checked exceptions - * - * @param - * The type of object which will be returned as a result of calling - * get(). - */ -public interface GuacamoleExceptionSupplier { - - /** - * Returns a value of the declared type. - * - * @return - * A value of the declared type. - * - * @throws GuacamoleException - * If an error occurs while attemping to calculate the return value. - */ - public T get() throws GuacamoleException; - -} \ No newline at end of file diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java index 59545d95bc..f667cb12c6 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java @@ -309,7 +309,7 @@ public Future getValue( UserContext userContext, return CompletableFuture.failedFuture(ex); }) - .thenCompose(f -> f) + .thenCompose(Function.identity()) .handle((value, ex) -> { if (ex == null) { return CompletableFuture.completedFuture(value); @@ -325,7 +325,7 @@ public Future getValue( UserContext userContext, return CompletableFuture.completedFuture(null); }) - .thenCompose(f -> f); + .thenCompose(Function.identity()); } /** From d3db450b1ce118ec19b1ffcdf4cce262c0db3b39 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Fri, 29 May 2026 10:48:45 +0200 Subject: [PATCH 05/27] GUACAMOLE-2137: Allow missing or incorrect application-wide vault --- .../vault/hv/GuacamoleExceptionSupplier.java | 47 ++++ .../guacamole/vault/hv/secret/HvClient.java | 4 +- .../vault/hv/secret/HvSecretService.java | 216 ++++++++++++------ 3 files changed, 195 insertions(+), 72 deletions(-) create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/GuacamoleExceptionSupplier.java diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/GuacamoleExceptionSupplier.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/GuacamoleExceptionSupplier.java new file mode 100644 index 0000000000..cb4f1a4f62 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/GuacamoleExceptionSupplier.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.guacamole.vault.hv; + +import org.apache.guacamole.GuacamoleException; + +/** + * A class that is basically equivalent to the standard Supplier class in + * Java, except that the get() function can throw GuacamoleException, which + * is impossible with any of the standard Java lambda type classes, since + * none of them can handle checked exceptions + * + * @param + * The type of object which will be returned as a result of calling + * get(). + */ +public interface GuacamoleExceptionSupplier { + + /** + * Returns a value of the declared type. + * + * @return + * A value of the declared type. + * + * @throws GuacamoleException + * If an error occurs while attemping to calculate the return value. + */ + public T get() throws GuacamoleException; + +} \ No newline at end of file diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java index 0ec6307706..e364054672 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java @@ -401,7 +401,7 @@ public CompletableFuture getSecret(String notation, String username, Str String type = getSecretsEngine(path).get("type").asText(); String mountPath = getSecretsEngine(path).get("path").asText(); String newpath = path.substring(mountPath.length()); - logger.debug("Vault {}, {}, {}, {}", type, mountPath, path, secret); + logger.debug("Vault {}, {}, {}, {}", type, mountPath, path, secret); JsonNode jsonNode; switch (type) { case "ssh": @@ -425,7 +425,7 @@ public CompletableFuture getSecret(String notation, String username, Str return jsonNode; } - + catch (Exception e) { logger.warn("Vault query failed for {} with {}", path, e.getMessage()); throw new CompletionException("Vault query failed for " + path, e); diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java index f667cb12c6..d373a206a4 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java @@ -105,10 +105,16 @@ public HvSecretService(HvConfigurationService confService, HvClientFactory hvCli // the per ConnectionGroup vaults, which MUST have a non expiring means of // authentication to avoid issues try { - HvClient client = getClient(confService.new VaultInfo(confService.getVaultUri(), + VaultInfo vaultInfo = confService.new VaultInfo(confService.getVaultUri(), confService.getVaultToken(), confService.getVaultUsername(), - confService.getVaultPassword())); + confService.getVaultPassword()); + if (isVaultInfoValid(vaultInfo)) { + HvClient client = getClient(confService.new VaultInfo(confService.getVaultUri(), + confService.getVaultToken(), + confService.getVaultUsername(), + confService.getVaultPassword())); + } } catch (GuacamoleException e) { logger.error("Can't initialize HvClient : {}", e.getMessage()); @@ -269,18 +275,22 @@ public Future getValue( UserContext userContext, config = new GuacamoleConfiguration(); } - // Create an application wide client + // Create an application wide client VaultInfo vaultInfo = confService.new VaultInfo(confService.getVaultUri(), confService.getVaultToken(), confService.getVaultUsername(), confService.getVaultPassword()); - HvClient client = getClient(vaultInfo); + HvClient client; + if (isVaultInfoValid(vaultInfo)) + client = getClient(vaultInfo); + else + client = null; // Create a connection group client HvClient connectionClient = getConnectionGroupHvClient(userContext, connectable); - - // Configure per user client if configured - HvClient userClient = getUserHvClient(userContext, connectable); + + // Configure per user client if configured + HvClient userClient = getUserHvClient(userContext, connectable); // Use a key including GUAC_USERNAME, to at least prevent a user stealing the // session of another due to timing issues. The ssh certificates of keys @@ -292,40 +302,71 @@ public Future getValue( UserContext userContext, userContext, config, new TokenFilter()); final String key = guac_username + "-" + username + "-" + finalName.substring(0, finalName.lastIndexOf('/')); - - return client.getSecret(finalName, username, key) - .handle((value, ex) -> { - if (ex == null) { - return CompletableFuture.completedFuture(value); + + if (client == null) { + if (connectionClient == null) { + if (userClient == null) { + return null; } + else { + return userClient.getSecret(finalName, username, key); + } + } + else { + return connectionClient.getSecret(finalName, username, key) + .handle((value, ex) -> { + if (ex == null) { + return CompletableFuture.completedFuture(value); + } - if (connectionClient != null) { - try { - return connectionClient.getSecret(finalName, username, key); - } catch (GuacamoleException e) { - return CompletableFuture.failedFuture(e); + if (userClient != null) { + try { + return userClient.getSecret(finalName, username, key); + } catch (GuacamoleException e) { + return CompletableFuture.failedFuture(e); + } + } + + return CompletableFuture.completedFuture(null); + }) + .thenCompose(Function.identity()); + } + } + else { + return client.getSecret(finalName, username, key) + .handle((value, ex) -> { + if (ex == null) { + return CompletableFuture.completedFuture(value); } - } - return CompletableFuture.failedFuture(ex); - }) - .thenCompose(Function.identity()) - .handle((value, ex) -> { - if (ex == null) { - return CompletableFuture.completedFuture(value); - } + if (connectionClient != null) { + try { + return connectionClient.getSecret(finalName, username, key); + } catch (GuacamoleException e) { + return CompletableFuture.failedFuture(e); + } + } - if (userClient != null) { - try { - return userClient.getSecret(finalName, username, key); - } catch (GuacamoleException e) { - return CompletableFuture.failedFuture(e); + return CompletableFuture.failedFuture(ex); + }) + .thenCompose(Function.identity()) + .handle((value, ex) -> { + if (ex == null) { + return CompletableFuture.completedFuture(value); } - } - return CompletableFuture.completedFuture(null); - }) - .thenCompose(Function.identity()); + if (userClient != null) { + try { + return userClient.getSecret(finalName, username, key); + } catch (GuacamoleException e) { + return CompletableFuture.failedFuture(e); + } + } + + return CompletableFuture.completedFuture(null); + }) + .thenCompose(Function.identity()); + } } /** @@ -358,7 +399,10 @@ public Future getValue(String name) throws GuacamoleException { confService.getVaultToken(), confService.getVaultUsername(), confService.getVaultPassword()); - return getClient(vaultInfo).getSecret(name, "", name.substring(0, name.lastIndexOf('/'))); + if (isVaultInfoValid(vaultInfo)) + return getClient(vaultInfo).getSecret(name, "", name.substring(0, name.lastIndexOf('/'))); + else + return null; } /** @@ -449,7 +493,7 @@ private HvClient getConnectionGroupHvClient(UserContext userContext, // If the current connection group has HV configuration attributes // set to a non-empty value, return immediately Map hvConfig = group.getAttributes(); - + if (hvConfig.get(HvAttributeService.HV_URI_ATTRIBUTE) == null) break; @@ -526,7 +570,7 @@ private boolean isHvUserConfigEnabled(Connectable connectable) { */ private HvClient getUserHvClient(UserContext userContext, Connectable connectable) throws GuacamoleException { - + // If user HV configs are enabled globally, and for the given connectable, // return the user-specific HV config, if one exists if (confService.getAllowUserConfig() && isHvUserConfigEnabled(connectable)) { @@ -534,11 +578,11 @@ private HvClient getUserHvClient(UserContext userContext, // Get the underlying user, to avoid the KSM config sanitization User self = (((HvDirectory) userContext.getUserDirectory()) .getUnderlyingDirectory().get(userContext.self().getIdentifier())); - + // If the current user has HV configuration attributes // set to a non-empty value, return immediately Map hvConfig = self.getAttributes(); - + if (hvConfig.get(HvAttributeService.HV_URI_ATTRIBUTE) != null) { VaultInfo vaultInfo = confService.new VaultInfo( URI.create(hvConfig.get(HvAttributeService.HV_URI_ATTRIBUTE)), @@ -549,7 +593,7 @@ private HvClient getUserHvClient(UserContext userContext, if (isVaultInfoValid(vaultInfo)) { logger.debug("Using User Vault configuration"); - return getClient(vaultInfo); + return getClient(vaultInfo); } } } @@ -598,7 +642,7 @@ public Map> getTokens(UserContext userContext, Map> tokens = new HashMap<>(); Map parameters = config.getParameters(); - // Create an application wide client + // Create an application wide client VaultInfo vaultInfo = confService.new VaultInfo(confService.getVaultUri(), confService.getVaultToken(), confService.getVaultUsername(), @@ -607,9 +651,9 @@ public Map> getTokens(UserContext userContext, // Create a connection group client HvClient connectionClient = getConnectionGroupHvClient(userContext, connectable); - - // Configure per user client if configured - HvClient userClient = getUserHvClient(userContext, connectable); + + // Configure per user client if configured + HvClient userClient = getUserHvClient(userContext, connectable); // Remove optional token parameter modifier and match only our own tokens Pattern tokenPattern = Pattern.compile("\\$\\{(" + client.VAULT_TOKEN_PREFIX + @@ -629,39 +673,71 @@ public Map> getTokens(UserContext userContext, while (tokenMatcher.find()) { String notation = tokenMatcher.group(1); String finalName = prepareToken(notation, userContext, config, filter); - tokens.put(notation, client.getSecret(finalName, username, key) - .handle((value, ex) -> { - if (ex == null) { - return CompletableFuture.completedFuture(value); + + if (client == null) { + if (connectionClient == null) { + if (userClient == null) { + tokens.put(notation, null); } + else { + tokens.put(notation, userClient.getSecret(finalName, username, key)); + } + } + else { + tokens.put(notation, connectionClient.getSecret(finalName, username, key) + .handle((value, ex) -> { + if (ex == null) { + return CompletableFuture.completedFuture(value); + } + + if (userClient != null) { + try { + return userClient.getSecret(finalName, username, key); + } catch (GuacamoleException e) { + return CompletableFuture.failedFuture(e); + } + } + + return CompletableFuture.completedFuture(null); + }) + .thenCompose(Function.identity())); + } + } + else { + tokens.put(notation, client.getSecret(finalName, username, key) + .handle((value, ex) -> { + if (ex == null) { + return CompletableFuture.completedFuture(value); + } - if (connectionClient != null) { - try { - return connectionClient.getSecret(finalName, username, key); - } catch (GuacamoleException e) { - return CompletableFuture.failedFuture(e); + if (connectionClient != null) { + try { + return connectionClient.getSecret(finalName, username, key); + } catch (GuacamoleException e) { + return CompletableFuture.failedFuture(e); + } } - } - return CompletableFuture.failedFuture(ex); - }) - .thenCompose(Function.identity()) - .handle((value, ex) -> { - if (ex == null) { - return CompletableFuture.completedFuture(value); - } + return CompletableFuture.failedFuture(ex); + }) + .thenCompose(Function.identity()) + .handle((value, ex) -> { + if (ex == null) { + return CompletableFuture.completedFuture(value); + } - if (userClient != null) { - try { - return userClient.getSecret(finalName, username, key); - } catch (GuacamoleException e) { - return CompletableFuture.failedFuture(e); + if (userClient != null) { + try { + return userClient.getSecret(finalName, username, key); + } catch (GuacamoleException e) { + return CompletableFuture.failedFuture(e); + } } - } - return CompletableFuture.completedFuture(null); - }) - .thenCompose(Function.identity())); + return CompletableFuture.completedFuture(null); + }) + .thenCompose(Function.identity())); + } } } From 2ca5bed48fd53675dd15a1e379d95d552dd3aebf Mon Sep 17 00:00:00 2001 From: David Bateman Date: Mon, 1 Jun 2026 16:30:00 +0200 Subject: [PATCH 06/27] GUACAMOLE-2137: Re-remove GuacamoleExceptionSupplier accidentially reintrodcude in last commit --- .../vault/hv/GuacamoleExceptionSupplier.java | 47 ------------------- .../guacamole/vault/hv/secret/HvClient.java | 1 - .../vault/hv/secret/HvSecretService.java | 1 - 3 files changed, 49 deletions(-) delete mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/GuacamoleExceptionSupplier.java diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/GuacamoleExceptionSupplier.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/GuacamoleExceptionSupplier.java deleted file mode 100644 index cb4f1a4f62..0000000000 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/GuacamoleExceptionSupplier.java +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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 org.apache.guacamole.vault.hv; - -import org.apache.guacamole.GuacamoleException; - -/** - * A class that is basically equivalent to the standard Supplier class in - * Java, except that the get() function can throw GuacamoleException, which - * is impossible with any of the standard Java lambda type classes, since - * none of them can handle checked exceptions - * - * @param - * The type of object which will be returned as a result of calling - * get(). - */ -public interface GuacamoleExceptionSupplier { - - /** - * Returns a value of the declared type. - * - * @return - * A value of the declared type. - * - * @throws GuacamoleException - * If an error occurs while attemping to calculate the return value. - */ - public T get() throws GuacamoleException; - -} \ No newline at end of file diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java index e364054672..be350a404d 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java @@ -45,7 +45,6 @@ import java.util.concurrent.Future; import javax.annotation.Nullable; import org.apache.guacamole.GuacamoleException; -import org.apache.guacamole.vault.hv.GuacamoleExceptionSupplier; import org.apache.guacamole.vault.hv.conf.HvConfigurationService.VaultInfo; import org.apache.guacamole.vault.hv.vault.FileTokenAuthentication; import org.apache.guacamole.vault.hv.vault.UsernamePasswordAuthentication; diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java index d373a206a4..cf7d99ae4c 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java @@ -51,7 +51,6 @@ import org.apache.guacamole.net.event.TunnelConnectEvent; import org.apache.guacamole.protocol.GuacamoleConfiguration; import org.apache.guacamole.token.TokenFilter; -import org.apache.guacamole.vault.hv.GuacamoleExceptionSupplier; import org.apache.guacamole.vault.hv.conf.HvAttributeService; import org.apache.guacamole.vault.hv.conf.HvConfigurationService; import org.apache.guacamole.vault.hv.conf.HvConfigurationService.VaultInfo; From f82680ba0d2a6f4ae54527f3a4ef29c96631a3c0 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Mon, 1 Jun 2026 17:04:14 +0200 Subject: [PATCH 07/27] GUACAMOLE-2137: Inverse of logic in VaultInfo.eqauls method --- .../guacamole/vault/hv/conf/HvConfigurationService.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java index 1f212f454a..7b38d04f3e 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java @@ -487,12 +487,12 @@ public boolean equals(Object o) { VaultInfo that = (VaultInfo) o; // If Token non null it is used for the connection if (Token == null || Token.trim().isEmpty()) - return Objects.equals(Uri, that.Uri) && - Objects.equals(Token, that.Token); - else return Objects.equals(Uri, that.Uri) && Objects.equals(Username, that.Username) && Objects.equals(Password, that.Password); + else + return Objects.equals(Uri, that.Uri) && + Objects.equals(Token, that.Token); } @Override From 1c1e832935e3e75f5a60398aeab78bf54b4a4eee Mon Sep 17 00:00:00 2001 From: David Bateman Date: Mon, 1 Jun 2026 17:09:05 +0200 Subject: [PATCH 08/27] GUACAMOLE-2137: VaultInfo.equals and VaultInfo.hashcode must use the same logic for safe use as a HashMap key --- .../guacamole/vault/hv/conf/HvConfigurationService.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java index 7b38d04f3e..0593392173 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java @@ -498,7 +498,10 @@ public boolean equals(Object o) { @Override public int hashCode() { // Only hash the values that can be different - return Objects.hash(Uri, Token, Username, Password); + if (Token == null || Token.trim().isEmpty()) + return Objects.hash(Uri, Username, Password); + else + return Objects.hash(Uri, Token); } } } From f66b1796d9bbddca59095750574d168758be2b48 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Tue, 2 Jun 2026 11:54:30 +0200 Subject: [PATCH 09/27] GUACAMOLE-2137: Add a timeout to the Future returned by getSecret, so that the Future isn't invalidated in the cache before its returned --- .../java/org/apache/guacamole/vault/hv/secret/HvClient.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java index be350a404d..e76f752254 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java @@ -43,6 +43,7 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import javax.annotation.Nullable; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.vault.hv.conf.HvConfigurationService.VaultInfo; @@ -429,7 +430,8 @@ public CompletableFuture getSecret(String notation, String username, Str logger.warn("Vault query failed for {} with {}", path, e.getMessage()); throw new CompletionException("Vault query failed for " + path, e); } - }); + }) + .orTimeout((long) vaultInfo.CacheLifetime, TimeUnit.MILLISECONDS); }); return futureResponse.whenComplete((jsonNode, ex) -> { From f7df2af550ff9721b7e4a444c6cd7072b6ef4973 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Tue, 2 Jun 2026 13:34:11 +0200 Subject: [PATCH 10/27] GUACAMOLE-2137: Refuse to accept token sink files that are world readable or not regular files --- .../hv/vault/FileTokenAuthentication.java | 20 ++++++++++++++++--- .../hv/vault/TtlAwareSessionManager.java | 4 ++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/FileTokenAuthentication.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/FileTokenAuthentication.java index ac74c16aa3..7033245821 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/FileTokenAuthentication.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/FileTokenAuthentication.java @@ -22,6 +22,7 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; import org.springframework.vault.VaultException; import org.springframework.vault.authentication.ClientAuthentication; import org.springframework.vault.support.VaultToken; @@ -53,14 +54,27 @@ public FileTokenAuthentication(String tokenPath) { */ @Override public VaultToken login() { - String token; + String token; try { + if (Files.isSymbolicLink(tokenPath)) { + throw new VaultException("Refusing to use symbolic link for token sink file: " + tokenPath); + } + + if (!Files.isRegularFile(tokenPath)) { + throw new VaultException("Token sink path must be a regular file: " + tokenPath); + } + + // I allow group readable but maybe I shouldn't + if (Files.isReadable(tokenPath) && + Files.getPosixFilePermissions(tokenPath).contains(PosixFilePermission.OTHERS_READ)) { + throw new VaultException("Refusing to use a world readable token sink file : " + tokenPath); + } + token = Files.readString(tokenPath).trim(); } catch (IOException e) { // This might be recoverable. So throw a VautException - throw new VaultException( - "Cannot read Vault token sink: " + tokenPath, e); + throw new VaultException("Cannot read Vault token sink: " + tokenPath, e); } return VaultToken.of(token); diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/TtlAwareSessionManager.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/TtlAwareSessionManager.java index d6b83a033a..e8e914d659 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/TtlAwareSessionManager.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/TtlAwareSessionManager.java @@ -315,7 +315,7 @@ private void renewTokenAsync() { tokenInfo = getTokenInfo(currentToken); } catch (VaultException e) { - logger.debug("Token lookup fail, attempting re-authentication"); + logger.debug("Token lookup fail, attempting re-authentication : " + e.getMessage()); attemptLogin(); return; } @@ -359,7 +359,7 @@ private void renewTokenAsync() { tokenInfo = getTokenInfo(newToken); } catch (VaultException e) { - logger.debug("Token lookup fail, attempting re-authentication"); + logger.debug("Token lookup fail, attempting re-authentication : " + e.getMessage()); attemptLogin(); return; } From c661175268df61362929eeaea35aa0d84153f760 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Fri, 5 Jun 2026 17:56:21 +0200 Subject: [PATCH 11/27] GUACAMOLE-2137: Refactor code following code audit by SAST/Linter PMD - Use final on all local and method arguments where possible - Comment all public methods and constructor - Catch only specific exceptions - Remove unused imports - Remove code that can no longer be used - No nested if block more than 2 levels deep - Prefer getter functions to direct access to class variables - Don't throw and catch exception in the same function, the the catch blocks closer to where the exceptions are thrown - Split HvClientProvider out from HvSecretService to reduce complexity and increase readibility of both classes - Create helper function for HvSecretService.prepareToken to regroup similar logic and reduce CognitiveComplexity of prepareToken - Split HvClient fallback logic into a seperate function, use an ordered List of HvClients in this function to simplify the logic --- .../hv/HvAuthenticationProviderModule.java | 17 +- .../vault/hv/conf/HvAttributeService.java | 41 +- .../vault/hv/conf/HvConfigurationService.java | 323 ++++++--- .../guacamole/vault/hv/secret/HvClient.java | 321 +++++---- .../vault/hv/secret/HvClientFactory.java | 45 -- .../vault/hv/secret/HvClientProvider.java | 376 ++++++++++ .../vault/hv/secret/HvSecretService.java | 663 ++++-------------- .../guacamole/vault/hv/secret/HvSshKeys.java | 75 +- .../hv/secret/HvTunnelEventListener.java | 16 +- .../guacamole/vault/hv/user/HvConnection.java | 6 +- .../vault/hv/user/HvConnectionGroup.java | 23 +- .../guacamole/vault/hv/user/HvDirectory.java | 14 +- .../vault/hv/user/HvDirectoryService.java | 69 +- .../guacamole/vault/hv/user/HvUser.java | 56 +- .../vault/hv/user/HvUserFactory.java | 40 -- .../hv/vault/FileTokenAuthentication.java | 38 +- .../hv/vault/TtlAwareSessionManager.java | 334 +++++---- .../vault/UsernamePasswordAuthentication.java | 36 +- ...UsernamePasswordAuthenticationOptions.java | 92 ++- 19 files changed, 1346 insertions(+), 1239 deletions(-) delete mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientFactory.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientProvider.java delete mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUserFactory.java diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProviderModule.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProviderModule.java index b539039408..cef828d3f7 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProviderModule.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProviderModule.java @@ -27,13 +27,11 @@ import org.apache.guacamole.vault.hv.conf.HvAttributeService; import org.apache.guacamole.vault.hv.conf.HvConfigurationService; import org.apache.guacamole.vault.hv.secret.HvClient; -import org.apache.guacamole.vault.hv.secret.HvClientFactory; +import org.apache.guacamole.vault.hv.secret.HvClientProvider; import org.apache.guacamole.vault.hv.secret.HvSecretService; import org.apache.guacamole.vault.hv.secret.HvTunnelEventListener; -import org.apache.guacamole.vault.hv.user.HvConnectionGroup; import org.apache.guacamole.vault.hv.user.HvDirectoryService; import org.apache.guacamole.vault.hv.user.HvUser; -import org.apache.guacamole.vault.hv.user.HvUserFactory; import org.apache.guacamole.vault.secret.VaultSecretService; import org.apache.guacamole.vault.user.VaultDirectoryService; @@ -52,8 +50,15 @@ public class HvAuthenticationProviderModule * @throws GuacamoleException * If configuration details in guacamole.properties cannot be parsed. */ - public HvAuthenticationProviderModule() throws GuacamoleException { } + public HvAuthenticationProviderModule() throws GuacamoleException { + // This constructor is intentionally empty + super(); + } + /** + * Configures injections for interfaces which are implementation-specific + * to the vault service in use. + */ @Override protected void configureVault() { @@ -67,12 +72,12 @@ protected void configureVault() { // Bind factory for creating HV Clients install(new FactoryModuleBuilder() .implement(HvClient.class, HvClient.class) - .build(HvClientFactory.class)); + .build(HvClientProvider.HvClientFactory.class)); // Bind factory for creating HvUsers install(new FactoryModuleBuilder() .implement(HvUser.class, HvUser.class) - .build(HvUserFactory.class)); + .build(HvUser.HvUserFactory.class)); // Static Injection of the Listener to get TunnelConnectEvent and TunnelCloseEvent requestStaticInjection(HvTunnelEventListener.class); diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvAttributeService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvAttributeService.java index 70cbd3ffd6..39f14d91c2 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvAttributeService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvAttributeService.java @@ -20,21 +20,20 @@ package org.apache.guacamole.vault.hv.conf; import com.google.inject.Singleton; -import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.Collection; import java.util.Collections; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.form.BooleanField; import org.apache.guacamole.form.Form; import org.apache.guacamole.form.TextField; import org.apache.guacamole.form.PasswordField; -import org.apache.guacamole.language.TranslatableGuacamoleClientException; import org.apache.guacamole.vault.conf.VaultAttributeService; +/** + * A service that exposes attributes for the admin UI, Four attributes are + * exposed: hv-uri, hv-token, hv-username and hv-password + * + */ @Singleton public class HvAttributeService implements VaultAttributeService { @@ -62,15 +61,6 @@ public class HvAttributeService implements VaultAttributeService { */ public static final String HV_PASSWORD_ATTRIBUTE = "hv-password"; - /** - * The HV configuration attribute contains sensitive information, so it - * should not be exposed through the directory. Instead, if a value is - * set on the attributes of an object, the following value will be exposed - * in its place, and correspondingly the underlying value will not be - * changed if this value is provided to an update call. - */ - public static final String HV_ATTRIBUTE_PLACEHOLDER_VALUE = "**********"; - /** * All attributes related to configuring the HV vault on a * per-connection-group or per-user basis. @@ -133,25 +123,4 @@ public Collection getUserPreferenceAttributes() { public Collection getConnectionGroupAttributes() { return HV_ATTRIBUTES; } - - public static Map processAttributes( - Map attributes) throws GuacamoleException { - attributes = new HashMap<>(attributes); - - // If the placeholder value was provided, do not update the attribute - String hvTokenValue = attributes.get(HvAttributeService.HV_TOKEN_ATTRIBUTE); - if (HvAttributeService.HV_ATTRIBUTE_PLACEHOLDER_VALUE.equals(hvTokenValue)) { - // Remove the attribute from the map so it won't be updated - attributes.remove(HvAttributeService.HV_TOKEN_ATTRIBUTE); - } - - // If the placeholder value was provided, do not update the attribute - String hvPasswordValue = attributes.get(HvAttributeService.HV_PASSWORD_ATTRIBUTE); - if (HvAttributeService.HV_ATTRIBUTE_PLACEHOLDER_VALUE.equals(hvPasswordValue)) { - // Remove the attribute from the map so it won't be updated - attributes.remove(HvAttributeService.HV_PASSWORD_ATTRIBUTE); - } - - return attributes; - } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java index 0593392173..1ee9849d6c 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java @@ -19,28 +19,25 @@ package org.apache.guacamole.vault.hv.conf; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import com.google.inject.Inject; import com.google.inject.Singleton; -import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.net.URI; -import java.util.HashMap; -import java.util.Map; import java.util.Objects; -import javax.annotation.Nonnull; import org.apache.guacamole.GuacamoleException; -import org.apache.guacamole.GuacamoleServerException; import org.apache.guacamole.environment.Environment; import org.apache.guacamole.properties.BooleanGuacamoleProperty; import org.apache.guacamole.properties.IntegerGuacamoleProperty; import org.apache.guacamole.properties.StringGuacamoleProperty; import org.apache.guacamole.properties.URIGuacamoleProperty; import org.apache.guacamole.vault.conf.VaultConfigurationService; +import org.apache.guacamole.vault.hv.secret.HvSshKeys; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** + * Service class to retrieve configuration information for an OpenBao + * or Hashicorp Vault. + */ @Singleton public class HvConfigurationService extends VaultConfigurationService { @@ -62,7 +59,7 @@ public class HvConfigurationService extends VaultConfigurationService { /** * The default connection timeout in milliseconds. */ - public static final int DEFAULT_CONNECTION_TIMEOUT = 10000; + public static final int DEFAULT_CONNECTION_TIMEOUT = 10_000; /** * The default ssh connection tiemout in seconds. @@ -73,12 +70,12 @@ public class HvConfigurationService extends VaultConfigurationService { * The default vault token renewal delay in milliseconds. Expiring * tokens will be renewed at least this delay before expiration */ - public static final int DEFAULT_TOKEN_RENEWAL_DELAY = 10000; + public static final int DEFAULT_TOKEN_RENEWAL_DELAY = 10_000; /** * The default ssh certificate type. */ - public static final String DEFAULT_SSH_TYPE = "ed25519"; + public static final String DEFAULT_SSH_TYPE = HvSshKeys.ED25519; /** * The default of whether to accept user configurations or not. @@ -282,6 +279,7 @@ public String getVaultUsername() throws GuacamoleException { public String getVaultPassword() throws GuacamoleException { return environment.getProperty(VAULT_PASSWORD); } + /** * The maximum time that the cached data is considered valid in * milliseconds. @@ -349,8 +347,8 @@ public int getTokenRenewalDelay() throws GuacamoleException { * If guacamole.properties can not be parsed. */ public String getSshType() throws GuacamoleException { - String type = environment.getProperty(VAULT_SSH_TYPE, DEFAULT_SSH_TYPE); - if (! type.equals("rsa") & ! type.equals("ed25519")) { + final String type = environment.getProperty(VAULT_SSH_TYPE, DEFAULT_SSH_TYPE); + if (! HvSshKeys.RSA.equals(type) & ! HvSshKeys.ED25519.equals(type)) { throw new GuacamoleException("Only ssh certificate types 'rsa' (4096-bit) and 'ed25519' are supported"); } return type; @@ -383,7 +381,7 @@ public int getSshConnectionTimeout() throws GuacamoleException { * If the value specified within guacamole.properties cannot be * parsed. */ - public boolean getAllowUserConfig() throws GuacamoleException { + public boolean allowUserConfig() throws GuacamoleException { return environment.getProperty(VAULT_ALLOW_USER_CONFIG, DEFAULT_ALLOW_USER_CONFIG); } @@ -398,110 +396,227 @@ public boolean getMatchUserRecordsByDomain() throws GuacamoleException { // Not needed for Hashicorp/OpenBao - return false return false; } - + /** * Class containing the configuration associated with a client instance */ public class VaultInfo { + + /** + * Stores value of vault specific 'vault-uri' + */ + private final URI uri; + + /** + * Stores value of vault specific 'vault-token' + */ + private final String token; + + /** + * Stores value of vault specific 'vault-username' + */ + private final String username; + + /** + * Stores value of vault specific 'vault-password' + */ + public final String password; + + /** + * Construct a class containing Vault specific configuration options. + */ + public VaultInfo(final URI uri, final String token, final String username, final String password) { + this.uri = uri; + this.token = token; + this.username = username; + this.password = password; + } - public final URI Uri; - public final String Token; - public final String Username; - public final String Password; - public final int ConnectionTimeout; - public final int RequestTimeout; - public final int TokenRenewalDelay; - public final int CacheLifetime; - public final String SshType; - public final int SshConnectionTimeout; - - public VaultInfo(URI Uri, - String Token, - String Username, - String Password) { - this.Uri = Uri; - this.Token = Token; - this.Username = Username; - this.Password = Password; - - int ConnectionTimeout; - try { - ConnectionTimeout = getConnectionTimeout(); - } - catch (GuacamoleException e) { - logger.warn("Error parsing ConnectTimeout, using default: {}", e.getMessage()); - ConnectionTimeout = DEFAULT_CONNECTION_TIMEOUT; - } - this.ConnectionTimeout = ConnectionTimeout; - int RequestTimeout; - try { - RequestTimeout = getRequestTimeout(); - } - catch (GuacamoleException e) { - logger.warn("Error parsing RequestTimeout, using default: {}", e.getMessage()); - RequestTimeout = DEFAULT_REQUEST_TIMEOUT; - } - this.RequestTimeout = RequestTimeout; - int TokenRenewalDelay; - try { - TokenRenewalDelay = getTokenRenewalDelay(); - } - catch (GuacamoleException e) { - logger.warn("Error parsing TokenRenewalDelay, using default: {}", e.getMessage()); - TokenRenewalDelay = DEFAULT_TOKEN_RENEWAL_DELAY; - } - this.TokenRenewalDelay = TokenRenewalDelay; - int CacheLifetime; - try { - CacheLifetime = getVaultCacheLifetime(); - } - catch (GuacamoleException e) { - logger.warn("Error parsing CacheLifetime, using default: {}", e.getMessage()); - CacheLifetime = DEFAULT_CACHE_LIFETIME; - } - this.CacheLifetime = CacheLifetime; - String SshType; - try { - SshType = getSshType(); - } - catch (GuacamoleException e) { - logger.warn("Error parsing SshType, using default: {}", e.getMessage()); - SshType = DEFAULT_SSH_TYPE; - } - this.SshType = SshType; - int SshConnectionTimeout; - try { - SshConnectionTimeout = getSshConnectionTimeout(); - } - catch (GuacamoleException e) { - logger.warn("Error parsing SshType, using default: {}", e.getMessage()); - SshConnectionTimeout = DEFAULT_SSH_CONNECTION_TIMEOUT; - } - this.SshConnectionTimeout = SshConnectionTimeout; + /** + * The URI of the hashicorp or OpenBao vault to use. + * + * @return + * The Hashicorp or OpenBao server URI (e.g., "http://localhost:8200"). + * + * @throws GuacamoleException + * If the property is not defined in guacamole.properties or + * guacamole.properties can not be parsed. + */ + public URI getVaultUri() throws GuacamoleException { + return uri; + } + + /** + * The authentication token to use to access the vault. + * + * @return + * The Hashicorp or OpenBao authentication token. + * + * @throws GuacamoleException + * If the property is not defined in guacamole.properties or + * guacamole.properties can not be parsed. + */ + public String getVaultToken() throws GuacamoleException { + return token; + } + + /** + * The authentication Username to use to access the vault. + * + * @return + * The Hashicorp or OpenBao authentication Username + * + * @throws GuacamoleException + * If the property is not defined in guacamole.properties or + * guacamole.properties can not be parsed. + */ + public String getVaultUsername() throws GuacamoleException { + return username; + } + + /** + * The authentication Password to use to access the vault. + * + * @return String + * The Hashicorp or OpenBao authentication Password + * + * @throws GuacamoleException + * If the property is not defined in guacamole.properties or + * guacamole.properties can not be parsed. + */ + public String getVaultPassword() throws GuacamoleException { + return password; + } + + /** + * The maximum time that the cached data is considered valid in + * milliseconds. + * + * @return + * The cache lifetime in milliseconds. + * + * @throws GuacamoleException + * If guacamole.properties can not be parsed. + */ + public int getVaultCacheLifetime() throws GuacamoleException { + return HvConfigurationService.this.getVaultCacheLifetime(); + } + + /** + * The maximum time that a request to the vault server can take in + * milliseconds. + * + * @return + * The request timeout in milliseconds. + * + * @throws GuacamoleException + * If guacamole.properties can not be parsed. + */ + public int getRequestTimeout() throws GuacamoleException { + return HvConfigurationService.this.getRequestTimeout(); + } + + /** + * The maximum time that a connection to the vault server can take in + * milliseconds. + * + * @return + * The connection timeout in milliseconds. + * + * @throws GuacamoleException + * If guacamole.properties can not be parsed. + */ + public int getConnectionTimeout() throws GuacamoleException { + return HvConfigurationService.this.getConnectionTimeout(); + } + + /** + * The renewal delay, in milliseconds of expiring token. A token will be renewed + * prior to it expiration by this delay + * + * @return + * The renewal delay in milliseconds. + * + * @throws GuacamoleException + * If guacamole.properties can not be parsed. + */ + public int getTokenRenewalDelay() throws GuacamoleException { + return HvConfigurationService.this.getTokenRenewalDelay(); + } + + /** + * The type of SSH certificates are will be generated. Must be either + * 'rsa' for 4096-bit RSA keys or 'ed25519'. + * + * @return + * The ssh type to use. + * + * @throws GuacamoleException + * If guacamole.properties can not be parsed. + */ + public String getSshType() throws GuacamoleException { + return HvConfigurationService.this.getSshType(); + } + + /** + * The maximum time that a signed SSH certificate is considered valid in + * milliseconds. + * + * @return + * The ssh connection timeout in milliseconds. + * + * @throws GuacamoleException + * If guacamole.properties can not be parsed. + */ + public int getSshConnectionTimeout() throws GuacamoleException { + return HvConfigurationService.this.getSshConnectionTimeout(); } @Override - public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - VaultInfo that = (VaultInfo) o; + public boolean equals(final Object object) { + if (this == object) { + return true; + } + if (object == null || getClass() != object.getClass()) { + return false; + } + + final VaultInfo that = (VaultInfo) object; // If Token non null it is used for the connection - if (Token == null || Token.trim().isEmpty()) - return Objects.equals(Uri, that.Uri) && - Objects.equals(Username, that.Username) && - Objects.equals(Password, that.Password); - else - return Objects.equals(Uri, that.Uri) && - Objects.equals(Token, that.Token); + if (token == null || token.isBlank()) { + return Objects.equals(uri, that.uri) && + Objects.equals(username, that.username) && + Objects.equals(password, that.password); + } + else { + return Objects.equals(uri, that.uri) && + Objects.equals(token, that.token); + } } @Override public int hashCode() { // Only hash the values that can be different - if (Token == null || Token.trim().isEmpty()) - return Objects.hash(Uri, Username, Password); - else - return Objects.hash(Uri, Token); + if (token == null || token.isBlank()) { + return Objects.hash(uri, username, password); + } + else { + return Objects.hash(uri, token); + } + } + + /** + * Returns true if the VaultInfo configuration seems valid + * + * @return + * True is the value in non null, URI is set and at least one of + * token or username/password id set + */ + public boolean isVaultInfoInvalid() { + return uri == null || uri.toString().isBlank() || + ((token == null || token.isBlank()) && + (username == null || username.isBlank() || + password == null || password.isBlank())); } } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java index e76f752254..73aafceb2a 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java @@ -21,28 +21,20 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.core.JsonProcessingException; import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; -import com.github.benmanes.caffeine.cache.RemovalCause; -import com.google.inject.Inject; import com.google.inject.assistedinject.Assisted; import com.google.inject.assistedinject.AssistedInject; -import java.io.IOException; -import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.Duration; import java.time.Instant; -import java.util.HashMap; import java.util.Map; -import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; -import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import javax.annotation.Nullable; import org.apache.guacamole.GuacamoleException; @@ -74,17 +66,17 @@ public class HvClient { * Name of the Guacamole token to resolve on a Hashicorp Vault (secret path * is set in the token modifier). */ - static final String VAULT_TOKEN_PREFIX = "vault://"; + public static final String VAULT_TOKEN_PREFIX = "vault://"; /** * The path prefix for the path-help REST API in the Vault */ - static final String VAULT_PATH_HELP = "/sys/internal/ui/mounts/"; + private static final String VAULT_PATH_HELP = "/sys/internal/ui/mounts/"; /** * Name temporary entry in the ldap sessions for session being cosntructed */ - static final String VAULT_LDAP_SESSION = "checkin"; + public static final String VAULT_LDAP_SESSION = "checkin"; /** * Logger for this class. @@ -115,7 +107,7 @@ public class HvClient { /** * A HashMap of the checked out LDAP Sessions */ - private final Map ldapSessions = new HashMap<>(); + private final Map ldapSessions = new ConcurrentHashMap<>(); /** * The HV configuration associated with this client instance. @@ -130,35 +122,35 @@ public class HvClient { * The HV configuration to use when retrieving properties from HV. */ @AssistedInject - public HvClient(@Assisted VaultInfo vaultInfo) { + public HvClient(@Assisted final VaultInfo vaultInfo) throws GuacamoleException { this.vaultInfo = vaultInfo; this.objectMapper = new ObjectMapper(); - VaultEndpoint endpoint = VaultEndpoint.from(vaultInfo.Uri.resolve("v1")); + final VaultEndpoint endpoint = VaultEndpoint.from(vaultInfo.getVaultUri().resolve("v1")); - SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); - requestFactory.setConnectTimeout(vaultInfo.ConnectionTimeout); - requestFactory.setReadTimeout(vaultInfo.RequestTimeout); + final SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(vaultInfo.getConnectionTimeout()); + requestFactory.setReadTimeout(vaultInfo.getRequestTimeout()); - RestTemplate restTemplate = new RestTemplate(requestFactory); + final RestTemplate restTemplate = new RestTemplate(requestFactory); restTemplate.setUriTemplateHandler(new DefaultUriBuilderFactory( - vaultInfo.Uri.resolve("v1").toString())); - ClientAuthentication authentication; + vaultInfo.getVaultUri().resolve("v1").toString())); + final ClientAuthentication authentication; - if (vaultInfo.Token != null) { - if (isTokenReadableFile(vaultInfo.Token)) { + if (vaultInfo.getVaultToken() != null) { + if (isTokenReadableFile(vaultInfo.getVaultToken())) { authentication = - new FileTokenAuthentication(vaultInfo.Token); + new FileTokenAuthentication(vaultInfo.getVaultToken()); } else { - authentication = new TokenAuthentication(vaultInfo.Token); + authentication = new TokenAuthentication(vaultInfo.getVaultToken()); } } - else if (vaultInfo.Username != null && vaultInfo.Password != null) { - UsernamePasswordAuthenticationOptions options = + else if (vaultInfo.getVaultUsername() != null && vaultInfo.getVaultPassword() != null) { + final UsernamePasswordAuthenticationOptions options = UsernamePasswordAuthenticationOptions.builder() - .username(vaultInfo.Username) - .password(vaultInfo.Password) + .username(vaultInfo.getVaultUsername()) + .password(vaultInfo.getVaultPassword()) .build(); authentication = @@ -170,14 +162,14 @@ else if (vaultInfo.Username != null && vaultInfo.Password != null) { } // Create a task scheduler for our token renewal - ThreadPoolTaskScheduler taskscheduler = new ThreadPoolTaskScheduler(); + final ThreadPoolTaskScheduler taskscheduler = new ThreadPoolTaskScheduler(); taskscheduler.setPoolSize(1); taskscheduler.setThreadNamePrefix("vault-renewal-"); taskscheduler.initialize(); // Session manager to automatically renew tokens before expiration - TtlAwareSessionManager sessionManager = new TtlAwareSessionManager(authentication, - restTemplate, taskscheduler, vaultInfo.TokenRenewalDelay); + final TtlAwareSessionManager sessionManager = new TtlAwareSessionManager(authentication, + restTemplate, taskscheduler, vaultInfo.getTokenRenewalDelay()); this.vaultTemplate = new VaultTemplate(endpoint, requestFactory, sessionManager); @@ -192,9 +184,10 @@ else if (vaultInfo.Username != null && vaultInfo.Password != null) { // values in memory.. A VaultConverter function could deal with the // spring-vault-core part of the problem, but not Gaucamole. logger.debug("Initialize Cache with expiry of {}, {} seconds", - vaultInfo.CacheLifetime, Duration.ofMillis(vaultInfo.CacheLifetime).toSeconds()); + vaultInfo.getVaultCacheLifetime(), + Duration.ofMillis(vaultInfo.getVaultCacheLifetime()).toSeconds()); this.cache = Caffeine.newBuilder() - .expireAfterWrite(Duration.ofMillis(vaultInfo.CacheLifetime)) + .expireAfterWrite(Duration.ofMillis(vaultInfo.getVaultCacheLifetime())) .maximumSize(1_000_000) .build(); } @@ -209,12 +202,13 @@ else if (vaultInfo.Username != null && vaultInfo.Password != null) { * @return * True is the token is a readable file */ - private static boolean isTokenReadableFile(String token) { + private static boolean isTokenReadableFile(final String token) { try { - Path path = Paths.get(token); + final Path path = Paths.get(token); return Files.isRegularFile(path) && Files.isReadable(path); } - catch (Exception e) { + catch (SecurityException e) { + // File not readbale, permission denied return false; } } @@ -230,33 +224,29 @@ private static boolean isTokenReadableFile(String token) { * @return * A Map with the secret engine type and its mount path */ - public JsonNode getSecretsEngine(String path) { - JsonNode cacheResponse = cache.getIfPresent(VAULT_PATH_HELP + path); + public JsonNode getSecretsEngine(final String path) { + final JsonNode cacheResponse = cache.getIfPresent(VAULT_PATH_HELP + path); if (cacheResponse != null) { return cacheResponse; } - VaultResponse response = vaultTemplate.read(VAULT_PATH_HELP + path); - Map data = response.getData(); + final VaultResponse response = vaultTemplate.read(VAULT_PATH_HELP + path); + final Map data = response.getData(); String type = String.valueOf(data.get("type")); - if (type.equals("kv")) { - // Need to detect if type 1 or type 2 Key/Value engine + if ("kv".equals(type)) { + final String version; if (data.get("options") instanceof Map) { - Map options = (Map) data.get("options"); - if ("2".equals(String.valueOf(options.get("version")))) { - type = "kv_2"; - } - else { - type = "kv_1"; - } + final Map options = (Map) data.get("options"); + version = String.valueOf(options.get("version")); } else { - // No options, assume kv_1 - type = "kv_1"; + version = null; } + type = "2".equals(version) ? "kv_2" : "kv_1"; } - JsonNode map = objectMapper.valueToTree(Map.of("type", type, + + final JsonNode map = objectMapper.valueToTree(Map.of("type", type, "path", String.valueOf(data.get("path")))); cache.put(VAULT_PATH_HELP + path, map); @@ -266,25 +256,34 @@ public JsonNode getSecretsEngine(String path) { /** * Contains information about the checked out LDAP sessions */ - private static class LDAPSessionInfo { - final String checkInPath; - final String username; - final Boolean initialized; - final Instant created; - - public LDAPSessionInfo(String checkInPath, String username) { + private static final class LDAPSessionInfo { + /** Stores the check-in path of an active ldap-session */ + private final String checkInPath; + /** Stores the service account username of the checked out account */ + private final String username; + /** Is true id the TunnelConnectEVent has been detected */ + private final boolean initialized; + /** The date the account was checked-out, allowing automatic check-in after 2h */ + private final Instant created; + + private LDAPSessionInfo(final String checkInPath, final String username) { this.checkInPath = checkInPath; this.username = username; this.initialized = false; this.created = Instant.now(); } - public LDAPSessionInfo(String checkInPath, String username, Boolean initialized) { + private LDAPSessionInfo(final String checkInPath, final String username, final boolean initialized) { this.checkInPath = checkInPath; this.username = username; this.initialized = initialized; this.created = Instant.now(); } + + private String getCheckInPath() { return checkInPath; } + private String getUsername() { return username; } + private boolean isInitialized() { return initialized; } + private Instant getCreated() { return created; } } /** @@ -298,36 +297,37 @@ public LDAPSessionInfo(String checkInPath, String username, Boolean initialized) * limit the risk of confusing two connection. There is still a small risk * of error here. * - * @param id - * The id of the tunnel ldap session - * * @param tunnelId + * The id of the tunnel of the ldap session. In a TunnelConnectEvent + * this is the temporary id used when the session was checked out + * + * @param tunnelIdNew * The tunnel ID to store if we are treating a TunnelConnectEvent * * @return * Returns true if our client treats this element */ - public Boolean treatLdapSession(String id, @Nullable String tunnelId) { - LDAPSessionInfo session = ldapSessions.get(id); + public Boolean treatLdapSession(final String tunnelId, @Nullable final String tunnelIdNew) { + final LDAPSessionInfo session = ldapSessions.get(tunnelId); if (session != null) { - if (tunnelId != null) { - logger.debug("Storing connection ID: {}", tunnelId); - ldapSessions.put(tunnelId, new LDAPSessionInfo(session.checkInPath, session.username, true)); + if (tunnelIdNew != null) { + logger.debug("Storing connection ID: {}", tunnelIdNew); + ldapSessions.put(tunnelIdNew, new LDAPSessionInfo(session.getCheckInPath(), session.getUsername(), true)); } - else if (session.initialized) { + else if (session.isInitialized()) { // FIXME : We don't always receive the TunnelCloseEvent // So this checkin function only kinda works - logger.debug("Removing stored LDAP session: {}", id); - vaultTemplate.write(session.checkInPath, Map.of("service_account_names", session.username)); + logger.debug("Removing stored LDAP session: {}", tunnelId); + vaultTemplate.write(session.getCheckInPath(), Map.of("service_account_names", session.getUsername())); } - ldapSessions.remove(id); + ldapSessions.remove(tunnelId); // Do some clean up of the active LDAP sessions. If a session hasn't been // checked in after 2 hours, just drop it from the hashMap. As the TTL of // the vault is already 2 hours don't need to check it in. Don't really // care if the value hang around in our hashmap so don't need a dedicated // task for this - ldapSessions.entrySet().removeIf(e -> Instant.now().isAfter(e.getValue().created.plusSeconds(7200))); + ldapSessions.entrySet().removeIf(e -> Instant.now().isAfter(e.getValue().getCreated().plusSeconds(7200))); return true; } @@ -359,11 +359,12 @@ else if (session.initialized) { * If the requested secret cannot be retrieved or the HV notation * is invalid. */ - public CompletableFuture getSecret(String notation, String username, String key) throws GuacamoleException { + public CompletableFuture getSecret(final String notation, final String username, final String key) throws GuacamoleException { // If it's not an HV token, fail - if (!notation.startsWith(VAULT_TOKEN_PREFIX)) + if (!notation.startsWith(VAULT_TOKEN_PREFIX)) { throw new GuacamoleException("Invalid token Vault notation: " + notation); + } /* * vault://path/to/secret <-- the Guacamole token name and its modifier @@ -371,67 +372,66 @@ public CompletableFuture getSecret(String notation, String username, Str * ^^^^^^ <-- this is the secret (or key in HV terms) */ int lastSlashIndex = notation.lastIndexOf('/'); - if (lastSlashIndex == -1) + if (lastSlashIndex == -1) { lastSlashIndex = VAULT_TOKEN_PREFIX.length(); + } - String path = notation.substring(VAULT_TOKEN_PREFIX.length(), lastSlashIndex); - String secret = notation.substring(lastSlashIndex + 1); + final String path = notation.substring(VAULT_TOKEN_PREFIX.length(), lastSlashIndex); + final String secret = notation.substring(lastSlashIndex + 1); - JsonNode cachedSecrets = cache.getIfPresent(key); + final JsonNode cachedSecrets = cache.getIfPresent(key); if (cachedSecrets != null) { logger.debug("Using cached data for token : {}", notation); - try { - JsonNode secretNode = cachedSecrets.get(secret); - if (secretNode == null) { - logger.warn("Could not find {}/{}", path, secret); - return CompletableFuture.completedFuture(""); - } - return CompletableFuture.completedFuture(secretNode.asText()); - } - catch (Exception e) { - throw new GuacamoleException("Failed to extract secret from cached JSON for " + notation, e); + final JsonNode secretNode = cachedSecrets.get(secret); + if (secretNode == null) { + logger.warn("Could not find {}/{}", path, secret); + return CompletableFuture.completedFuture(""); } + return CompletableFuture.completedFuture(secretNode.asText()); } + + long cacheLifetimeTmp; + try { + cacheLifetimeTmp = (long) vaultInfo.getVaultCacheLifetime(); + } + catch (GuacamoleException e) { + cacheLifetimeTmp = 5000L; + } + final long cacheLifetime = cacheLifetimeTmp; // Cache miss, either get an existing in-flight request or create a new one - CompletableFuture futureResponse = inFlightRequests.computeIfAbsent(key, k -> { + final CompletableFuture futureResponse = inFlightRequests.computeIfAbsent(key, k -> { return CompletableFuture.supplyAsync(() -> { - try { - String type = getSecretsEngine(path).get("type").asText(); - String mountPath = getSecretsEngine(path).get("path").asText(); - String newpath = path.substring(mountPath.length()); - logger.debug("Vault {}, {}, {}, {}", type, mountPath, path, secret); - JsonNode jsonNode; - switch (type) { - case "ssh": - jsonNode = getValueSSH(mountPath, newpath, username); - break; - case "ldap": - jsonNode = getValueLDAP(mountPath, newpath); - break; - case "database": - jsonNode = getValueDB(mountPath, newpath); - break; - case "kv_1": - jsonNode = getValueKV(mountPath, newpath, VaultKeyValueOperations.KeyValueBackend.KV_1); - break; - case "kv_2": - jsonNode = getValueKV(mountPath, newpath, VaultKeyValueOperations.KeyValueBackend.KV_2); - break; - default: - throw new IllegalArgumentException("Unknown secret engine for the token: '" + type +"'"); - } - - return jsonNode; + final String type = getSecretsEngine(path).get("type").asText(); + final String mountPath = getSecretsEngine(path).get("path").asText(); + final String newpath = path.substring(mountPath.length()); + logger.debug("Vault {}, {}, {}, {}", type, mountPath, path, secret); + + final JsonNode jsonNode; + switch (type) { + case "ssh": + jsonNode = getValueSSH(mountPath, newpath, username); + break; + case "ldap": + jsonNode = getValueLDAP(mountPath, newpath); + break; + case "database": + jsonNode = getValueDB(mountPath, newpath); + break; + case "kv_1": + jsonNode = getValueKV(mountPath, newpath, VaultKeyValueOperations.KeyValueBackend.KV_1); + break; + case "kv_2": + jsonNode = getValueKV(mountPath, newpath, VaultKeyValueOperations.KeyValueBackend.KV_2); + break; + default: + throw new IllegalArgumentException("Unknown secret engine for the token: '" + type +"'"); } - catch (Exception e) { - logger.warn("Vault query failed for {} with {}", path, e.getMessage()); - throw new CompletionException("Vault query failed for " + path, e); - } + return jsonNode; }) - .orTimeout((long) vaultInfo.CacheLifetime, TimeUnit.MILLISECONDS); + .orTimeout(cacheLifetime, TimeUnit.MILLISECONDS); }); return futureResponse.whenComplete((jsonNode, ex) -> { @@ -441,14 +441,15 @@ public CompletableFuture getSecret(String notation, String username, Str cache.put(key, jsonNode); } - // Now that the cache is filled, this in-flight request is obsolete and must be removed + // Now that the cache is filled, this in-flight request is obsolete and + // must be removed inFlightRequests.remove(key); }).thenApply(jsonNode -> { /* * Extract and return the secret */ - JsonNode secretNode = jsonNode.get(secret); + final JsonNode secretNode = jsonNode.get(secret); if (secretNode == null) { logger.warn("Could not find {}/{}", path, secret); return ""; @@ -456,11 +457,13 @@ public CompletableFuture getSecret(String notation, String username, Str return secretNode.asText(); }).exceptionally(e -> { // Make sure that the exception is a GuacamoleException - Throwable cause = e.getCause(); - String errorMessage = (cause != null) ? cause.getMessage() : "Unknown error"; + final Throwable cause = e.getCause(); + final String errorMessage = (cause != null) ? cause.getMessage() : "Unknown error"; + logger.warn("Vault query failed for {} with {}", path, errorMessage); - if (cause instanceof GuacamoleException) + if (cause instanceof GuacamoleException) { throw new CompletionException(cause); + } throw new CompletionException( new GuacamoleException("Vault query failed for " + path + ": " + errorMessage, cause)); @@ -485,11 +488,11 @@ public CompletableFuture getSecret(String notation, String username, Str * @throws GuacamoleException * If the secrets cannot be retrieved from the Vault. */ - private JsonNode getValueKV(String mountPath, String path, VaultKeyValueOperations.KeyValueBackend type) throws VaultException { - VaultKeyValueOperations kvOperations = vaultTemplate.opsForKeyValue(mountPath, type); + private JsonNode getValueKV(final String mountPath, final String path, final VaultKeyValueOperations.KeyValueBackend type) throws VaultException { + final VaultKeyValueOperations kvOperations = vaultTemplate.opsForKeyValue(mountPath, type); // Get the values on the path and cache them - VaultResponse response = kvOperations.get(path); + final VaultResponse response = kvOperations.get(path); if (response == null || response.getData() == null) { @@ -518,50 +521,58 @@ private JsonNode getValueKV(String mountPath, String path, VaultKeyValueOperatio * @throws GuacamoleException * If the secrets cannot be retrieved from the Vault. */ - private JsonNode getValueSSH(String mountPath, String path, String username) throws VaultException { + private JsonNode getValueSSH(final String mountPath, final String path, final String username) throws VaultException { if (path.startsWith("creds/")) { if (username == null || username.isEmpty()) { throw new VaultException("The username can not be empty for SSH signed certificates"); } - VaultResponse response = + final VaultResponse response = vaultTemplate.write(mountPath + path, Map.of("ip", "0.0.0.0")); if (response == null || response.getData() == null) { throw new VaultException("No response from Vault SSH engine"); } - Map retval = response.getData(); + final Map retval = response.getData(); retval.put("password", retval.get("key")); return objectMapper.valueToTree(retval); } - else if (path.startsWith("sign/")) { - HvSshKeys sshKeys = new HvSshKeys(vaultInfo.SshType); - Map request = Map.of( - "public_key", sshKeys.publicSsh, - "valid_principals", username, - "extensions", Map.of("permit-pty", ""), - "ttl", vaultInfo.SshConnectionTimeout); - VaultResponse vaultResponse = vaultTemplate.write(mountPath + path, request); + if (path.startsWith("sign/")) { + + final HvSshKeys sshKeys; + final Map request; + try { + sshKeys = new HvSshKeys(vaultInfo.getSshType()); + request = Map.of( + "public_key", sshKeys.getPublic(), + "valid_principals", username, + "extensions", Map.of("permit-pty", ""), + "ttl", vaultInfo.getSshConnectionTimeout()); + } + catch (GuacamoleException e) { + throw new VaultException("Error reading Vault configuration : " + e.getMessage()); + } + + final VaultResponse vaultResponse = vaultTemplate.write(mountPath + path, request); if (vaultResponse == null || vaultResponse.getData() == null) { throw new VaultException("No response from Vault SSH engine"); } - - String signedCert = (String) vaultResponse.getData().get("signed_key"); + final Map data = vaultResponse.getData(); + final String signedCert = (String) data.get("signed_key"); if (signedCert == null) { throw new VaultException("Vault did not return a signed SSH certificate"); } - return objectMapper.valueToTree(Map.of("private", sshKeys.privateSshPem, + return objectMapper.valueToTree(Map.of("private", sshKeys.getPrivate(), "public", signedCert, - "unsigned", sshKeys.publicSsh)); - } - else { - throw new VaultException("Unknown SSH type on path: " + mountPath + path); + "unsigned", sshKeys.getPublic())); } + + throw new VaultException("Unknown SSH type on path: " + mountPath + path); } /** @@ -580,8 +591,8 @@ else if (path.startsWith("sign/")) { * @throws GuacamoleException * If the secrets cannot be retrieved from the Vault. */ - private JsonNode getValueLDAP(String mountPath, String path) throws VaultException { - VaultResponse response; + private JsonNode getValueLDAP(final String mountPath, final String path) throws VaultException { + final VaultResponse response; if (path.startsWith("static") || path.startsWith("creds/")) { response = vaultTemplate.read(mountPath + path); } @@ -596,14 +607,14 @@ else if (path.startsWith("library/")) { throw new VaultException("No response from LDAP secrets engine"); } - Map retval = response.getData(); + final Map retval = response.getData(); if (path.startsWith("library/")) { - String username = String.valueOf(retval.get("service_account_name")); + final String username = String.valueOf(retval.get("service_account_name")); retval.put("username", username); // Register a listener to check-in the account for a TunnelClose Event logger.info("Caching session : {}", username); - LDAPSessionInfo info = new LDAPSessionInfo(mountPath + path + "/check-in", username); + final LDAPSessionInfo info = new LDAPSessionInfo(mountPath + path + "/check-in", username); ldapSessions.put(VAULT_LDAP_SESSION, info); } @@ -628,8 +639,8 @@ else if (path.startsWith("library/")) { * @throws GuacamoleException * If the secrets cannot be retrieved from the Vault. */ - private JsonNode getValueDB(String mountPath, String path) throws VaultException { - VaultResponse response = vaultTemplate.read(mountPath + path); + private JsonNode getValueDB(final String mountPath, final String path) throws VaultException { + final VaultResponse response = vaultTemplate.read(mountPath + path); if (response == null || response.getData() == null) { throw new VaultException("No response from Database secrets engine"); diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientFactory.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientFactory.java deleted file mode 100644 index a1b9c1f296..0000000000 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientFactory.java +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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 org.apache.guacamole.vault.hv.secret; - -import java.util.Map; -import javax.annotation.Nonnull; -import org.apache.guacamole.vault.hv.conf.HvConfigurationService.VaultInfo; - -/** - * Factory for creating HvClient instances. - */ -public interface HvClientFactory { - - /** - * Returns a new instance of a HvClient instance associated with - * the provided HV configuration options and API interval. - * - * @param hvConfigOptions - * The HV config options to use when constructing the HvClient - * object. - * - * @return - * A new HvClient instance associated with the provided HV config - * options. - */ - HvClient create(@Nonnull VaultInfo vaultInfo); - -} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientProvider.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientProvider.java new file mode 100644 index 0000000000..07fc7b520f --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientProvider.java @@ -0,0 +1,376 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.guacamole.vault.hv.secret; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import java.net.URI; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import javax.annotation.Nonnull; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.net.auth.Attributes; +import org.apache.guacamole.net.auth.Connectable; +import org.apache.guacamole.net.auth.Connection; +import org.apache.guacamole.net.auth.ConnectionGroup; +import org.apache.guacamole.net.auth.Directory; +import org.apache.guacamole.net.auth.User; +import org.apache.guacamole.net.auth.UserContext; +import org.apache.guacamole.net.event.TunnelCloseEvent; +import org.apache.guacamole.net.event.TunnelConnectEvent; +import org.apache.guacamole.vault.hv.conf.HvAttributeService; +import org.apache.guacamole.vault.hv.conf.HvConfigurationService; +import org.apache.guacamole.vault.hv.conf.HvConfigurationService.VaultInfo; +import org.apache.guacamole.vault.hv.user.HvDirectory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A Provider for a OpenBao/Hashicorp Vault client for a particular + * configuration. + */ +@Singleton +public class HvClientProvider { + + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(HvClientProvider.class); + + /** + * Service for retrieving configuration information. + */ + private final HvConfigurationService confService; + + /** + * Factory for creating HV client instances. + */ + private final HvClientFactory hvClientFactory; + + /** + * A map of HV VaultInfo configurations to associated HV client instances. + * A distinct HV client will exist for every VaultInfo. + */ + private static final ConcurrentMap hvClientMap = new ConcurrentHashMap<>(); + + + /** + * Public constructor for Guice, so that we can instantiate the existing + * Vault clients early, to avoid problems with expiring tokens + * + * @param confService + * Service for retrieving configuration information + * + * @param hvClientFactory + * Factory for creating HV client instances + */ + @Inject + public HvClientProvider(final HvConfigurationService confService, + final HvClientFactory hvClientFactory) { + this.confService = confService; + this.hvClientFactory = hvClientFactory; + } + + /** + * Create and return a HV client for the provided HV config if not already + * present in the client map, otherwise return the existing client entry. + * + * @param hvConfig + * The base-64 encoded JSON HV config blob associated with the client entry. + * If an associated entry does not already exist, it will be created using + * this configuration. + * + * @return + * A HV client for the provided HV config if not already present in the + * client map, otherwise the existing client entry. + * + * @throws GuacamoleException + * If an error occurs while creating the HV client. + */ + public HvClient getHvClient(final VaultInfo vaultInfo) + throws GuacamoleException { + if (vaultInfo.isVaultInfoInvalid()) { + return null; + } + + // If a client already exists for the provided config, use it + HvClient hvClient = hvClientMap.get(vaultInfo); + if (hvClient != null) { + return hvClient; + } + + // Create and store a new HV client instance for the provided HV config blob + hvClient = hvClientFactory.create(vaultInfo); + final HvClient prevClient = hvClientMap.putIfAbsent(vaultInfo, hvClient); + + // If the client was already set before this thread got there, use the existing one + return prevClient != null ? prevClient : hvClient; + } + + /** + * Search for a HV configuration attribute, recursing up the connection group tree + * until a connection group with the appropriate attribute is found. If the HV + * configuration is found, the corresponding HVClient will be returned, else null. + * + * @param userContext + * The userContext associated with the connection or connection group. + * + * @param connectable + * A connection or connection group for which the tokens are being replaced. + * + * @return + * The value of the HV configuration attributes if found in the tree, the default + * HV config defined in guacamole.properties otherwise. + * + * @throws GuacamoleException + * If an error occurs while attempting to retrieve the HV config attribute, or if + * no HV config is found in the connection group tree, and the value is also not + * defined in the config file. + */ + public HvClient getConnectionGroupHvClient(final UserContext userContext, + final Connectable connectable) throws GuacamoleException { + HvClient client = null; + + // Check to make sure it's a usable type before proceeding + if ((connectable instanceof Connection) || (connectable instanceof ConnectionGroup)) { + // For connections, start searching the parent group for the HV config + // For connection groups, start searching the group directly + String parentIdentifier = (connectable instanceof Connection) + ? ((Connection) connectable).getParentIdentifier() + : ((ConnectionGroup) connectable).getIdentifier(); + + // Keep track of all group identifiers seen while recursing up the tree + // in case there's a cycle - if the same identifier is ever seen twice, + // the search is over. + final Set observedIdentifiers = new HashSet<>(); + + // Use the unwrapped connection group directory to avoid HV config + // value sanitization + final Directory connectionGroupDirectory = ( + (HvDirectory) userContext.getConnectionGroupDirectory() + ).getUnderlyingDirectory(); + + // If the parent is a group that's already been seen, this is a cycle, so + // there's no need to search any further + while (client == null && observedIdentifiers.add(parentIdentifier)) { + // Fetch the parent group, if one exists + final ConnectionGroup group = connectionGroupDirectory.get(parentIdentifier); + if (group == null) { + break; + } + + // If the current connection group has HV configuration attributes + // set to a non-empty value, return immediately + final Map hvConfig = group.getAttributes(); + + if (hvConfig.get(HvAttributeService.HV_URI_ATTRIBUTE) == null) { + break; + } + final VaultInfo vaultInfo = confService.new VaultInfo( + URI.create(hvConfig.get(HvAttributeService.HV_URI_ATTRIBUTE)), + hvConfig.get(HvAttributeService.HV_TOKEN_ATTRIBUTE), + hvConfig.get(HvAttributeService.HV_USERNAME_ATTRIBUTE), + hvConfig.get(HvAttributeService.HV_PASSWORD_ATTRIBUTE)); + + client = getHvClient(vaultInfo); + + // Otherwise, keep searching up the tree until an appropriate + // configuration is found + parentIdentifier = group.getParentIdentifier(); + } + } + else { + logger.warn( + "Unsupported Connectable type: {}; skipping HV config lookup.", + connectable.getClass() + ); + } + + return client; + } + + /** + * Return the HVclient for the current user IFF user User HV configuration + * is enabled globally, and are enabled for the given connectable. If no + * HV configuartion exists for the given user or HV configs are not enabled, + * null will be returned. + * + * @param userContext + * The user context from which the current user should be fetched. + * + * @param connectable + * The connectable to which the connection is being established. This + * is the connection which will be checked to see if user HV configs + * are enabled. + * + * @return + * The value of the user HV configuration attributes if found in the tree, + * the default HV config defined in guacamole.properties otherwise. + * + * @throws GuacamoleException + * If an error occurs while attempting to fetch the HV config. + */ + public HvClient getUserHvClient(final UserContext userContext, + final Connectable connectable) throws GuacamoleException { + HvClient client = null; + + // If user HV configs are enabled globally, and for the given connectable, + // return the user-specific HV config, if one exists + + if (confService.allowUserConfig() && (connectable instanceof Attributes) && + HvAttributeService.TRUTH_VALUE.equals(((Attributes) connectable).getAttributes().get( + HvAttributeService.HV_USER_CONFIG_ENABLED_ATTRIBUTE))) { + + // Get the underlying user, to avoid the KSM config sanitization + final User self = ((HvDirectory) userContext.getUserDirectory()) + .getUnderlyingDirectory().get(userContext.self().getIdentifier()); + + // If the current user has HV configuration attributes + // set to a non-empty value, return immediately + final Map hvConfig = self.getAttributes(); + + if (hvConfig.get(HvAttributeService.HV_URI_ATTRIBUTE) != null) { + final VaultInfo vaultInfo = confService.new VaultInfo( + URI.create(hvConfig.get(HvAttributeService.HV_URI_ATTRIBUTE)), + hvConfig.get(HvAttributeService.HV_TOKEN_ATTRIBUTE), + hvConfig.get(HvAttributeService.HV_USERNAME_ATTRIBUTE), + hvConfig.get(HvAttributeService.HV_PASSWORD_ATTRIBUTE)); + + client = getHvClient(vaultInfo); + } + } + + return client; + } + + /** + * Return an ordered list of non null HVClients. The application-wide + * client is first if non null, followed by the ConnectionGroup client, + * and finally the User client. This order is important to ensure that + * the adminsitrator always has control of te injected tokens*. + * + * @param userContext + * The user context from which the current user should be fetched. + * + * @param connectable + * The connectable to which the connection is being established. This + * is the connection which will be checked to see if user HV configs + * are enabled. + * + * @return + * The value of the user HV configuration attributes if found in the tree, + * the default HV config defined in guacamole.properties otherwise. + * + * @throws GuacamoleException + * If an error occurs while attempting to fetch the HV config. + */ + public List getHvClients(final UserContext userContext, + final Connectable connectable) throws GuacamoleException { + final List clients = new ArrayList<>(); + + // Create an application wide client + final VaultInfo vaultInfo = confService.new VaultInfo( + confService.getVaultUri(), + confService.getVaultToken(), + confService.getVaultUsername(), + confService.getVaultPassword()); + + final HvClient client = getHvClient(vaultInfo); + if (client != null) { + clients.add(client); + } + + // Create a connection group client + final HvClient connectionClient = getConnectionGroupHvClient(userContext, connectable); + if (connectionClient != null) { + clients.add(connectionClient); + } + + // Configure per user client if configured + final HvClient userClient = getUserHvClient(userContext, connectable); + if (userClient != null) { + clients.add(userClient); + } + + return clients; + } + + /** + * The LDAP session interface checks out sessions that then can not be + * used till they are checked in. In getTokens we have the problem that + * we don't have access to the tunnel ID and so can't identify it. + * Guacamole is also not guarenteed to generate a TunnelCloseEvent. + * + * So this function is fragile and relies on the fact that the TunnelConnectEvent + * will be running a few tens of milliseconds after the getTokens command to + * limit the risk of confusing two connections. There is still a small risk + * of error here. This needs a better solution + * + * @param event + * A TunnelConnectEvent or TunnelCloseEvent + */ + public static void treatLdapSession(final Object event) { + if (event instanceof TunnelConnectEvent) { + final String tunnelId = ((TunnelConnectEvent) event).getTunnel().getUUID().toString(); + for (final Map.Entry entry : hvClientMap.entrySet()) { + final HvClient client = entry.getValue(); + if (client.treatLdapSession(client.VAULT_LDAP_SESSION, tunnelId)) { + break; + } + } + } + else { + final String tunnelId = ((TunnelCloseEvent) event).getTunnel().getUUID().toString(); + for (final Map.Entry entry : hvClientMap.entrySet()) { + final HvClient client = entry.getValue(); + if (client.treatLdapSession(tunnelId, null)) { + break; + } + } + } + } + + /** + * Factory for creating HvClient instances. + */ + public interface HvClientFactory { + + /** + * Returns a new instance of a HvClient instance associated with + * the provided HV configuration options and API interval. + * + * @param hvConfigOptions + * The HV config options to use when constructing the HvClient + * object. + * + * @return + * A new HvClient instance associated with the provided HV config + * options. + */ + HvClient create(@Nonnull VaultInfo vaultInfo); + + } +} + diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java index cf7d99ae4c..8af8c46e97 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java @@ -19,44 +19,28 @@ package org.apache.guacamole.vault.hv.secret; -import com.google.common.base.Objects; import com.google.inject.Inject; import com.google.inject.Singleton; -import java.io.UnsupportedEncodingException; -import java.net.URI; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; -import java.util.HashMap; -import java.util.HashSet; +import java.util.List; import java.util.Map; -import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; import java.util.concurrent.Future; import java.util.function.Function; import java.util.regex.Matcher; import java.util.regex.Pattern; -import javax.annotation.Nonnull; import org.apache.guacamole.GuacamoleException; -import org.apache.guacamole.net.auth.Attributes; import org.apache.guacamole.net.auth.Connectable; import org.apache.guacamole.net.auth.Connection; -import org.apache.guacamole.net.auth.ConnectionGroup; -import org.apache.guacamole.net.auth.Directory; -import org.apache.guacamole.net.auth.User; import org.apache.guacamole.net.auth.UserContext; -import org.apache.guacamole.net.event.TunnelCloseEvent; -import org.apache.guacamole.net.event.TunnelConnectEvent; import org.apache.guacamole.protocol.GuacamoleConfiguration; import org.apache.guacamole.token.TokenFilter; -import org.apache.guacamole.vault.hv.conf.HvAttributeService; import org.apache.guacamole.vault.hv.conf.HvConfigurationService; import org.apache.guacamole.vault.hv.conf.HvConfigurationService.VaultInfo; -import org.apache.guacamole.vault.hv.user.HvDirectory; import org.apache.guacamole.vault.secret.VaultSecretService; -import org.apache.guacamole.vault.secret.WindowsUsername; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -77,12 +61,12 @@ public class HvSecretService implements VaultSecretService { /** * Service for retrieving configuration information. */ - private HvConfigurationService confService; + private final HvConfigurationService confService; /** - * Factory for creating HV client instances. + * Provider for HV client instances. */ - private HvClientFactory hvClientFactory; + private final HvClientProvider hvClientProvider; /** * Public constructor for Guice, so that we can instantiate the existing @@ -95,68 +79,28 @@ public class HvSecretService implements VaultSecretService { * Factory for creating HV client instances */ @Inject - public HvSecretService(HvConfigurationService confService, HvClientFactory hvClientFactory) { + public HvSecretService(final HvConfigurationService confService, final HvClientProvider hvClientProvider) { this.confService = confService; - this.hvClientFactory = hvClientFactory; + this.hvClientProvider = hvClientProvider; // Instantiate the HvClient early to start Token renewal of main Vault account. // FIXME: Don't have access to the root ConnectionGroup here, so can't instantiate // the per ConnectionGroup vaults, which MUST have a non expiring means of // authentication to avoid issues try { - VaultInfo vaultInfo = confService.new VaultInfo(confService.getVaultUri(), + final VaultInfo vaultInfo = confService.new VaultInfo( + confService.getVaultUri(), confService.getVaultToken(), confService.getVaultUsername(), confService.getVaultPassword()); - if (isVaultInfoValid(vaultInfo)) { - HvClient client = getClient(confService.new VaultInfo(confService.getVaultUri(), - confService.getVaultToken(), - confService.getVaultUsername(), - confService.getVaultPassword())); - } + + hvClientProvider.getHvClient(vaultInfo); } catch (GuacamoleException e) { logger.error("Can't initialize HvClient : {}", e.getMessage()); } } - /** - * A map of HV VaultInfo configurations to associated HV client instances. - * A distinct HV client will exist for every VaultInfo. - */ - static private final ConcurrentMap hvClientMap = new ConcurrentHashMap<>(); - - /** - * Create and return a HV client for the provided HV config if not already - * present in the client map, otherwise return the existing client entry. - * - * @param hvConfig - * The base-64 encoded JSON HV config blob associated with the client entry. - * If an associated entry does not already exist, it will be created using - * this configuration. - * - * @return - * A HV client for the provided HV config if not already present in the - * client map, otherwise the existing client entry. - * - * @throws GuacamoleException - * If an error occurs while creating the HV client. - */ - private HvClient getClient(@Nonnull VaultInfo vaultInfo) - throws GuacamoleException { - // If a client already exists for the provided config, use it - HvClient hvClient = hvClientMap.get(vaultInfo); - if (hvClient != null) - return hvClient; - - // Create and store a new HV client instance for the provided HV config blob - hvClient = hvClientFactory.create(vaultInfo); - HvClient prevClient = hvClientMap.putIfAbsent(vaultInfo, hvClient); - - // If the client was already set before this thread got there, use the existing one - return prevClient != null ? prevClient : hvClient; - } - /** * As vault notation is essentially a URL, encode all components * using standard URL escaping. @@ -168,17 +112,43 @@ private HvClient getClient(@Nonnull VaultInfo vaultInfo) * The canonicalized token */ @Override - public String canonicalize(String nameComponent) { - try { + public String canonicalize(final String nameComponent) { - // As HV notation is essentially a URL, encode all components - // using standard URL escaping - return URLEncoder.encode(nameComponent, "UTF-8"); + // As HV notation is essentially a URL, encode all components + // using standard URL escaping + return URLEncoder.encode(nameComponent, StandardCharsets.UTF_8); + } + /** + * Helper function for prepareToken that implements that decides if + * a sub-token exists in the token and if it should be replaced + * + * @param oldtoken + * The token string potentially containing a sub-token to replace + * + * @param placeholder + * The sub-token value to look for. For example "{USERNAME}" + * + * @param filter + * A TokenFilter to use of the value if the suboken itself is a token + * + * @return + * The token with the sub-token replaced with its value + */ + private String applyToken(final String oldtoken, String value, + final String placeholder, final TokenFilter filter) { + String token = oldtoken; + + // FIXME There is an edge case for tokens like + // vault://ldap/$${USERNAME}/{USERNAME}/password + // This seems a pretty unlikely case, so don't treat. + if (value != null && !value.isEmpty()) { + value = filter.filter(value); + if (!value.contains("${") && token.contains(placeholder) && !token.contains("$$" + placeholder)) { + token = token.replace(placeholder, value); + } } - catch (UnsupportedEncodingException e) { - throw new UnsupportedOperationException("Unexpected lack of UTF-8 support.", e); - } + return token; } /** @@ -186,7 +156,7 @@ public String canonicalize(String nameComponent) { * "{HOSTNAME}", "{GATEWAY_USERNAME}" and "{GATEWAY_HOSTNAME}" in the token with * values supplied in the parameters. * - * @param token + * @param oldtoken * A token that might or might not include sub-tokens to be replaced * * @param userContext @@ -200,39 +170,24 @@ public String canonicalize(String nameComponent) { * @return * A token with the sub-tokens included eplaced. */ - private String prepareToken(String token, UserContext userContext, GuacamoleConfiguration config, TokenFilter filter) { - // FIXME There is an edge case for tokens like "vault://ldap/$${USER}/{USER}/password" - // both here and below. This seems a pretty unlikely case, so don't treat. - String guac_username = userContext == null ? "" : userContext.self().getIdentifier(); - if (guac_username != null && !guac_username.isEmpty() - && token.contains("{GUAC_USERNAME}") && ! token.contains("$${GUAC_USERNAME}")) { - token = token.replace("{GUAC_USERNAME}", filter.filter(guac_username)); + private String prepareToken(final String oldtoken, final UserContext userContext, + final GuacamoleConfiguration config, final TokenFilter filter) { + String token = oldtoken; - } - String hostname = config.getParameter("hostname"); - if (hostname != null && !hostname.isEmpty() && !hostname.contains("${") - && token.contains("{HOSTNAME}") && ! token.contains("$${HOSTNAME}")) { - token = token.replace("{HOSTNAME}", filter.filter(hostname)); + final String guacUsername = userContext == null ? "" : userContext.self().getIdentifier(); + token = applyToken(token, guacUsername, "{GUAC_USERNAME}", filter); - } - String username = config.getParameter("username"); - if (username != null && !username.isEmpty() && !username.contains("${") - && token.contains("{USERNAME}") && ! token.contains("$${USERNAME}")) { - token = token.replace("{USERNAME}", filter.filter(username)); + final String hostname = config.getParameter("hostname"); + token = applyToken(token, config.getParameter("hostname"), "{HOSTNAME}", filter); - } - String gatewayHostname = config.getParameter("gateway-hostname"); - if (gatewayHostname != null && !gatewayHostname.isEmpty() && !gatewayHostname.contains("${") - && token.contains("{GATEWAY}") && ! token.contains("$${GATEWAY}")) { - token = token.replace("{GATEWAY}", filter.filter(gatewayHostname)); + final String username = config.getParameter("username"); + token = applyToken(token, config.getParameter("username"), "{USERNAME}", filter); - } - String gatewayUsername = config.getParameter("gateway-username"); - if (gatewayUsername != null && !gatewayUsername.isEmpty() && !gatewayUsername.contains("${") - && token.contains("{GATEWAY_USER}") && ! token.contains("$${GATEWAY_USER}")) { - token = token.replace("{GATEWAY_USER}", filter.filter(gatewayUsername)); + final String gatewayHostname = config.getParameter("gateway-hostname"); + token = applyToken(token, config.getParameter("gateway-hostname"), "{GATEWAY}", filter); - } + final String gatewayUsername = config.getParameter("gateway-username"); + token = applyToken(token, config.getParameter("gateway-username"), "{GATEWAY_USER}", filter); return token; } @@ -264,9 +219,9 @@ private String prepareToken(String token, UserContext userContext, GuacamoleConf * If the secret cannot be retrieved due to an error. */ @Override - public Future getValue( UserContext userContext, - Connectable connectable, String name) throws GuacamoleException { - GuacamoleConfiguration config; + public Future getValue(final UserContext userContext, + final Connectable connectable, final String name) throws GuacamoleException { + final GuacamoleConfiguration config; if (connectable instanceof Connection) { config = ((Connection) connectable).getConfiguration(); } @@ -274,98 +229,24 @@ public Future getValue( UserContext userContext, config = new GuacamoleConfiguration(); } - // Create an application wide client - VaultInfo vaultInfo = confService.new VaultInfo(confService.getVaultUri(), - confService.getVaultToken(), - confService.getVaultUsername(), - confService.getVaultPassword()); - HvClient client; - if (isVaultInfoValid(vaultInfo)) - client = getClient(vaultInfo); - else - client = null; - - // Create a connection group client - HvClient connectionClient = getConnectionGroupHvClient(userContext, connectable); - - // Configure per user client if configured - HvClient userClient = getUserHvClient(userContext, connectable); + // An ordered array of non null HvClients + final List clients = hvClientProvider.getHvClients(userContext, connectable); + if (clients.size() == 0) { + return null; + } // Use a key including GUAC_USERNAME, to at least prevent a user stealing the // session of another due to timing issues. The ssh certificates of keys // of token from vault-token-mapping.yml must have an explicit username associated // with it final String username = config.getParameter("username"); - final String guac_username = userContext.self().getIdentifier(); + final String guacUsername = userContext.self().getIdentifier(); final String finalName = prepareToken(name.replaceFirst(":(LOWER|UPPER|OPTIONAL)$", ""), userContext, config, new TokenFilter()); - final String key = guac_username + "-" + username + "-" + + final String key = guacUsername + "-" + username + "-" + finalName.substring(0, finalName.lastIndexOf('/')); - - if (client == null) { - if (connectionClient == null) { - if (userClient == null) { - return null; - } - else { - return userClient.getSecret(finalName, username, key); - } - } - else { - return connectionClient.getSecret(finalName, username, key) - .handle((value, ex) -> { - if (ex == null) { - return CompletableFuture.completedFuture(value); - } - - if (userClient != null) { - try { - return userClient.getSecret(finalName, username, key); - } catch (GuacamoleException e) { - return CompletableFuture.failedFuture(e); - } - } - - return CompletableFuture.completedFuture(null); - }) - .thenCompose(Function.identity()); - } - } - else { - return client.getSecret(finalName, username, key) - .handle((value, ex) -> { - if (ex == null) { - return CompletableFuture.completedFuture(value); - } - - if (connectionClient != null) { - try { - return connectionClient.getSecret(finalName, username, key); - } catch (GuacamoleException e) { - return CompletableFuture.failedFuture(e); - } - } - - return CompletableFuture.failedFuture(ex); - }) - .thenCompose(Function.identity()) - .handle((value, ex) -> { - if (ex == null) { - return CompletableFuture.completedFuture(value); - } - - if (userClient != null) { - try { - return userClient.getSecret(finalName, username, key); - } catch (GuacamoleException e) { - return CompletableFuture.failedFuture(e); - } - } - - return CompletableFuture.completedFuture(null); - }) - .thenCompose(Function.identity()); - } + + return resolveSecret(clients, finalName, username, key); } /** @@ -389,215 +270,24 @@ public Future getValue( UserContext userContext, * If the secret cannot be retrieved due to an error. */ @Override - public Future getValue(String name) throws GuacamoleException { + public Future getValue(final String token) throws GuacamoleException { // Ensure modifiers are removed - name = name.replaceFirst(":(LOWER|UPPER|OPTIONAL)$", ""); + final String name = token.replaceFirst(":(LOWER|UPPER|OPTIONAL)$", ""); // Use the default HV configuration from guacamole.properties - VaultInfo vaultInfo = confService.new VaultInfo(confService.getVaultUri(), + final VaultInfo vaultInfo = confService.new VaultInfo( + confService.getVaultUri(), confService.getVaultToken(), confService.getVaultUsername(), confService.getVaultPassword()); - if (isVaultInfoValid(vaultInfo)) - return getClient(vaultInfo).getSecret(name, "", name.substring(0, name.lastIndexOf('/'))); - else - return null; - } - /** - * Returns true if the VaultInfo configuration seems valid - * - * @param vaultInfo - * The VaultInfo variable to test - * - * @return - * True is the value in non null, URI is set and at least one of - * token or username/password id set - */ - private Boolean isVaultInfoValid(VaultInfo vaultInfo) { - if (vaultInfo == null || vaultInfo.Uri == null || vaultInfo.Uri.toString().trim().isEmpty()) - return false; - if (vaultInfo.Token != null && !vaultInfo.Token.trim().isEmpty()) - return true; - if (vaultInfo.Username == null || vaultInfo.Username.trim().isEmpty()) - return false; - if (vaultInfo.Password != null && !vaultInfo.Password.trim().isEmpty()) - return true; - return false; - } - - /** - * Search for a HV configuration attribute, recursing up the connection group tree - * until a connection group with the appropriate attribute is found. If the HV config - * is found, it will be returned. If not, the default value from the config file will - * be returned. - * - * @param userContext - * The userContext associated with the connection or connection group. - * - * @param connectable - * A connection or connection group for which the tokens are being replaced. - * - * @return - * The value of the HV configuration attributes if found in the tree, the default - * HV config defined in guacamole.properties otherwise. - * - * @throws GuacamoleException - * If an error occurs while attempting to retrieve the HV config attribute, or if - * no HV config is found in the connection group tree, and the value is also not - * defined in the config file. - */ - @Nonnull - private HvClient getConnectionGroupHvClient(UserContext userContext, - Connectable connectable) throws GuacamoleException { - - // Check to make sure it's a usable type before proceeding - if (!(connectable instanceof Connection) && !(connectable instanceof ConnectionGroup)) { - logger.warn( - "Unsupported Connectable type: {}; skipping HV config lookup.", - connectable.getClass() - ); - - // Use the default value if searching is impossible - return getClient(confService.new VaultInfo(confService.getVaultUri(), - confService.getVaultToken(), - confService.getVaultUsername(), - confService.getVaultPassword())); - } - - // For connections, start searching the parent group for the HV config - // For connection groups, start searching the group directly - String parentIdentifier = (connectable instanceof Connection) - ? ((Connection) connectable).getParentIdentifier() - : ((ConnectionGroup) connectable).getIdentifier(); - - // Keep track of all group identifiers seen while recursing up the tree - // in case there's a cycle - if the same identifier is ever seen twice, - // the search is over. - Set observedIdentifiers = new HashSet<>(); - observedIdentifiers.add(parentIdentifier); - - // Use the unwrapped connection group directory to avoid HV config - // value sanitization - Directory connectionGroupDirectory = ( - (HvDirectory) userContext.getConnectionGroupDirectory() - ).getUnderlyingDirectory(); - - while (true) { - // Fetch the parent group, if one exists - ConnectionGroup group = connectionGroupDirectory.get(parentIdentifier); - if (group == null) - break; - - // If the current connection group has HV configuration attributes - // set to a non-empty value, return immediately - Map hvConfig = group.getAttributes(); - - if (hvConfig.get(HvAttributeService.HV_URI_ATTRIBUTE) == null) - break; - - VaultInfo vaultInfo = confService.new VaultInfo( - URI.create(hvConfig.get(HvAttributeService.HV_URI_ATTRIBUTE)), - hvConfig.get(HvAttributeService.HV_TOKEN_ATTRIBUTE), - hvConfig.get(HvAttributeService.HV_USERNAME_ATTRIBUTE), - hvConfig.get(HvAttributeService.HV_PASSWORD_ATTRIBUTE) - ); - - if (isVaultInfoValid(vaultInfo)) - return getClient(vaultInfo); - - // Otherwise, keep searching up the tree until an appropriate configuration is found - parentIdentifier = group.getParentIdentifier(); - - // If the parent is a group that's already been seen, this is a cycle, so there's no - // need to search any further - if (!observedIdentifiers.add(parentIdentifier)) - break; + final HvClient client = hvClientProvider.getHvClient(vaultInfo); + if (client != null) { + return client.getSecret(name, "", name.substring(0, name.lastIndexOf('/'))); } - - // If no HV configuration was ever found, use the default value - return getClient(confService.new VaultInfo(confService.getVaultUri(), - confService.getVaultToken(), - confService.getVaultUsername(), - confService.getVaultPassword())); - } - - /** - * Returns true if user-level HV configuration is enabled for the given - * Connectable, false otherwise. - * - * @param connectable - * The connectable to check for whether user-level HV configs are - * enabled. - * - * @return - * True if user-level HV configuration is enabled for the given - * Connectable, false otherwise. - */ - private boolean isHvUserConfigEnabled(Connectable connectable) { - - // User-level config is enabled IFF the appropriate attribute is set to true - if (connectable instanceof Attributes) - return HvAttributeService.TRUTH_VALUE.equals(((Attributes) connectable).getAttributes().get( - HvAttributeService.HV_USER_CONFIG_ENABLED_ATTRIBUTE)); - - // If there's no attributes to check, the user config cannot be enabled - return false; - - } - - /** - * Return the HV config blob for the current user IFF user HV configs - * are enabled globally, and are enabled for the given connectable. If no - * HV config exists for the given user or HV configs are not enabled, - * null will be returned. - * - * @param userContext - * The user context from which the current user should be fetched. - * - * @param connectable - * The connectable to which the connection is being established. This - * is the connection which will be checked to see if user HV configs - * are enabled. - * - * @return - * The value of the user HV configuration attributes if found in the tree, - * the default HV config defined in guacamole.properties otherwise. - * - * @throws GuacamoleException - * If an error occurs while attempting to fetch the HV config. - */ - private HvClient getUserHvClient(UserContext userContext, - Connectable connectable) throws GuacamoleException { - - // If user HV configs are enabled globally, and for the given connectable, - // return the user-specific HV config, if one exists - if (confService.getAllowUserConfig() && isHvUserConfigEnabled(connectable)) { - - // Get the underlying user, to avoid the KSM config sanitization - User self = (((HvDirectory) userContext.getUserDirectory()) - .getUnderlyingDirectory().get(userContext.self().getIdentifier())); - - // If the current user has HV configuration attributes - // set to a non-empty value, return immediately - Map hvConfig = self.getAttributes(); - - if (hvConfig.get(HvAttributeService.HV_URI_ATTRIBUTE) != null) { - VaultInfo vaultInfo = confService.new VaultInfo( - URI.create(hvConfig.get(HvAttributeService.HV_URI_ATTRIBUTE)), - hvConfig.get(HvAttributeService.HV_TOKEN_ATTRIBUTE), - hvConfig.get(HvAttributeService.HV_USERNAME_ATTRIBUTE), - hvConfig.get(HvAttributeService.HV_PASSWORD_ATTRIBUTE) - ); - - if (isVaultInfoValid(vaultInfo)) { - logger.debug("Using User Vault configuration"); - return getClient(vaultInfo); - } - } + else { + return null; } - - return null; } /* @@ -634,109 +324,39 @@ private HvClient getUserHvClient(UserContext userContext, * given configuration. */ @Override - public Map> getTokens(UserContext userContext, - Connectable connectable, GuacamoleConfiguration config, - TokenFilter filter) throws GuacamoleException { - - Map> tokens = new HashMap<>(); - Map parameters = config.getParameters(); - - // Create an application wide client - VaultInfo vaultInfo = confService.new VaultInfo(confService.getVaultUri(), - confService.getVaultToken(), - confService.getVaultUsername(), - confService.getVaultPassword()); - HvClient client = getClient(vaultInfo); - - // Create a connection group client - HvClient connectionClient = getConnectionGroupHvClient(userContext, connectable); - - // Configure per user client if configured - HvClient userClient = getUserHvClient(userContext, connectable); + public Map> getTokens(final UserContext userContext, + final Connectable connectable, final GuacamoleConfiguration config, + final TokenFilter filter) throws GuacamoleException { + + final Map> tokens = new ConcurrentHashMap<>(); + + // An ordered array of non null HvClients + final List clients = hvClientProvider.getHvClients(userContext, connectable); + if (clients.size() == 0) { + return tokens; + } // Remove optional token parameter modifier and match only our own tokens - Pattern tokenPattern = Pattern.compile("\\$\\{(" + client.VAULT_TOKEN_PREFIX + + final Pattern tokenPattern = Pattern.compile("\\$\\{(" + HvClient.VAULT_TOKEN_PREFIX + "(?:[^{}:]|:(?!(?:LOWER|UPPER|OPTIONAL)(?=\\}))|\\{(?:[^{}]|\\{[^{}]*\\})*\\})+" + ")(:(?:(LOWER|UPPER|OPTIONAL))(?=\\}))?\\}"); // To keep the tokens for the same connection associated with each other in the // cache, for tokens that might create a confusion, we cache them with a shared // key - String key = UUID.randomUUID().toString(); + final String key = UUID.randomUUID().toString(); // Resolve any tokens in the username for use in possible ssh certificate - String username = filter.filter(config.getParameter("username")); + final String username = filter.filter(config.getParameter("username")); - for (Map.Entry entry : parameters.entrySet()) { - Matcher tokenMatcher = tokenPattern.matcher(entry.getValue()); + final Map parameters = config.getParameters(); + for (final Map.Entry entry : parameters.entrySet()) { + final Matcher tokenMatcher = tokenPattern.matcher(entry.getValue()); while (tokenMatcher.find()) { - String notation = tokenMatcher.group(1); - String finalName = prepareToken(notation, userContext, config, filter); - - if (client == null) { - if (connectionClient == null) { - if (userClient == null) { - tokens.put(notation, null); - } - else { - tokens.put(notation, userClient.getSecret(finalName, username, key)); - } - } - else { - tokens.put(notation, connectionClient.getSecret(finalName, username, key) - .handle((value, ex) -> { - if (ex == null) { - return CompletableFuture.completedFuture(value); - } - - if (userClient != null) { - try { - return userClient.getSecret(finalName, username, key); - } catch (GuacamoleException e) { - return CompletableFuture.failedFuture(e); - } - } - - return CompletableFuture.completedFuture(null); - }) - .thenCompose(Function.identity())); - } - } - else { - tokens.put(notation, client.getSecret(finalName, username, key) - .handle((value, ex) -> { - if (ex == null) { - return CompletableFuture.completedFuture(value); - } - - if (connectionClient != null) { - try { - return connectionClient.getSecret(finalName, username, key); - } catch (GuacamoleException e) { - return CompletableFuture.failedFuture(e); - } - } - - return CompletableFuture.failedFuture(ex); - }) - .thenCompose(Function.identity()) - .handle((value, ex) -> { - if (ex == null) { - return CompletableFuture.completedFuture(value); - } - - if (userClient != null) { - try { - return userClient.getSecret(finalName, username, key); - } catch (GuacamoleException e) { - return CompletableFuture.failedFuture(e); - } - } - - return CompletableFuture.completedFuture(null); - }) - .thenCompose(Function.identity())); - } + final String notation = tokenMatcher.group(1); + final String finalName = prepareToken(notation, userContext, config, filter); + + tokens.put(notation, resolveSecret(clients, finalName, username, key)); } } @@ -757,39 +377,66 @@ public Map> getTokens(UserContext userContext, return tokens; } - + /** - * The LDAP session interface checks out sessions that then can not be - * used till they are checked in. In getTokens we have the problem that - * we don't have access to the tunnel ID and so can't identify it. - * Guacamole is also not guarenteed to generate a TunnelCloseEvent. + * Given a List of possible HvClients to use, attempt to resolve a secret value + * with the HvClient that returns a value * - * So this function is fragile and relies on the fact that the TunnelConnectEvent - * will be running a few tens of milliseconds after the getTokens command to - * limit the risk of confusing two connections. There is still a small risk - * of error here. This needs a better solution + * @param clients + * An ordered list of HvClient with application-wide client, ConnectGroup + * client followed by User client values, to ensure teh adminsitrator always + * has the last word on token resolution * - * @param event - * A TunnelConnectEvent or TunnelCloseEvent + * @param FinalName + * The HV notation of the secret to retrieve. + * + * @param username + * The connection username, that must be non null for SSH certificate + * generation. + * + * @param key + * A pseudo-unique key to use to stored cached secrets, to keep secrets + * associated with the same connection together, even if they vault token + * itself is not unique. + * + * @return + * A Future which completes with the value of the secret represented by + * the given HV notation, or null if there is no such secret. */ - static public void treatLdapSession(Object event) { - if (event instanceof TunnelConnectEvent) { - String id = ((TunnelConnectEvent) event).getTunnel().getUUID().toString(); - for (Map.Entry entry : hvClientMap.entrySet()) { - HvClient client = entry.getValue(); - if (client.treatLdapSession(client.VAULT_LDAP_SESSION, id)) { - break; + private Future resolveSecret(final List clients, final String finalName, + final String username, final String key) throws GuacamoleException { + return clients.get(0).getSecret(finalName, username, key) + .handle((value, ex) -> { + if (ex == null) { + return CompletableFuture.completedFuture(value); } - } - } - else { - String id = ((TunnelCloseEvent) event).getTunnel().getUUID().toString(); - for (Map.Entry entry : hvClientMap.entrySet()) { - HvClient client = entry.getValue(); - if (client.treatLdapSession(id, null)) { - break; + + if (clients.size() > 1) { + try { + return clients.get(1).getSecret(finalName, username, key); + } catch (GuacamoleException e) { + return CompletableFuture.failedFuture(e); + } } - } - } + + return CompletableFuture.failedFuture(ex); + }) + .thenCompose(Function.identity()) + .handle((value, ex) -> { + if (ex == null) { + return CompletableFuture.completedFuture(value); + } + + if (clients.size() > 2) { + try { + return clients.get(2).getSecret(finalName, username, key); + } catch (GuacamoleException e) { + return CompletableFuture.failedFuture(e); + } + } + + return CompletableFuture.completedFuture(null); + }) + .thenCompose(Function.identity()); } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSshKeys.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSshKeys.java index 120d900bb8..8682cbaa2d 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSshKeys.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSshKeys.java @@ -20,18 +20,35 @@ package org.apache.guacamole.vault.hv.secret; import java.io.ByteArrayOutputStream; +import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.KeyPair; import java.security.KeyPairGenerator; +import java.security.GeneralSecurityException; import org.apache.guacamole.vault.hv.conf.HvConfigurationService; -import org.apache.sshd.common.keyprovider.KeyPairProvider; import org.apache.sshd.common.config.keys.writer.openssh.OpenSSHKeyPairResourceWriter; import org.apache.sshd.common.config.keys.PublicKeyEntry; import org.apache.sshd.common.util.security.SecurityUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +/** + * A Class using Apache MINA SSHD to create temporary SSH keys and + * corresponding public certificates. It supports the creation of + * RSA-4096 and ed25519 keys only. + */ public class HvSshKeys { + /** + * The value of vailt-ssh-type to use for RSA certificate + */ + public static final String RSA = "rsa"; + + + /** + * The value of vailt-ssh-type to use for ED25519 certificate + */ + public static final String ED25519 = "ed25519"; + /** * Logger for this class. */ @@ -40,7 +57,7 @@ public class HvSshKeys { /** * The PEM encoded private SSH key */ - public final String privateSshPem; + private final String privateSsh; /** * The OpenSSH encoded public SSH key @@ -61,13 +78,13 @@ public HvSshKeys() { * The type of ssh key to generate. Can be "rsa" or "ed25519" only. * Generated RSA keys are 4096 bit only */ - public HvSshKeys(String type) { - KeyPair keyPair; + public HvSshKeys(final String type) { + final KeyPair keyPair; - if ("rsa".equals(type)) { + if (RSA.equals(type)) { keyPair = generateRsa(); } - else if ("ed25519".equals(type)) { + else if (ED25519.equals(type)) { keyPair = generateEd25519WithFallback(); } else { @@ -76,15 +93,15 @@ else if ("ed25519".equals(type)) { try { this.publicSsh = PublicKeyEntry.toString(keyPair.getPublic()); - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - OpenSSHKeyPairResourceWriter writer = + final ByteArrayOutputStream baos = new ByteArrayOutputStream(); + final OpenSSHKeyPairResourceWriter writer = new OpenSSHKeyPairResourceWriter(); writer.writePrivateKey(keyPair, null, null, baos); - this.privateSshPem = + this.privateSsh = baos.toString(StandardCharsets.UTF_8); } - catch (Exception e) { - throw new IllegalStateException("Failed to serialize SSH keypair: " + e.getMessage(), e); + catch (IOException | GeneralSecurityException | IllegalStateException e) { + throw new IllegalStateException("Failed to serialize SSH keypair", e); } } @@ -95,15 +112,17 @@ else if ("ed25519".equals(type)) { * A java.security.KeyPair containing the ed25519 key pair or RSA if failure */ private KeyPair generateEd25519WithFallback() { + KeyPair keyPair; try { - KeyPairGenerator keyPairGenerator = - SecurityUtils.getKeyPairGenerator("EdDSA"); - return keyPairGenerator.generateKeyPair(); + final KeyPairGenerator keyPairGenerator = SecurityUtils.getKeyPairGenerator("EdDSA"); + keyPair = keyPairGenerator.generateKeyPair(); } - catch (Exception e) { + catch (GeneralSecurityException e) { logger.warn("Ed25519 not available via SSHD EdDSA. Falling back to RSA : {}", e.getMessage()); - return generateRsa(); + keyPair = generateRsa(); } + + return keyPair; } /** @@ -114,13 +133,33 @@ private KeyPair generateEd25519WithFallback() { */ private KeyPair generateRsa() { try { - KeyPairGenerator keyPairGenerator = + final KeyPairGenerator keyPairGenerator = SecurityUtils.getKeyPairGenerator("RSA"); keyPairGenerator.initialize(4096); return keyPairGenerator.generateKeyPair(); } - catch (Exception e) { + catch (GeneralSecurityException e) { throw new IllegalStateException("Failed to generate RSA SSH keypair", e); } } + + /** + * Returns the generated SSH public certificate + * + * @return + * Return the public certificate in PEM format + */ + public String getPublic() { + return publicSsh; + } + + /** + * Returns the generated SSH private key + * + * @return + * Return the private key in OpenSSH's format + */ + public String getPrivate() { + return privateSsh; + } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvTunnelEventListener.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvTunnelEventListener.java index fdeb7855e9..fc867e7730 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvTunnelEventListener.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvTunnelEventListener.java @@ -19,20 +19,24 @@ package org.apache.guacamole.vault.hv.secret; -import com.google.inject.Inject; -import com.google.inject.Provider; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.net.event.listener.Listener; import org.apache.guacamole.net.event.TunnelCloseEvent; import org.apache.guacamole.net.event.TunnelConnectEvent; -import org.apache.guacamole.vault.hv.secret.HvSecretService; +/** + * Listen for TunnelConnectEvent and TunnelCLoseEvent and pass to + * HvClientProvider to see if they correspond to an active ldap + * service account connection + */ public class HvTunnelEventListener implements Listener { /** * Default constructor for ProviderFactory */ - public HvTunnelEventListener() { } + public HvTunnelEventListener() { + // Constructor deliberately empty + } /** * The LDAP session interface checks out session that then can not be @@ -43,9 +47,9 @@ public HvTunnelEventListener() { } * A tunnel close event */ @Override - public void handleEvent(Object event) throws GuacamoleException { + public void handleEvent(final Object event) throws GuacamoleException { if (event instanceof TunnelConnectEvent || event instanceof TunnelCloseEvent) { - HvSecretService.treatLdapSession(event); + HvClientProvider.treatLdapSession(event); } } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnection.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnection.java index 2a1eb1f52d..aef38eabbb 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnection.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnection.java @@ -40,7 +40,7 @@ public class HvConnection extends DelegatingConnection { * @param connection * The connection record to wrap. */ - HvConnection(Connection connection) { + public HvConnection(final Connection connection) { super(connection); } @@ -50,7 +50,7 @@ public class HvConnection extends DelegatingConnection { * @return * The wrapped connection record. */ - Connection getUnderlyingConnection() { + public Connection getUnderlyingConnection() { return getDelegateConnection(); } @@ -58,7 +58,7 @@ Connection getUnderlyingConnection() { public Map getAttributes() { // Make a copy of the existing map - Map attributes = Maps.newHashMap(super.getAttributes()); + final Map attributes = Maps.newHashMap(super.getAttributes()); // Add the user-config-enabled configuration attribute attributes.putIfAbsent(HvAttributeService.HV_USER_CONFIG_ENABLED_ATTRIBUTE, null); diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnectionGroup.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnectionGroup.java index 1c57b4c561..b8e939661c 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnectionGroup.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnectionGroup.java @@ -20,9 +20,7 @@ package org.apache.guacamole.vault.hv.user; import com.google.common.collect.Maps; -import java.util.HashMap; import java.util.Map; -import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.net.auth.ConnectionGroup; import org.apache.guacamole.net.auth.DelegatingConnectionGroup; import org.apache.guacamole.vault.hv.conf.HvAttributeService; @@ -50,7 +48,7 @@ public class HvConnectionGroup extends DelegatingConnectionGroup { * @param connectionGroup * The ConnectionGroup record to wrap. */ - HvConnectionGroup(ConnectionGroup connectionGroup) { + public HvConnectionGroup(final ConnectionGroup connectionGroup) { super(connectionGroup); } @@ -60,7 +58,7 @@ public class HvConnectionGroup extends DelegatingConnectionGroup { * @return * The wrapped connection group record. */ - ConnectionGroup getUnderlyingConnectionGroup() { + public ConnectionGroup getUnderlyingConnectionGroup() { return getDelegateConnectionGroup(); } @@ -70,7 +68,7 @@ ConnectionGroup getUnderlyingConnectionGroup() { * @return * The underlying ConnectionGroup that's wrapped by this HvConnectionGroup. */ - ConnectionGroup getUnderlyConnectionGroup() { + public ConnectionGroup getUnderlyConnectionGroup() { return getDelegateConnectionGroup(); } @@ -78,7 +76,7 @@ ConnectionGroup getUnderlyConnectionGroup() { public Map getAttributes() { // Make a copy of the existing map - Map attributes = Maps.newHashMap(super.getAttributes()); + final Map attributes = Maps.newHashMap(super.getAttributes()); attributes.put( HvAttributeService.HV_URI_ATTRIBUTE, @@ -98,17 +96,4 @@ public Map getAttributes() { ); return attributes; } - - @Override - public void setAttributes(Map attributes) { - try { - super.setAttributes( - HvAttributeService.processAttributes(attributes) - ); - } - catch (GuacamoleException e) { - logger.warn("HvConnectionGroup setAttributes failed"); - } - } - } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectory.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectory.java index eeb244eb4a..bbe3d265a2 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectory.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectory.java @@ -30,8 +30,8 @@ * A HV-specific version of DecoratingDirectory that exposes the underlying * directory for when it's needed. */ -public abstract class HvDirectory - extends DelegatingDirectory { +public abstract class HvDirectory + extends DelegatingDirectory { /** * Create a new HvDirectory, delegating to the provided directory. @@ -39,7 +39,7 @@ public abstract class HvDirectory * @param directory * The directory to delegate to. */ - public HvDirectory(Directory directory) { + public HvDirectory(final Directory directory) { super(directory); } @@ -50,7 +50,7 @@ public HvDirectory(Directory directory) { * @return * The underlying directory. */ - public Directory getUnderlyingDirectory() { + public Directory getUnderlyingDirectory() { return getDelegateDirectory(); } @@ -65,10 +65,10 @@ public Directory getUnderlyingDirectory() { * A potentially-modified version of the object with the same * identifier in the wrapped directory. */ - protected abstract ObjectType wrap(ObjectType object); + protected abstract T wrap(T object); @Override - public ObjectType get(String identifier) throws GuacamoleException { + public T get(final String identifier) throws GuacamoleException { // Process and return the object from the wrapped directory return wrap(super.get(identifier)); @@ -76,7 +76,7 @@ public ObjectType get(String identifier) throws GuacamoleException { } @Override - public Collection getAll(Collection identifiers) + public Collection getAll(final Collection identifiers) throws GuacamoleException { // Process and return each object from the wrapped directory diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectoryService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectoryService.java index c84f43effd..38fd3a4e0e 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectoryService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectoryService.java @@ -27,10 +27,7 @@ import org.apache.guacamole.net.auth.Directory; import org.apache.guacamole.net.auth.User; import org.apache.guacamole.vault.hv.conf.HvAttributeService; -import org.apache.guacamole.vault.hv.user.HvConnectionGroup; import org.apache.guacamole.vault.user.VaultDirectoryService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * A HV-specific vault directory service that wraps the connection group directory @@ -38,16 +35,11 @@ */ public class HvDirectoryService extends VaultDirectoryService { - /** - * Logger for this class. - */ - private static final Logger logger = LoggerFactory.getLogger(HvDirectoryService.class); - /** * A factory for constructing new HvUser instances. */ @Inject - private HvUserFactory hvUserFactory; + private HvUser.HvUserFactory hvUserFactory; /** * Service for retrieving any custom attributes defined for the @@ -57,17 +49,17 @@ public class HvDirectoryService extends VaultDirectoryService { private HvAttributeService attributeService; @Override - public Directory getConnectionDirectory(Directory underlyingDirectory) throws GuacamoleException { + public Directory getConnectionDirectory(final Directory underlyingDirectory) throws GuacamoleException { return new DecoratingDirectory(underlyingDirectory) { @Override - protected Connection decorate(Connection connection) throws GuacamoleException { + protected Connection decorate(final Connection connection) throws GuacamoleException { return new HvConnection(connection); } @Override - protected Connection undecorate(Connection connection) throws GuacamoleException { + protected Connection undecorate(final Connection connection) throws GuacamoleException { return ((HvConnection) connection).getUnderlyingConnection(); } @@ -75,7 +67,7 @@ protected Connection undecorate(Connection connection) throws GuacamoleException } @Override - public Directory getConnectionGroupDirectory(Directory underlyingDirectory) throws GuacamoleException { + public Directory getConnectionGroupDirectory(final Directory underlyingDirectory) throws GuacamoleException { // A ConnectionGroup directory that will intercept add and update calls to // validate HV configurations, and translate one-time-tokens, if possible, // as well as ensuring that all ConnectionGroups returned include the @@ -84,7 +76,7 @@ public Directory getConnectionGroupDirectory(Directory(underlyingDirectory) { @Override - public void add(ConnectionGroup connectionGroup) throws GuacamoleException { + public void add(final ConnectionGroup connectionGroup) throws GuacamoleException { // Process attribute values before saving connectionGroup.setAttributes(connectionGroup.getAttributes()); @@ -93,47 +85,33 @@ public void add(ConnectionGroup connectionGroup) throws GuacamoleException { } @Override - public void update(ConnectionGroup connectionGroup) throws GuacamoleException { - - // Unwrap the existing ConnectionGroup - if (connectionGroup instanceof HvConnectionGroup) - connectionGroup = ((HvConnectionGroup) connectionGroup).getUnderlyingConnectionGroup(); - + public void update(final ConnectionGroup connectionGroup) throws GuacamoleException { // Process attribute values before saving connectionGroup.setAttributes(connectionGroup.getAttributes()); super.update(connectionGroup); - } @Override - protected ConnectionGroup wrap(ConnectionGroup object) { - - // Do not process the ConnectionGroup further if it does not exist - if (object == null) - return null; - + protected ConnectionGroup wrap(final ConnectionGroup object) { // Sanitize values when a ConnectionGroup is fetched from the directory - return new HvConnectionGroup(object); - + // Do not process the ConnectionGroup further if it does not exist + return (object == null) ? null : new HvConnectionGroup(object); } }; } @Override - public Directory getUserDirectory(Directory underlyingDirectory) throws GuacamoleException { + public Directory getUserDirectory(final Directory underlyingDirectory) throws GuacamoleException { // A User directory that will intercept add and update calls to - // validate HV configurations, and translate one-time-tokens, if possible - // Additionally, this directory will will decorate all users with a - // HvUser wrapper to ensure that all defined HV fields will be exposed - // in the user attributes. The value of the HV_CONFIGURATION_ATTRIBUTE - // will be sanitized if set. + // validate HV configurations. Additionally, this directory will will + // decorate all users with a HvUser wrapper to ensure that all defined + // HV fields will be exposed in the user attributes. return new HvDirectory(underlyingDirectory) { @Override - public void add(User user) throws GuacamoleException { - + public void add(final User user) throws GuacamoleException { // Process attribute values before saving user.setAttributes(user.getAttributes()); @@ -141,11 +119,13 @@ public void add(User user) throws GuacamoleException { } @Override - public void update(User user) throws GuacamoleException { + public void update(final User oldUser) throws GuacamoleException { + User user = oldUser; // Unwrap the existing user - if (user instanceof HvUser) + if (user instanceof HvUser) { user = ((HvUser) user).getUnderlyingUser(); + } // Process attribute values before saving user.setAttributes(user.getAttributes()); @@ -155,14 +135,9 @@ public void update(User user) throws GuacamoleException { } @Override - protected User wrap(User object) { - - // Do not process the user further if it does not exist - if (object == null) - return null; - - // Sanitize values when a user is fetched from the directory - return hvUserFactory.create(object); + protected User wrap(final User object) { + // If null, do not process the user further if it does not exist + return (object == null) ? null : hvUserFactory.create(object); } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUser.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUser.java index c4bd7d1dfa..6d67d7ae67 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUser.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUser.java @@ -61,7 +61,7 @@ public class HvUser extends DelegatingUser { * The User record to wrap. */ @AssistedInject - HvUser(@Assisted User user) { + public HvUser(@Assisted final User user) { super(user); } @@ -71,7 +71,7 @@ public class HvUser extends DelegatingUser { * @return * The wrapped user record. */ - User getUnderlyingUser() { + public User getUnderlyingUser() { return getDelegateUser(); } @@ -79,31 +79,24 @@ User getUnderlyingUser() { public Map getAttributes() { // Make a copy of the existing map - Map attributes = Maps.newHashMap(super.getAttributes()); + final Map attributes = Maps.newHashMap(super.getAttributes()); - // Figure out if user-level HV config is enabled + // Figure out if user-level HV configuration is enabled boolean userHvConfigEnabled = false; try { - userHvConfigEnabled = configurationService.getAllowUserConfig(); + userHvConfigEnabled = configurationService.allowUserConfig(); } catch (GuacamoleException e) { - logger.warn( "Disabling user HV config due to exception: {}" , e.getMessage()); - logger.debug("Error looking up if user HV config is enabled.", e); + logger.debug("Error looking up if user HV config is enabled. {}", e); } // If user-specific HV configuration is not enabled, do not expose the // attribute at all - if (!userHvConfigEnabled) { - attributes.remove(HvAttributeService.HV_URI_ATTRIBUTE); - attributes.remove(HvAttributeService.HV_TOKEN_ATTRIBUTE); - attributes.remove(HvAttributeService.HV_USERNAME_ATTRIBUTE); - attributes.remove(HvAttributeService.HV_PASSWORD_ATTRIBUTE); - } - else { + if (userHvConfigEnabled) { attributes.put( HvAttributeService.HV_URI_ATTRIBUTE, attributes.get(HvAttributeService.HV_URI_ATTRIBUTE) @@ -120,21 +113,32 @@ public Map getAttributes() { HvAttributeService.HV_PASSWORD_ATTRIBUTE, attributes.get(HvAttributeService.HV_PASSWORD_ATTRIBUTE) ); - } + else { + attributes.remove(HvAttributeService.HV_URI_ATTRIBUTE); + attributes.remove(HvAttributeService.HV_TOKEN_ATTRIBUTE); + attributes.remove(HvAttributeService.HV_USERNAME_ATTRIBUTE); + attributes.remove(HvAttributeService.HV_PASSWORD_ATTRIBUTE); + } + return attributes; } - @Override - public void setAttributes(Map attributes) { - try { - super.setAttributes( - HvAttributeService.processAttributes(attributes) - ); - } - catch (GuacamoleException e) { - logger.warn("HvUser setAttributes failed"); - } - } + /** + * Factory for creating HV-specific users, which wrap an underlying User. + */ + public interface HvUserFactory { + + /** + * Returns a new instance of a HvUser, wrapping the provided underlying User. + * + * @param user + * The underlying User that should be wrapped. + * + * @return + * A new instance of a HvUser, wrapping the provided underlying User. + */ + HvUser create(User user); + } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUserFactory.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUserFactory.java deleted file mode 100644 index e27c84ad19..0000000000 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUserFactory.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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 org.apache.guacamole.vault.hv.user; - -import org.apache.guacamole.net.auth.User; - -/** - * Factory for creating HV-specific users, which wrap an underlying User. - */ -public interface HvUserFactory { - - /** - * Returns a new instance of a HvUser, wrapping the provided underlying User. - * - * @param user - * The underlying User that should be wrapped. - * - * @return - * A new instance of a HvUser, wrapping the provided underlying User. - */ - HvUser create(User user); - -} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/FileTokenAuthentication.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/FileTokenAuthentication.java index 7033245821..2f7dbd9e44 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/FileTokenAuthentication.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/FileTokenAuthentication.java @@ -27,6 +27,10 @@ import org.springframework.vault.authentication.ClientAuthentication; import org.springframework.vault.support.VaultToken; +/** + * A spring-vault ClientAuthentication class for VaultAgent sink + * files + */ public final class FileTokenAuthentication implements ClientAuthentication { /** @@ -42,7 +46,7 @@ public final class FileTokenAuthentication implements ClientAuthentication { * @param String tokenPath * A path to a readable file containing the token */ - public FileTokenAuthentication(String tokenPath) { + public FileTokenAuthentication(final String tokenPath) { this.tokenPath = Path.of(tokenPath); } @@ -54,29 +58,21 @@ public FileTokenAuthentication(String tokenPath) { */ @Override public VaultToken login() { - String token; - try { - if (Files.isSymbolicLink(tokenPath)) { - throw new VaultException("Refusing to use symbolic link for token sink file: " + tokenPath); - } + if (Files.isSymbolicLink(tokenPath)) { + throw new VaultException("Refusing to use symbolic link for token sink file: " + tokenPath); + } - if (!Files.isRegularFile(tokenPath)) { - throw new VaultException("Token sink path must be a regular file: " + tokenPath); - } + if (!Files.isRegularFile(tokenPath)) { + throw new VaultException("Token sink path must be a regular file: " + tokenPath); + } - // I allow group readable but maybe I shouldn't - if (Files.isReadable(tokenPath) && - Files.getPosixFilePermissions(tokenPath).contains(PosixFilePermission.OTHERS_READ)) { - throw new VaultException("Refusing to use a world readable token sink file : " + tokenPath); + try { + if (! Files.getPosixFilePermissions(tokenPath).contains(PosixFilePermission.OTHERS_READ)) { + return VaultToken.of(Files.readString(tokenPath).trim()); } - - token = Files.readString(tokenPath).trim(); + } catch (IOException e) { + throw new VaultException("Cannot read or inspect Vault token sink: " + tokenPath, e); } - catch (IOException e) { - // This might be recoverable. So throw a VautException - throw new VaultException("Cannot read Vault token sink: " + tokenPath, e); - } - - return VaultToken.of(token); + throw new VaultException("Refusing to use a world readable token sink file: " + tokenPath); } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/TtlAwareSessionManager.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/TtlAwareSessionManager.java index e8e914d659..7d723ca732 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/TtlAwareSessionManager.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/TtlAwareSessionManager.java @@ -22,6 +22,7 @@ import java.time.Duration; import java.time.Instant; import java.util.Map; +import java.util.Optional; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.atomic.AtomicReference; import org.springframework.http.HttpEntity; @@ -33,6 +34,7 @@ import org.springframework.vault.authentication.ClientAuthentication; import org.springframework.vault.authentication.LoginToken; import org.springframework.vault.authentication.SessionManager; +import org.springframework.vault.authentication.VaultLoginException; import org.springframework.vault.client.VaultHttpHeaders; import org.springframework.vault.support.VaultResponse; import org.springframework.vault.support.VaultToken; @@ -50,7 +52,12 @@ * - Skip renewal for tokens with TTL=0 (non-expiring tokens) * - Handle both LoginToken and VaultToken types. */ -public class TtlAwareSessionManager implements SessionManager { +public final class TtlAwareSessionManager implements SessionManager { + /** + * The outcome of the renew token process + */ + private enum Outcome { CONTINUE, REAUTH, STOP } + /** * Logger for this class. */ @@ -111,10 +118,10 @@ public class TtlAwareSessionManager implements SessionManager { * should occur. */ public TtlAwareSessionManager( - ClientAuthentication clientAuthentication, - RestOperations restOperations, - TaskScheduler scheduler, - long renewalDelayMillis) { + final ClientAuthentication clientAuthentication, + final RestOperations restOperations, + final TaskScheduler scheduler, + final long renewalDelayMillis) { this.clientAuthentication = clientAuthentication; this.restOperations = restOperations; @@ -123,16 +130,18 @@ public TtlAwareSessionManager( // Obtain the initial token and schedule its renewal try { - VaultToken token = clientAuthentication.login(); - setSessionToken(token); + final VaultToken token = clientAuthentication.login(); if (token != null && !token.getToken().isEmpty()) { + this.token.set(token); + scheduleRenewal(token); logger.info("TtlAwareSessionManager initialized with renewal delay of {} ms", renewalDelayMillis); } } - catch (RuntimeException e) { - logger.error("Non recoverable error initializing TtlAwareSessionManager : {}", e.getMessage(), e); + catch (VaultLoginException e) { + // An Authentication exception at this point is non recoverable + logger.error("Non recoverable error initializing TtlAwareSessionManager : {}", e.getMessage()); } - catch (Exception e) { + catch (VaultException e) { // Recoverable error: reschedule authentication scheduleRenewal(null); } @@ -154,7 +163,7 @@ public void close() { * or in case of unrecoverable errors or non-expiring tokens. */ private void stopScheduling() { - ScheduledFuture future = scheduledRenewal.getAndSet(null); + final ScheduledFuture future = scheduledRenewal.getAndSet(null); if (future != null) { future.cancel(false); } @@ -168,7 +177,7 @@ private void stopScheduling() { * @param token * The token to schedule renewal for. */ - private void scheduleRenewal(VaultToken token) { + private void scheduleRenewal(final VaultToken token) { // Cancel any existing scheduled renewal stopScheduling(); @@ -176,45 +185,56 @@ private void scheduleRenewal(VaultToken token) { if (token == null || token.getToken().isEmpty()) { // Vault not ready or available ? Try again in 10 seconds logger.debug("Null token detected. Vault not ready ? Rescheduling authentication"); - scheduledRenewal.set(scheduler.schedule(this::renewTokenAsync, Instant.now().plusMillis(10000))); - return; + scheduledRenewal.set(scheduler.schedule(this::renewTokenAsync, Instant.now().plusMillis(10_000))); } + else { + try { + final TokenInfo tokenInfo = getTokenInfo(token); - try { - TokenInfo tokenInfo = getTokenInfo(token); - - // Skip scheduling if TTL is 0 (non-expiring token) - if (tokenInfo.ttlSeconds == 0) { - logger.info("Skipping renewal scheduling for non-expiring token"); - return; - } + // Skip scheduling if TTL is 0 (non-expiring token) + if (tokenInfo.ttlSeconds == 0) { + logger.info("Skipping renewal scheduling for non-expiring token"); + return; + } - long delay = calculateDelayUntilRenewal(tokenInfo.ttlSeconds); + final long delay = calculateDelayUntilRenewal(tokenInfo.ttlSeconds); - if (delay <= 0) { - // Token is already past renewal threshold; renew immediately - logger.debug("Token is past renewal threshold, renewing immediately"); - renewTokenAsync(); + if (delay <= 0) { + // Token is already past renewal threshold; renew immediately + logger.debug("Token is past renewal threshold, renewing immediately"); + renewTokenAsync(); + } + else { + logger.debug("Scheduling token renewal in {} ms", delay); + scheduledRenewal.set(scheduler.schedule(this::renewTokenAsync, Instant.now().plusMillis(delay))); + } } - else { - logger.debug("Scheduling token renewal in {} ms", delay); - scheduledRenewal.set(scheduler.schedule(this::renewTokenAsync, Instant.now().plusMillis(delay))); + catch (VaultException e) { + logger.warn("Failed to get token information, attempting immediate renewal: {}", e.getMessage()); + renewTokenAsync(); } } - catch (VaultException e) { - logger.warn("Failed to get token information, attempting immediate renewal: {}", e.getMessage()); - renewTokenAsync(); - } } /** * Contains token information extracted from Vault responses. */ - private static class TokenInfo { - final long ttlSeconds; - final boolean renewable; - - TokenInfo(long ttlSeconds, boolean renewable) { + public static class TokenInfo { + /** The time-to-live of a token*/ + public final long ttlSeconds; + /** Whether the token is renewable or not */ + public final boolean renewable; + + /** + * Constructor for token information extrcted from Vault responses + * + * @param ttlSeconds + * The number of second till the token expires, or zero is non expirying + * + * @param renewable + * Boolean flag of whether the token is renewable + */ + public TokenInfo(final long ttlSeconds, final boolean renewable) { this.ttlSeconds = ttlSeconds; this.renewable = renewable; } @@ -231,51 +251,58 @@ private static class TokenInfo { * * @throws VaultException If the token lookup fails. */ - private TokenInfo getTokenInfo(VaultToken token) { + private TokenInfo getTokenInfo(final VaultToken token) { + final long ttlSeconds; + final boolean isRenewable; if (token instanceof LoginToken) { - LoginToken loginToken = (LoginToken) token; - Duration leaseDuration = loginToken.getLeaseDuration(); - long ttlSeconds = leaseDuration != null ? leaseDuration.getSeconds() : 0; - boolean renewable = loginToken.isRenewable(); + final LoginToken loginToken = (LoginToken) token; + final Duration leaseDuration = loginToken.getLeaseDuration(); + ttlSeconds = leaseDuration != null ? leaseDuration.getSeconds() : 0; + isRenewable = loginToken.isRenewable(); - logger.debug("Token is already a LoginToken. TTL: {}, Renewable: {}", ttlSeconds, renewable); - return new TokenInfo(ttlSeconds, renewable); + logger.debug("Token is already a LoginToken. TTL: {}, Renewable: {}", ttlSeconds, isRenewable); } - - ResponseEntity response; - - try { + else { logger.debug("Looking up token information"); - HttpHeaders headers = new HttpHeaders(); + final HttpHeaders headers = new HttpHeaders(); headers.set("X-Vault-Token", token.getToken()); - HttpEntity requestEntity = new HttpEntity<>(headers); + final HttpEntity requestEntity = new HttpEntity<>(headers); - response = restOperations.exchange("/auth/token/lookup-self", - HttpMethod.GET, requestEntity, Map.class); - } - catch (RestClientException e) { - throw new VaultException("Vault request failed: " + e.getMessage()); - } + final ResponseEntity response; + try { + response = restOperations.exchange( + "/auth/token/lookup-self", + HttpMethod.GET, + requestEntity, + Map.class + ); + } + catch (RestClientException e) { + throw new VaultException("Vault request failed: " + e.getMessage(), e); + } - if (response == null || response.getBody() == null - || !(response.getBody().get("data") instanceof Map)) { - throw new VaultException("Failed to lookup token: no response data"); + @SuppressWarnings("unchecked") + final Map data = + Optional.ofNullable(response) + .map(ResponseEntity::getBody) + .map(body -> body.get("data")) + .filter(Map.class::isInstance) + .map(Map.class::cast) + .orElseThrow(() -> + new VaultException("Failed to lookup token: no response data") + ); + + final Object val = data.get("ttl"); + isRenewable = Boolean.TRUE.equals(data.get("renewable")); + ttlSeconds = val instanceof Number ? ((Number) val).longValue() : 0L; + + logger.debug("Token lookup successful. TTL: {}, Renewable: {}", ttlSeconds, isRenewable); } - @SuppressWarnings("unchecked") - Map data = (Map) response.getBody().get("data"); - Number ttl = (Number) data.get("ttl"); - Boolean renewable = (Boolean) data.get("renewable"); - - long ttlSeconds = ttl != null ? ttl.longValue() : 0; - boolean isRenewable = renewable != null && renewable; - - logger.debug("Token lookup successful. TTL: {}, Renewable: {}", ttlSeconds, isRenewable); - return new TokenInfo(ttlSeconds, isRenewable); - } + } /** * Calculates the delay until the token should be renewed. @@ -284,9 +311,9 @@ private TokenInfo getTokenInfo(VaultToken token) { * * @return The delay in milliseconds until renewal should occur. */ - private long calculateDelayUntilRenewal(long ttlSeconds) { - long expiryTime = System.currentTimeMillis() + (ttlSeconds * 1000); - long delay = (expiryTime - renewalDelayMillis) - System.currentTimeMillis(); + private long calculateDelayUntilRenewal(final long ttlSeconds) { + final long expiryTime = System.currentTimeMillis() + (ttlSeconds * 1000); + final long delay = expiryTime - renewalDelayMillis - System.currentTimeMillis(); logger.debug("Calculated renewal delay: {} ms for token with TTL: {} seconds", delay, ttlSeconds); @@ -299,86 +326,84 @@ private long calculateDelayUntilRenewal(long ttlSeconds) { * re-authenticates to get a new token. */ private void renewTokenAsync() { + final VaultToken currentToken = getSessionToken(); - try { - VaultToken currentToken = getSessionToken(); - - if (currentToken == null || currentToken.getToken().isEmpty()) { - logger.debug("No current token to renew, attempting re-authentication"); - attemptLogin(); - return; - } + if (currentToken == null || currentToken.getToken().isEmpty()) { + logger.debug("No current token to renew, attempting re-authentication"); + attemptLogin(); + return; + } - TokenInfo tokenInfo; + TokenInfo tokenInfo; - try { - tokenInfo = getTokenInfo(currentToken); - } - catch (VaultException e) { - logger.debug("Token lookup fail, attempting re-authentication : " + e.getMessage()); - attemptLogin(); - return; - } - catch (Exception e) { - logger.error("Non-recoverable error during token lookup: {}", e.getMessage(), e); - stopScheduling(); - return; - } - - if (tokenInfo.ttlSeconds == 0) { - logger.info("Token TTL is zero. Stop renewal for non-expiring tokens"); - stopScheduling(); - return; - } + try { + tokenInfo = getTokenInfo(currentToken); + } + catch (VaultException e) { + logger.debug("Token lookup fail, attempting re-authentication : " + e.getMessage()); + attemptLogin(); + return; + } + catch (ClassCastException | IllegalArgumentException e) { + logger.error("Non-recoverable error during token lookup: {}", e.getMessage()); + stopScheduling(); + return; + } - VaultToken newToken; + if (tokenInfo.ttlSeconds == 0) { + logger.info("Token TTL is zero. Stop renewal for non-expiring tokens"); + stopScheduling(); + return; + } - if (tokenInfo.renewable) { - try { - logger.debug("Renewing token"); - newToken = renewToken(currentToken); - } - catch (VaultException e) { - logger.warn("Token renewal failed, re-authenticating: {}", e.getMessage()); - attemptLogin(); - return; - } - catch (Exception e) { - logger.error("Non-recoverable error during token renewal: {}", e.getMessage(), e); - stopScheduling(); - return; - } - } - else { - logger.debug("Re-authenticating for non-renewable token"); - attemptLogin(); - return; - } + final VaultToken newToken; + if (tokenInfo.renewable) { try { - tokenInfo = getTokenInfo(newToken); + logger.debug("Renewing token"); + newToken = renewToken(currentToken); } catch (VaultException e) { - logger.debug("Token lookup fail, attempting re-authentication : " + e.getMessage()); + logger.warn("Token renewal failed, re-authenticating: {}", e.getMessage()); attemptLogin(); return; } - - long delay = calculateDelayUntilRenewal(tokenInfo.ttlSeconds); - if (delay <= 0) { - // Token is already past renewal threshold; renew immediately - logger.info("Token is past renewal threshold during renewal, re-authenticating"); - attemptLogin(); + catch (ClassCastException | IllegalArgumentException e) { + logger.error("Non-recoverable error during token renewal: {}", e.getMessage()); + stopScheduling(); return; } + } + else { + logger.debug("Re-authenticating for non-renewable token"); + attemptLogin(); + return; + } - logger.debug("Successfully renewed the token"); - setSessionToken(newToken); + try { + tokenInfo = getTokenInfo(newToken); + } + catch (VaultException e) { + logger.debug("Token lookup fail, attempting re-authentication : " + e.getMessage()); + attemptLogin(); + return; } - catch (Exception e) { - logger.error("Unexplained failure to renew token : {}", e.getMessage(), e); + catch (ClassCastException | IllegalArgumentException e) { + logger.error("Non-recoverable error during token renewal: {}", e.getMessage()); stopScheduling(); + return; + } + + final long delay = calculateDelayUntilRenewal(tokenInfo.ttlSeconds); + if (delay <= 0) { + // Token is already past renewal threshold; renew immediately + logger.info("Token is past renewal threshold during renewal, re-authenticating"); + attemptLogin(); + return; } + + logger.debug("Successfully renewed the token"); + setSessionToken(newToken); } /** @@ -394,35 +419,34 @@ private void renewTokenAsync() { * If the renewal fails, but the error is recoverable. Non * recoverable exceptions are bubbled up. */ - private LoginToken renewToken(VaultToken token) throws VaultException { - - VaultResponse response; - + private LoginToken renewToken(final VaultToken token) throws VaultException { + final VaultResponse response; + try { response = restOperations.postForObject("/auth/token/renew-self", - new HttpEntity<>(VaultHttpHeaders.from(token)), VaultResponse.class); + new HttpEntity<>(VaultHttpHeaders.from(token)), VaultResponse.class); } catch (RestClientException e) { - throw new VaultException("Vault request failed: " + e.getMessage()); + throw new VaultException("Vault request failed: " + e.getMessage(), e); } - + if (response == null || response.getAuth() == null) { throw new VaultException("Token renewal failed: no response data"); } - String renewedToken = (String) response.getAuth().get("client_token"); + final String renewedToken = (String) response.getAuth().get("client_token"); if (renewedToken == null) { throw new VaultException("Token renewal failed: missing token in response"); } - Number ttl = (Number) response.getAuth().get("lease_duration"); - Boolean renewable = (Boolean) response.getAuth().get("renewable"); + final Number ttl = (Number) response.getAuth().get("lease_duration"); + final Boolean renewable = (Boolean) response.getAuth().get("renewable"); - long ttlSeconds = ttl != null ? ttl.longValue() : 0; - boolean isRenewable = renewable != null && renewable; + final long ttlSeconds = ttl != null ? ttl.longValue() : 0; + final boolean isRenewable = renewable != null && renewable; - LoginToken newToken = LoginToken.builder() + final LoginToken newToken = LoginToken.builder() .token(renewedToken) .leaseDuration(Duration.ofSeconds(ttlSeconds)) .renewable(isRenewable) @@ -447,7 +471,7 @@ private void attemptLogin() { // to force a VaultExpception for an invalid token, and avoid // an infinite loop. Since we have the token info, might as // well promote it to a LoginToken directly - TokenInfo tokenInfo = getTokenInfo(newToken); + final TokenInfo tokenInfo = getTokenInfo(newToken); if (!(newToken instanceof LoginToken)) { newToken = LoginToken.builder() .token(newToken.getToken()) @@ -464,10 +488,10 @@ private void attemptLogin() { logger.warn("Recoverable authentication failure, rescheduling renewal : {}", e.getMessage()); stopScheduling(); scheduledRenewal.set(scheduler.schedule(this::renewTokenAsync, - Instant.now().plusMillis(10000))); + Instant.now().plusMillis(10_000))); } - catch (Exception e) { - logger.error("Non-recoverable authentication failure: {}", e.getMessage(), e); + catch (ClassCastException | IllegalArgumentException e) { + logger.error("Non-recoverable authentication failure: {}", e.getMessage()); stopScheduling(); } } @@ -486,7 +510,7 @@ public VaultToken getSessionToken() { * @param token * Can be either a Null, VaultToken or a LoginToken */ - public void setSessionToken(VaultToken token) { + public void setSessionToken(final VaultToken token) { if (token != null && !token.getToken().isEmpty()) { this.token.set(token); } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/UsernamePasswordAuthentication.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/UsernamePasswordAuthentication.java index 4954593c0a..00f24677af 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/UsernamePasswordAuthentication.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/UsernamePasswordAuthentication.java @@ -17,9 +17,6 @@ * under the License. */ -// This is a minimal backport of this class to version 2.3.4 of spring-vault-core -// It is compatible with lease renewal using a life cycle aware session manager - package org.apache.guacamole.vault.hv.vault; import java.time.Duration; @@ -35,9 +32,14 @@ import org.springframework.vault.support.VaultToken; import org.springframework.web.client.RestTemplate; +/** + * This is a minimal backport of this class to version 2.3.4 of spring-vault-core + * It is compatible with lease renewal using a life cycle aware session manager + */ public class UsernamePasswordAuthentication implements ClientAuthentication { /** - * A class containing all of the configuration options for username/password authentication + * A class containing all of the configuration options for + * username/password authentication */ private final UsernamePasswordAuthenticationOptions options; @@ -64,9 +66,9 @@ public class UsernamePasswordAuthentication implements ClientAuthentication { * The spring-framework RestTemplate used to communicate with the Vault */ public UsernamePasswordAuthentication( - UsernamePasswordAuthenticationOptions options, - VaultEndpoint endpoint, - RestTemplate restTemplate) { + final UsernamePasswordAuthenticationOptions options, + final VaultEndpoint endpoint, + final RestTemplate restTemplate) { Assert.notNull(options, "Options must not be null"); Assert.notNull(endpoint, "VaultEndpoint must not be null"); @@ -91,7 +93,7 @@ public UsernamePasswordAuthentication( @Override public VaultToken login() throws VaultException { - String loginPath = String.format( + final String loginPath = String.format( "%s://%s:%d/v1/auth/%s/login/%s", endpoint.getScheme(), endpoint.getHost(), @@ -99,29 +101,27 @@ public VaultToken login() throws VaultException { options.getMountPath(), options.getUsername()); - Map body = + final Map body = Collections.singletonMap("password", options.getPassword()); - ResponseEntity response = + final ResponseEntity response = restTemplate.postForEntity(loginPath, body, VaultResponse.class); - VaultResponse vaultResponse = response.getBody(); + final VaultResponse vaultResponse = response.getBody(); if (vaultResponse == null || vaultResponse.getAuth() == null) { throw new VaultException("No auth section returned from Vault"); } - String token = (String) vaultResponse.getAuth().get("client_token"); - Number lease = (Number) vaultResponse.getAuth().get("lease_duration"); - Boolean renewable = (Boolean) vaultResponse.getAuth().get("renewable"); - - long leaseDuration = lease != null ? lease.longValue() : 0; - boolean isRenewable = renewable != null && renewable; - + final String token = (String) vaultResponse.getAuth().get("client_token"); if (token == null) { throw new VaultException("No client_token in Vault auth response"); } + final Number lease = (Number) vaultResponse.getAuth().get("lease_duration"); + final Boolean renewable = (Boolean) vaultResponse.getAuth().get("renewable"); + final long leaseDuration = lease != null ? lease.longValue() : 0; + final boolean isRenewable = renewable != null && renewable; return LoginToken.builder().token(token) .leaseDuration(Duration.ofSeconds(leaseDuration)) .renewable(isRenewable) diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/UsernamePasswordAuthenticationOptions.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/UsernamePasswordAuthenticationOptions.java index cffda5c19d..8e9632a948 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/UsernamePasswordAuthenticationOptions.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/UsernamePasswordAuthenticationOptions.java @@ -17,12 +17,13 @@ * under the License. */ - // This is a minimal backport of this class to version 2.3.4 of spring-vault-core - package org.apache.guacamole.vault.hv.vault; import org.springframework.util.Assert; +/** + * This is a minimal backport of this class to version 2.3.4 of spring-vault-core + */ public final class UsernamePasswordAuthenticationOptions { /** * Path of the userpass authentication method mount. @@ -30,20 +31,20 @@ public final class UsernamePasswordAuthenticationOptions { private final String mountPath; /** - * Username of the userpass authetication method mount. - */ + * Username of the userpass authetication method mount. + */ private final String username; /** - * Password of the userpass authetication method mount. - */ + * Password of the userpass authetication method mount. + */ private final String password; - private UsernamePasswordAuthenticationOptions(Builder builder) { - this.username = builder.username; - this.password = builder.password; - this.mountPath = builder.mountPath; + private UsernamePasswordAuthenticationOptions(final Builder builder) { + this.username = builder.usern; + this.password = builder.passw; + this.mountPath = builder.mount; } public String getUsername() { @@ -58,37 +59,78 @@ public String getMountPath() { return mountPath; } + /** + * Returns a builder for this class + */ public static Builder builder() { return new Builder(); } + /** + * A Builder class for UsernamePasswordOptions + */ public static final class Builder { - private String username; - private String password; - private String mountPath = "userpass"; - - public Builder username(String username) { - this.username = username; + /** The username */ + private String usern; + /** The password */ + private String passw; + /** The mount path for Vault authentication */ + private String mount = "userpass"; + + /** + * Set the username for this instance of UsernamePasswordAuthenticationOptions. + * + * @param username + * A username to be used. It must be set for the built class to be valid + * + * @return + * The builder instance + */ + public Builder username(final String usern) { + this.usern = usern; return this; } - public Builder password(String password) { - this.password = password; + /** + * Set the password for this instance of UsernamePasswordAuthenticationOptions. + * + * @param password + * A password to be used. It must be set for the built class to be valid + * + * @return + * The builder instance + */ + public Builder password(final String passw) { + this.passw = passw; return this; } - - /** Optional – defaults to "userpass" */ - public Builder mountPath(String mountPath) { - this.mountPath = mountPath; + + /** + * Set the mountPath for this instance of UsernamePasswordAuthenticationOptions. + * + * @param mount + * A Vault mount path to use for authentication. Defaults to "userpass" if not + * set. + */ + public Builder mountPath(final String mount) { + this.mount = mount; return this; } + /** + * Once arguments are finalized, return the corresponding + * UsernamePasswordAuthenticationOptions. + * + * @return + * A UsernamePasswordAuthenticationOptions for use with a + * spring-vault ClientAuthentication class. + */ public UsernamePasswordAuthenticationOptions build() { - Assert.hasText(username, "Username must not be empty"); - Assert.hasText(password, "Password must not be empty"); - Assert.hasText(mountPath, "Mount path must not be empty"); + Assert.hasText(usern, "Username must not be empty"); + Assert.hasText(passw, "Password must not be empty"); + Assert.hasText(mount, "Mount path must not be empty"); return new UsernamePasswordAuthenticationOptions(this); } From 23964784ca561677286c90f7680079eef797a5dc Mon Sep 17 00:00:00 2001 From: David Bateman Date: Tue, 9 Jun 2026 14:49:53 +0200 Subject: [PATCH 12/27] GUACAMOLE-2137: Add wrapping of UserContext, so that User and ConnectionGroup information can be updated for the UI --- .../vault/hv/HvAuthenticationProvider.java | 18 +++ .../vault/hv/user/HvUserContext.java | 129 ++++++++++++++++++ 2 files changed, 147 insertions(+) create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUserContext.java diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProvider.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProvider.java index 08bbb2da95..d58a0a5db9 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProvider.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProvider.java @@ -20,7 +20,11 @@ package org.apache.guacamole.vault.hv; import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.net.auth.AuthenticatedUser; +import org.apache.guacamole.net.auth.Credentials; +import org.apache.guacamole.net.auth.UserContext; import org.apache.guacamole.vault.VaultAuthenticationProvider; +import org.apache.guacamole.vault.hv.user.HvUserContext; /** * VaultAuthenticationProvider implementation which reads secrets from @@ -44,4 +48,18 @@ public String getIdentifier() { return "hashicorp-vault"; } + @Override + public UserContext decorate(UserContext context, + AuthenticatedUser authenticatedUser, Credentials credentials) + throws GuacamoleException { + + return new HvUserContext(context); + } + + @Override + public UserContext redecorate(UserContext decorated, UserContext context, + AuthenticatedUser authenticatedUser, Credentials credentials) + throws GuacamoleException { + return new HvUserContext(context); + } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUserContext.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUserContext.java new file mode 100644 index 0000000000..81bca2c784 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUserContext.java @@ -0,0 +1,129 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 org.apache.guacamole.vault.hv.user; + +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.form.Form; +import org.apache.guacamole.net.auth.Connection; +import org.apache.guacamole.net.auth.ConnectionGroup; +import org.apache.guacamole.net.auth.DecoratingDirectory; +import org.apache.guacamole.net.auth.DelegatingUserContext; +import org.apache.guacamole.net.auth.Directory; +import org.apache.guacamole.net.auth.User; +import org.apache.guacamole.net.auth.UserContext; +import org.apache.guacamole.net.auth.UserGroup; +import org.apache.guacamole.vault.hv.conf.HvAttributeService; + +/** + * OpenBao/Hashicorp Vault-specific UserContext implementation which wraps the + * UserContext of some other extension, providing (or hiding) additional data. + */ +public class HvUserContext extends DelegatingUserContext { + + /** + * Creates a new HvUserContext which wraps the given UserContext, + * providing (or hiding) additional Hv-specific data. + * + * @param userContext + * The UserContext to wrap. + */ + public HvUserContext(UserContext userContext) { + super(userContext); + } + + @Override + public Directory getUserDirectory() throws GuacamoleException { + return new DecoratingDirectory(super.getUserDirectory()) { + + @Override + protected User decorate(User object) { + return new HvUser(object); + } + + @Override + protected User undecorate(User object) { + assert(object instanceof HvUser); + return ((HvUser) object).getUnderlyingUser(); + } + + }; + } + + @Override + public Collection getUserAttributes() { + Collection userAttrs = new HashSet<>(super.getUserAttributes()); + userAttrs.addAll(HvAttributeService.HV_ATTRIBUTES); + return Collections.unmodifiableCollection(userAttrs); + } + + @Override + public Directory getConnectionDirectory() throws GuacamoleException { + return new DecoratingDirectory(super.getConnectionDirectory()) { + + @Override + protected Connection decorate(Connection object) throws GuacamoleException { + return new HvConnection(object); + } + + @Override + protected Connection undecorate(Connection object) { + assert(object instanceof HvConnection); + return ((HvConnection) object).getUnderlyingConnection(); + } + + }; + } + + @Override + public Collection getConnectionAttributes() { + Collection connectionAttrs = new HashSet<>(super.getConnectionAttributes()); + connectionAttrs.addAll(HvAttributeService.HV_CONNECTION_ATTRIBUTES); + return Collections.unmodifiableCollection(connectionAttrs); + } + + @Override + public Directory getConnectionGroupDirectory() throws GuacamoleException { + return new DecoratingDirectory(super.getConnectionGroupDirectory()) { + + @Override + protected ConnectionGroup decorate(ConnectionGroup object) throws GuacamoleException { + return new HvConnectionGroup(object); + } + + @Override + protected ConnectionGroup undecorate(ConnectionGroup object) { + assert(object instanceof HvConnectionGroup); + return ((HvConnectionGroup) object).getUnderlyingConnectionGroup(); + } + + }; + } + + @Override + public Collection getConnectionGroupAttributes() { + Collection connectionGroupAttrs = new HashSet<>(super.getConnectionGroupAttributes()); + connectionGroupAttrs.addAll(HvAttributeService.HV_ATTRIBUTES); + return Collections.unmodifiableCollection(connectionGroupAttrs); + } +} From 33de1ce7381d5a2198bf9456604cc814051ff0a3 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Tue, 9 Jun 2026 14:50:50 +0200 Subject: [PATCH 13/27] GUACAMOLE-2137: Fully abstract HvClient provision to the HvClientProvider class. Correct some comments --- .../vault/hv/secret/HvClientProvider.java | 46 ++++++++++++------- .../vault/hv/secret/HvSecretService.java | 18 +------- .../vault/hv/user/HvConnectionGroup.java | 19 ++------ .../vault/hv/user/HvDirectoryService.java | 7 ++- .../guacamole/vault/hv/user/HvUser.java | 11 ++--- 5 files changed, 42 insertions(+), 59 deletions(-) diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientProvider.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientProvider.java index 07fc7b520f..d13abd8c9f 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientProvider.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientProvider.java @@ -109,7 +109,7 @@ public HvClientProvider(final HvConfigurationService confService, * @throws GuacamoleException * If an error occurs while creating the HV client. */ - public HvClient getHvClient(final VaultInfo vaultInfo) + private HvClient getHvClient(final VaultInfo vaultInfo) throws GuacamoleException { if (vaultInfo.isVaultInfoInvalid()) { return null; @@ -129,6 +129,27 @@ public HvClient getHvClient(final VaultInfo vaultInfo) return prevClient != null ? prevClient : hvClient; } + /** + * Returns the application-wide HVClient if one exists, else null. + * + * @return + * The value of the HV configuration attributes if found in the tree, the default + * HV config defined in guacamole.properties otherwise. + * + * @throws GuacamoleException + * If an error occurs while attempting to retrieve the HV config attribute. + */ + public HvClient getAppHvClient() throws GuacamoleException { + + final VaultInfo vaultInfo = confService.new VaultInfo( + confService.getVaultUri(), + confService.getVaultToken(), + confService.getVaultUsername(), + confService.getVaultPassword()); + + return getHvClient(vaultInfo); + } + /** * Search for a HV configuration attribute, recursing up the connection group tree * until a connection group with the appropriate attribute is found. If the HV @@ -141,8 +162,8 @@ public HvClient getHvClient(final VaultInfo vaultInfo) * A connection or connection group for which the tokens are being replaced. * * @return - * The value of the HV configuration attributes if found in the tree, the default - * HV config defined in guacamole.properties otherwise. + * The value of the HVClient corresponding to the configuration attributes if + * found in the tree, else null * * @throws GuacamoleException * If an error occurs while attempting to retrieve the HV config attribute, or if @@ -226,8 +247,7 @@ public HvClient getConnectionGroupHvClient(final UserContext userContext, * are enabled. * * @return - * The value of the user HV configuration attributes if found in the tree, - * the default HV config defined in guacamole.properties otherwise. + * The value of the user HVClient if found for the current user, else null. * * @throws GuacamoleException * If an error occurs while attempting to fetch the HV config. @@ -269,7 +289,7 @@ public HvClient getUserHvClient(final UserContext userContext, * Return an ordered list of non null HVClients. The application-wide * client is first if non null, followed by the ConnectionGroup client, * and finally the User client. This order is important to ensure that - * the adminsitrator always has control of te injected tokens*. + * the adminsitrator always has control of the injected tokens. * * @param userContext * The user context from which the current user should be fetched. @@ -280,8 +300,8 @@ public HvClient getUserHvClient(final UserContext userContext, * are enabled. * * @return - * The value of the user HV configuration attributes if found in the tree, - * the default HV config defined in guacamole.properties otherwise. + * The values of the HVClient corresponding to the application-wide, + * connectionGroup and User HVClients in an ordered list. * * @throws GuacamoleException * If an error occurs while attempting to fetch the HV config. @@ -289,15 +309,9 @@ public HvClient getUserHvClient(final UserContext userContext, public List getHvClients(final UserContext userContext, final Connectable connectable) throws GuacamoleException { final List clients = new ArrayList<>(); - - // Create an application wide client - final VaultInfo vaultInfo = confService.new VaultInfo( - confService.getVaultUri(), - confService.getVaultToken(), - confService.getVaultUsername(), - confService.getVaultPassword()); - final HvClient client = getHvClient(vaultInfo); + // Create an application-wide client + final HvClient client = getAppHvClient(); if (client != null) { clients.add(client); } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java index 8af8c46e97..496c71dfb9 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java @@ -39,7 +39,6 @@ import org.apache.guacamole.protocol.GuacamoleConfiguration; import org.apache.guacamole.token.TokenFilter; import org.apache.guacamole.vault.hv.conf.HvConfigurationService; -import org.apache.guacamole.vault.hv.conf.HvConfigurationService.VaultInfo; import org.apache.guacamole.vault.secret.VaultSecretService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -88,13 +87,7 @@ public HvSecretService(final HvConfigurationService confService, final HvClientP // the per ConnectionGroup vaults, which MUST have a non expiring means of // authentication to avoid issues try { - final VaultInfo vaultInfo = confService.new VaultInfo( - confService.getVaultUri(), - confService.getVaultToken(), - confService.getVaultUsername(), - confService.getVaultPassword()); - - hvClientProvider.getHvClient(vaultInfo); + hvClientProvider.getAppHvClient(); } catch (GuacamoleException e) { logger.error("Can't initialize HvClient : {}", e.getMessage()); @@ -274,14 +267,7 @@ public Future getValue(final String token) throws GuacamoleException { // Ensure modifiers are removed final String name = token.replaceFirst(":(LOWER|UPPER|OPTIONAL)$", ""); - // Use the default HV configuration from guacamole.properties - final VaultInfo vaultInfo = confService.new VaultInfo( - confService.getVaultUri(), - confService.getVaultToken(), - confService.getVaultUsername(), - confService.getVaultPassword()); - - final HvClient client = hvClientProvider.getHvClient(vaultInfo); + final HvClient client = hvClientProvider.getAppHvClient(); if (client != null) { return client.getSecret(name, "", name.substring(0, name.lastIndexOf('/'))); } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnectionGroup.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnectionGroup.java index b8e939661c..2f8f548a14 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnectionGroup.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnectionGroup.java @@ -29,11 +29,8 @@ /** * A HV-specific connection group implementation that always exposes - * the HV_CONFIGURATION_ATTRIBUTE attribute, even when no value is set. - * The value of the attribute will be sanitized if non-empty. This ensures - * that the attribute will always show up in the UI, even for connection - * groups that don't already have it set, and that any sensitive information - * in the attribute value will not be exposed. + * the HV_URI_ATTRIBUTE, HV_TOKEN_ATTRIBUTE, HV_USERNAME_ATTRIBUTE and + * HV_PASSORD_ATTRIBUTE attributes, even when no value is set. */ public class HvConnectionGroup extends DelegatingConnectionGroup { @@ -62,16 +59,6 @@ public ConnectionGroup getUnderlyingConnectionGroup() { return getDelegateConnectionGroup(); } - /** - * Return the underlying ConnectionGroup that's wrapped by this HvConnectionGroup. - * - * @return - * The underlying ConnectionGroup that's wrapped by this HvConnectionGroup. - */ - public ConnectionGroup getUnderlyConnectionGroup() { - return getDelegateConnectionGroup(); - } - @Override public Map getAttributes() { @@ -95,5 +82,5 @@ public Map getAttributes() { attributes.get(HvAttributeService.HV_PASSWORD_ATTRIBUTE) ); return attributes; - } + } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectoryService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectoryService.java index 38fd3a4e0e..1f6536ef3f 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectoryService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectoryService.java @@ -69,10 +69,9 @@ protected Connection undecorate(final Connection connection) throws GuacamoleExc @Override public Directory getConnectionGroupDirectory(final Directory underlyingDirectory) throws GuacamoleException { // A ConnectionGroup directory that will intercept add and update calls to - // validate HV configurations, and translate one-time-tokens, if possible, - // as well as ensuring that all ConnectionGroups returned include the - // HV_CONFIGURATION_ATTRIBUTE attribute, so it will be available in the UI. - // The value of the HV_CONFIGURATION_ATTRIBUTE will be sanitized if set. + // validate HV configurations, and ensure that all ConnectionGroups returned + // include the HV_URI_ATTRIBUTE, HV_TOKEN_ATTRIBUTE, HV_USERNAME_ATTRIBUTE + // and HV_PASSWORD_ATTRIBUTE attributes, so they will be available in the UI.. return new HvDirectory(underlyingDirectory) { @Override diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUser.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUser.java index 6d67d7ae67..35507f71ca 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUser.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUser.java @@ -24,6 +24,7 @@ import com.google.inject.assistedinject.Assisted; import com.google.inject.assistedinject.AssistedInject; import java.util.Map; +import java.util.HashMap; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.net.auth.DelegatingUser; import org.apache.guacamole.net.auth.User; @@ -33,13 +34,9 @@ import org.slf4j.LoggerFactory; /** - * A HV-specific user implementation that exposes the - * HV_CONFIGURATION_ATTRIBUTE attribute even if no value is set. but only - * if user-specific HV configuration is enabled. The value of the attribute - * will be sanitized if non-empty. This ensures that the attribute will always - * show up in the UI when the feature is enabled, even for users that don't - * already have it set, and that any sensitive information in the attribute - * value will not be exposed. + * A HV-specific user implementation implementation that always exposes + * the HV_URI_ATTRIBUTE, HV_TOKEN_ATTRIBUTE, HV_USERNAME_ATTRIBUTE and + * HV_PASSORD_ATTRIBUTE attributes, even when no value is set. */ public class HvUser extends DelegatingUser { From d4ee18a72d73b2ba5c0f3b468e479d15c12000f8 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Tue, 9 Jun 2026 15:24:54 +0200 Subject: [PATCH 14/27] GUACAMOLE-2137: A few minor changes proposed by PMD --- .../vault/hv/HvAuthenticationProvider.java | 8 +++---- .../guacamole/vault/hv/user/HvUser.java | 1 - .../vault/hv/user/HvUserContext.java | 21 +++++++++---------- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProvider.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProvider.java index d58a0a5db9..ab84566924 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProvider.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProvider.java @@ -49,16 +49,16 @@ public String getIdentifier() { } @Override - public UserContext decorate(UserContext context, - AuthenticatedUser authenticatedUser, Credentials credentials) + public UserContext decorate(final UserContext context, + final AuthenticatedUser authenticatedUser, final Credentials credentials) throws GuacamoleException { return new HvUserContext(context); } @Override - public UserContext redecorate(UserContext decorated, UserContext context, - AuthenticatedUser authenticatedUser, Credentials credentials) + public UserContext redecorate(final UserContext decorated, final UserContext context, + final AuthenticatedUser authenticatedUser, final Credentials credentials) throws GuacamoleException { return new HvUserContext(context); } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUser.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUser.java index 35507f71ca..02d4be21e0 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUser.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUser.java @@ -24,7 +24,6 @@ import com.google.inject.assistedinject.Assisted; import com.google.inject.assistedinject.AssistedInject; import java.util.Map; -import java.util.HashMap; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.net.auth.DelegatingUser; import org.apache.guacamole.net.auth.User; diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUserContext.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUserContext.java index 81bca2c784..e66a245183 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUserContext.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUserContext.java @@ -32,7 +32,6 @@ import org.apache.guacamole.net.auth.Directory; import org.apache.guacamole.net.auth.User; import org.apache.guacamole.net.auth.UserContext; -import org.apache.guacamole.net.auth.UserGroup; import org.apache.guacamole.vault.hv.conf.HvAttributeService; /** @@ -48,7 +47,7 @@ public class HvUserContext extends DelegatingUserContext { * @param userContext * The UserContext to wrap. */ - public HvUserContext(UserContext userContext) { + public HvUserContext(final UserContext userContext) { super(userContext); } @@ -57,12 +56,12 @@ public Directory getUserDirectory() throws GuacamoleException { return new DecoratingDirectory(super.getUserDirectory()) { @Override - protected User decorate(User object) { + protected User decorate(final User object) { return new HvUser(object); } @Override - protected User undecorate(User object) { + protected User undecorate(final User object) { assert(object instanceof HvUser); return ((HvUser) object).getUnderlyingUser(); } @@ -72,7 +71,7 @@ protected User undecorate(User object) { @Override public Collection getUserAttributes() { - Collection userAttrs = new HashSet<>(super.getUserAttributes()); + final Collection userAttrs = new HashSet<>(super.getUserAttributes()); userAttrs.addAll(HvAttributeService.HV_ATTRIBUTES); return Collections.unmodifiableCollection(userAttrs); } @@ -82,12 +81,12 @@ public Directory getConnectionDirectory() throws GuacamoleException return new DecoratingDirectory(super.getConnectionDirectory()) { @Override - protected Connection decorate(Connection object) throws GuacamoleException { + protected Connection decorate(final Connection object) throws GuacamoleException { return new HvConnection(object); } @Override - protected Connection undecorate(Connection object) { + protected Connection undecorate(final Connection object) { assert(object instanceof HvConnection); return ((HvConnection) object).getUnderlyingConnection(); } @@ -97,7 +96,7 @@ protected Connection undecorate(Connection object) { @Override public Collection getConnectionAttributes() { - Collection connectionAttrs = new HashSet<>(super.getConnectionAttributes()); + final Collection connectionAttrs = new HashSet<>(super.getConnectionAttributes()); connectionAttrs.addAll(HvAttributeService.HV_CONNECTION_ATTRIBUTES); return Collections.unmodifiableCollection(connectionAttrs); } @@ -107,12 +106,12 @@ public Directory getConnectionGroupDirectory() throws Guacamole return new DecoratingDirectory(super.getConnectionGroupDirectory()) { @Override - protected ConnectionGroup decorate(ConnectionGroup object) throws GuacamoleException { + protected ConnectionGroup decorate(final ConnectionGroup object) throws GuacamoleException { return new HvConnectionGroup(object); } @Override - protected ConnectionGroup undecorate(ConnectionGroup object) { + protected ConnectionGroup undecorate(final ConnectionGroup object) { assert(object instanceof HvConnectionGroup); return ((HvConnectionGroup) object).getUnderlyingConnectionGroup(); } @@ -122,7 +121,7 @@ protected ConnectionGroup undecorate(ConnectionGroup object) { @Override public Collection getConnectionGroupAttributes() { - Collection connectionGroupAttrs = new HashSet<>(super.getConnectionGroupAttributes()); + final Collection connectionGroupAttrs = new HashSet<>(super.getConnectionGroupAttributes()); connectionGroupAttrs.addAll(HvAttributeService.HV_ATTRIBUTES); return Collections.unmodifiableCollection(connectionGroupAttrs); } From 8d0d0f5f691f03a24cdbfc8e1852082be1124f91 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Tue, 9 Jun 2026 18:10:51 +0200 Subject: [PATCH 15/27] GUACAMOME-2137: Remove HvUserContext, as problem updated was that HvDirectoryService didn't unwrap HvConnectionGroup --- .../vault/hv/HvAuthenticationProvider.java | 17 +-- .../vault/hv/user/HvDirectoryService.java | 8 +- .../vault/hv/user/HvUserContext.java | 128 ------------------ 3 files changed, 8 insertions(+), 145 deletions(-) delete mode 100644 extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUserContext.java diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProvider.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProvider.java index ab84566924..86d174dd1f 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProvider.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProvider.java @@ -34,7 +34,7 @@ public class HvAuthenticationProvider extends VaultAuthenticationProvider { /** * Creates a new HvKeyVaultAuthenticationProvider which reads secrets - * from a configured Hashicorp Vault. + * from a configured OpenBao/Hashicorp Vault. * * @throws GuacamoleException * If configuration details cannot be read from guacamole.properties. @@ -47,19 +47,4 @@ public HvAuthenticationProvider() throws GuacamoleException { public String getIdentifier() { return "hashicorp-vault"; } - - @Override - public UserContext decorate(final UserContext context, - final AuthenticatedUser authenticatedUser, final Credentials credentials) - throws GuacamoleException { - - return new HvUserContext(context); - } - - @Override - public UserContext redecorate(final UserContext decorated, final UserContext context, - final AuthenticatedUser authenticatedUser, final Credentials credentials) - throws GuacamoleException { - return new HvUserContext(context); - } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectoryService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectoryService.java index 1f6536ef3f..7812a1f9b3 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectoryService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectoryService.java @@ -84,7 +84,13 @@ public void add(final ConnectionGroup connectionGroup) throws GuacamoleException } @Override - public void update(final ConnectionGroup connectionGroup) throws GuacamoleException { + public void update(final ConnectionGroup oldConnectionGroup) throws GuacamoleException { + ConnectionGroup connectionGroup = oldConnectionGroup; + + // Unwrap the existing ConnectionGroup + if (connectionGroup instanceof HvConnectionGroup) + connectionGroup = ((HvConnectionGroup) connectionGroup).getUnderlyingConnectionGroup(); + // Process attribute values before saving connectionGroup.setAttributes(connectionGroup.getAttributes()); diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUserContext.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUserContext.java deleted file mode 100644 index e66a245183..0000000000 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvUserContext.java +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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 org.apache.guacamole.vault.hv.user; - -import java.util.Collection; -import java.util.Collections; -import java.util.HashSet; -import org.apache.guacamole.GuacamoleException; -import org.apache.guacamole.form.Form; -import org.apache.guacamole.net.auth.Connection; -import org.apache.guacamole.net.auth.ConnectionGroup; -import org.apache.guacamole.net.auth.DecoratingDirectory; -import org.apache.guacamole.net.auth.DelegatingUserContext; -import org.apache.guacamole.net.auth.Directory; -import org.apache.guacamole.net.auth.User; -import org.apache.guacamole.net.auth.UserContext; -import org.apache.guacamole.vault.hv.conf.HvAttributeService; - -/** - * OpenBao/Hashicorp Vault-specific UserContext implementation which wraps the - * UserContext of some other extension, providing (or hiding) additional data. - */ -public class HvUserContext extends DelegatingUserContext { - - /** - * Creates a new HvUserContext which wraps the given UserContext, - * providing (or hiding) additional Hv-specific data. - * - * @param userContext - * The UserContext to wrap. - */ - public HvUserContext(final UserContext userContext) { - super(userContext); - } - - @Override - public Directory getUserDirectory() throws GuacamoleException { - return new DecoratingDirectory(super.getUserDirectory()) { - - @Override - protected User decorate(final User object) { - return new HvUser(object); - } - - @Override - protected User undecorate(final User object) { - assert(object instanceof HvUser); - return ((HvUser) object).getUnderlyingUser(); - } - - }; - } - - @Override - public Collection getUserAttributes() { - final Collection userAttrs = new HashSet<>(super.getUserAttributes()); - userAttrs.addAll(HvAttributeService.HV_ATTRIBUTES); - return Collections.unmodifiableCollection(userAttrs); - } - - @Override - public Directory getConnectionDirectory() throws GuacamoleException { - return new DecoratingDirectory(super.getConnectionDirectory()) { - - @Override - protected Connection decorate(final Connection object) throws GuacamoleException { - return new HvConnection(object); - } - - @Override - protected Connection undecorate(final Connection object) { - assert(object instanceof HvConnection); - return ((HvConnection) object).getUnderlyingConnection(); - } - - }; - } - - @Override - public Collection getConnectionAttributes() { - final Collection connectionAttrs = new HashSet<>(super.getConnectionAttributes()); - connectionAttrs.addAll(HvAttributeService.HV_CONNECTION_ATTRIBUTES); - return Collections.unmodifiableCollection(connectionAttrs); - } - - @Override - public Directory getConnectionGroupDirectory() throws GuacamoleException { - return new DecoratingDirectory(super.getConnectionGroupDirectory()) { - - @Override - protected ConnectionGroup decorate(final ConnectionGroup object) throws GuacamoleException { - return new HvConnectionGroup(object); - } - - @Override - protected ConnectionGroup undecorate(final ConnectionGroup object) { - assert(object instanceof HvConnectionGroup); - return ((HvConnectionGroup) object).getUnderlyingConnectionGroup(); - } - - }; - } - - @Override - public Collection getConnectionGroupAttributes() { - final Collection connectionGroupAttrs = new HashSet<>(super.getConnectionGroupAttributes()); - connectionGroupAttrs.addAll(HvAttributeService.HV_ATTRIBUTES); - return Collections.unmodifiableCollection(connectionGroupAttrs); - } -} From 60d91e0a9e708662564002b08a94c64f1843f78f Mon Sep 17 00:00:00 2001 From: David Bateman Date: Tue, 9 Jun 2026 18:20:20 +0200 Subject: [PATCH 16/27] GUACAMOLE-2137: Fixes for comment blocks. No code changes --- .../hv/HvAuthenticationProviderModule.java | 2 +- .../vault/hv/conf/HvConfigurationService.java | 2 +- .../guacamole/vault/hv/secret/HvClient.java | 30 +++---- .../vault/hv/secret/HvClientProvider.java | 80 +++++++++---------- .../vault/hv/secret/HvSecretService.java | 43 +++++----- .../guacamole/vault/hv/secret/HvSshKeys.java | 17 ++-- .../hv/secret/HvTunnelEventListener.java | 2 +- .../guacamole/vault/hv/user/HvConnection.java | 2 +- .../hv/vault/FileTokenAuthentication.java | 2 +- .../vault/UsernamePasswordAuthentication.java | 4 +- 10 files changed, 88 insertions(+), 96 deletions(-) diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProviderModule.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProviderModule.java index cef828d3f7..ccb7648ee1 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProviderModule.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProviderModule.java @@ -36,7 +36,7 @@ import org.apache.guacamole.vault.user.VaultDirectoryService; /** - * Guice module which configures injections specific to Hashicorp Vault + * Guice module which configures injections specific to OpenBao/Hashicorp Vault * support. */ public class HvAuthenticationProviderModule diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java index 1ee9849d6c..fbdbf60cf0 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java @@ -610,7 +610,7 @@ public int hashCode() { * * @return * True is the value in non null, URI is set and at least one of - * token or username/password id set + * token or username/password is set */ public boolean isVaultInfoInvalid() { return uri == null || uri.toString().isBlank() || diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java index 73aafceb2a..5aab6b6d74 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java @@ -74,7 +74,7 @@ public class HvClient { private static final String VAULT_PATH_HELP = "/sys/internal/ui/mounts/"; /** - * Name temporary entry in the ldap sessions for session being cosntructed + * Name temporary entry in the ldap sessions for session being constructed */ public static final String VAULT_LDAP_SESSION = "checkin"; @@ -261,7 +261,7 @@ private static final class LDAPSessionInfo { private final String checkInPath; /** Stores the service account username of the checked out account */ private final String username; - /** Is true id the TunnelConnectEVent has been detected */ + /** Is true if the TunnelConnectEvent has been detected */ private final boolean initialized; /** The date the account was checked-out, allowing automatic check-in after 2h */ private final Instant created; @@ -287,14 +287,14 @@ private LDAPSessionInfo(final String checkInPath, final String username, final b } /** - * The LDAP session interface checks out session that then can not be + * The LDAP session interface checks out sessions that then can not be * used till they are checked in. In getTokens we have the problem that * we don't have access to the tunnel ID and so can't identify it. * Guacamole is also not guarenteed to generate a TunnelCloseEvent. * * So this function is fragile and relies on the fact that the TunnelConnectEvent * will be running a few tens of milliseconds after the getTokens command to - * limit the risk of confusing two connection. There is still a small risk + * limit the risk of confusing two connections. There is still a small risk * of error here. * * @param tunnelId @@ -325,7 +325,7 @@ else if (session.isInitialized()) { // Do some clean up of the active LDAP sessions. If a session hasn't been // checked in after 2 hours, just drop it from the hashMap. As the TTL of // the vault is already 2 hours don't need to check it in. Don't really - // care if the value hang around in our hashmap so don't need a dedicated + // care if the value hangs around in our hashmap so don't need a dedicated // task for this ldapSessions.entrySet().removeIf(e -> Instant.now().isAfter(e.getValue().getCreated().plusSeconds(7200))); @@ -337,7 +337,7 @@ else if (session.isInitialized()) { } /** - * Returns the value of the secret stored within Hashicorp Vault. + * Returns the value of the secret stored within OpenBao/Hashicorp Vault. * * @param notation * The HV notation of the secret to retrieve. @@ -347,8 +347,8 @@ else if (session.isInitialized()) { * generation. * * @param key - * A pseudo-unique key to use to stored cached secrets, to keep secrets - * associated with the same connection together, even if they vault token + * A pseudo-unique key to use to store cached secrets, to keep secrets + * associated with the same connection together, even if the vault token * itself is not unique. * * @return @@ -485,7 +485,7 @@ public CompletableFuture getSecret(final String notation, final String u * @return * The values associated with the path. * - * @throws GuacamoleException + * @throws VaultException * If the secrets cannot be retrieved from the Vault. */ private JsonNode getValueKV(final String mountPath, final String path, final VaultKeyValueOperations.KeyValueBackend type) throws VaultException { @@ -518,7 +518,7 @@ private JsonNode getValueKV(final String mountPath, final String path, final Vau * @return * The values associated with the path. * - * @throws GuacamoleException + * @throws VaultException * If the secrets cannot be retrieved from the Vault. */ private JsonNode getValueSSH(final String mountPath, final String path, final String username) throws VaultException { @@ -588,7 +588,7 @@ private JsonNode getValueSSH(final String mountPath, final String path, final St * @return * The values associated with the path. * - * @throws GuacamoleException + * @throws VaultException * If the secrets cannot be retrieved from the Vault. */ private JsonNode getValueLDAP(final String mountPath, final String path) throws VaultException { @@ -623,9 +623,9 @@ else if (path.startsWith("library/")) { /** * Retrieves a username or password from a Database secret engine. - * Before version 1.10 the data could only have username/password - * After there can be static preconfigured fields that the vault tokens - * might access. + * Before Hashicorp version 1.10 the data could only have username/password + * After there can be additional static preconfigured fields that the vault + * tokens might access. * * @param mountPath * The mountPath of the database secret engine on the vault server. @@ -636,7 +636,7 @@ else if (path.startsWith("library/")) { * @return * The values associated with the path. * - * @throws GuacamoleException + * @throws VaultException * If the secrets cannot be retrieved from the Vault. */ private JsonNode getValueDB(final String mountPath, final String path) throws VaultException { diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientProvider.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientProvider.java index d13abd8c9f..c1ee7ea637 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientProvider.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientProvider.java @@ -48,8 +48,8 @@ import org.slf4j.LoggerFactory; /** - * A Provider for a OpenBao/Hashicorp Vault client for a particular - * configuration. + * A Provider for a OpenBao/Hashicorp Vault client for a particular + * configuration. */ @Singleton public class HvClientProvider { @@ -97,14 +97,14 @@ public HvClientProvider(final HvConfigurationService confService, * Create and return a HV client for the provided HV config if not already * present in the client map, otherwise return the existing client entry. * - * @param hvConfig - * The base-64 encoded JSON HV config blob associated with the client entry. + * @param vaultInfo + * The Vault configuration information associated with this instance. * If an associated entry does not already exist, it will be created using * this configuration. * * @return - * A HV client for the provided HV config if not already present in the - * client map, otherwise the existing client entry. + * A HV client for the provided HV configuration if not already present in + * the client map, otherwise the existing client entry. * * @throws GuacamoleException * If an error occurs while creating the HV client. @@ -133,11 +133,11 @@ private HvClient getHvClient(final VaultInfo vaultInfo) * Returns the application-wide HVClient if one exists, else null. * * @return - * The value of the HV configuration attributes if found in the tree, the default - * HV config defined in guacamole.properties otherwise. + * The HVClient instances associated with the application-wide configuration + * attributes from guacamole.properties. * * @throws GuacamoleException - * If an error occurs while attempting to retrieve the HV config attribute. + * If an error occurs while attempting to retrieve the HV configuration attribute. */ public HvClient getAppHvClient() throws GuacamoleException { @@ -151,7 +151,7 @@ public HvClient getAppHvClient() throws GuacamoleException { } /** - * Search for a HV configuration attribute, recursing up the connection group tree + * Search for HV configuration attributes, recursing up the connection group tree * until a connection group with the appropriate attribute is found. If the HV * configuration is found, the corresponding HVClient will be returned, else null. * @@ -162,13 +162,13 @@ public HvClient getAppHvClient() throws GuacamoleException { * A connection or connection group for which the tokens are being replaced. * * @return - * The value of the HVClient corresponding to the configuration attributes if + * The value of the HVClient corresponding to the configuration attributes if * found in the tree, else null * * @throws GuacamoleException - * If an error occurs while attempting to retrieve the HV config attribute, or if - * no HV config is found in the connection group tree, and the value is also not - * defined in the config file. + * If an error occurs while attempting to retrieve the HV configurations attributes, + * or if no HV configuration is found in the connection group tree, and the value is + * also not defined in the config file. */ public HvClient getConnectionGroupHvClient(final UserContext userContext, final Connectable connectable) throws GuacamoleException { @@ -193,7 +193,7 @@ public HvClient getConnectionGroupHvClient(final UserContext userContext, (HvDirectory) userContext.getConnectionGroupDirectory() ).getUnderlyingDirectory(); - // If the parent is a group that's already been seen, this is a cycle, so + // If the parent is a group that's already been seen, this is a cycle, so // there's no need to search any further while (client == null && observedIdentifiers.add(parentIdentifier)) { // Fetch the parent group, if one exists @@ -213,7 +213,7 @@ public HvClient getConnectionGroupHvClient(final UserContext userContext, URI.create(hvConfig.get(HvAttributeService.HV_URI_ATTRIBUTE)), hvConfig.get(HvAttributeService.HV_TOKEN_ATTRIBUTE), hvConfig.get(HvAttributeService.HV_USERNAME_ATTRIBUTE), - hvConfig.get(HvAttributeService.HV_PASSWORD_ATTRIBUTE)); + hvConfig.get(HvAttributeService.HV_PASSWORD_ATTRIBUTE)); client = getHvClient(vaultInfo); @@ -233,10 +233,10 @@ public HvClient getConnectionGroupHvClient(final UserContext userContext, } /** - * Return the HVclient for the current user IFF user User HV configuration + * Return the HVclient for the current user IFF User HV configuration * is enabled globally, and are enabled for the given connectable. If no - * HV configuartion exists for the given user or HV configs are not enabled, - * null will be returned. + * HV configuartion exists for the given user or HV configurations are not + * enabled, null will be returned. * * @param userContext * The user context from which the current user should be fetched. @@ -257,18 +257,18 @@ public HvClient getUserHvClient(final UserContext userContext, HvClient client = null; // If user HV configs are enabled globally, and for the given connectable, - // return the user-specific HV config, if one exists - + // use the user-specific HV config, if one exists + if (confService.allowUserConfig() && (connectable instanceof Attributes) && HvAttributeService.TRUTH_VALUE.equals(((Attributes) connectable).getAttributes().get( HvAttributeService.HV_USER_CONFIG_ENABLED_ATTRIBUTE))) { - // Get the underlying user, to avoid the KSM config sanitization + // Get the underlying user final User self = ((HvDirectory) userContext.getUserDirectory()) .getUnderlyingDirectory().get(userContext.self().getIdentifier()); // If the current user has HV configuration attributes - // set to a non-empty value, return immediately + // set to a empty values, return immediately final Map hvConfig = self.getAttributes(); if (hvConfig.get(HvAttributeService.HV_URI_ATTRIBUTE) != null) { @@ -276,17 +276,17 @@ public HvClient getUserHvClient(final UserContext userContext, URI.create(hvConfig.get(HvAttributeService.HV_URI_ATTRIBUTE)), hvConfig.get(HvAttributeService.HV_TOKEN_ATTRIBUTE), hvConfig.get(HvAttributeService.HV_USERNAME_ATTRIBUTE), - hvConfig.get(HvAttributeService.HV_PASSWORD_ATTRIBUTE)); - + hvConfig.get(HvAttributeService.HV_PASSWORD_ATTRIBUTE)); + client = getHvClient(vaultInfo); } } return client; } - + /** - * Return an ordered list of non null HVClients. The application-wide + * Return an ordered list of non null HVClients. The application-wide * client is first if non null, followed by the ConnectionGroup client, * and finally the User client. This order is important to ensure that * the adminsitrator always has control of the injected tokens. @@ -307,7 +307,7 @@ public HvClient getUserHvClient(final UserContext userContext, * If an error occurs while attempting to fetch the HV config. */ public List getHvClients(final UserContext userContext, - final Connectable connectable) throws GuacamoleException { + final Connectable connectable) throws GuacamoleException { final List clients = new ArrayList<>(); // Create an application-wide client @@ -320,27 +320,21 @@ public List getHvClients(final UserContext userContext, final HvClient connectionClient = getConnectionGroupHvClient(userContext, connectable); if (connectionClient != null) { clients.add(connectionClient); - } + } // Configure per user client if configured final HvClient userClient = getUserHvClient(userContext, connectable); if (userClient != null) { clients.add(userClient); } - + return clients; } /** * The LDAP session interface checks out sessions that then can not be - * used till they are checked in. In getTokens we have the problem that - * we don't have access to the tunnel ID and so can't identify it. - * Guacamole is also not guarenteed to generate a TunnelCloseEvent. - * - * So this function is fragile and relies on the fact that the TunnelConnectEvent - * will be running a few tens of milliseconds after the getTokens command to - * limit the risk of confusing two connections. There is still a small risk - * of error here. This needs a better solution + * used till they are checked in. This function searchs all HVClient + * until one responds that it has treated the event. * * @param event * A TunnelConnectEvent or TunnelCloseEvent @@ -375,16 +369,16 @@ public interface HvClientFactory { * Returns a new instance of a HvClient instance associated with * the provided HV configuration options and API interval. * - * @param hvConfigOptions - * The HV config options to use when constructing the HvClient - * object. + * @param VaultInfo + * The Vault configuration information associated with the instance. + * to create. * * @return - * A new HvClient instance associated with the provided HV config + * A new HvClient instance associated with the provided HV configuration * options. */ HvClient create(@Nonnull VaultInfo vaultInfo); - } + } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java index 496c71dfb9..491b0559e1 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java @@ -44,10 +44,10 @@ import org.slf4j.LoggerFactory; /** - * Service which retrieves secrets from Hashicorp Vault. + * Service which retrieves secrets from OpenBao/Hashicorp Vault. * The configuration used to connect to HV can be set at a global - * level using guacamole.properties, or using a connection group - * attribute. + * level using guacamole.properties, using a connection group + * attribute, or users attributes. */ @Singleton public class HvSecretService implements VaultSecretService { @@ -113,8 +113,8 @@ public String canonicalize(final String nameComponent) { } /** - * Helper function for prepareToken that implements that decides if - * a sub-token exists in the token and if it should be replaced + * Helper function for prepareToken that that decides if a sub-token + * exists in the token and if it should be replaced * * @param oldtoken * The token string potentially containing a sub-token to replace @@ -123,7 +123,7 @@ public String canonicalize(final String nameComponent) { * The sub-token value to look for. For example "{USERNAME}" * * @param filter - * A TokenFilter to use of the value if the suboken itself is a token + * A TokenFilter to use of the value if the sub-token itself is a token * * @return * The token with the sub-token replaced with its value @@ -132,7 +132,7 @@ private String applyToken(final String oldtoken, String value, final String placeholder, final TokenFilter filter) { String token = oldtoken; - // FIXME There is an edge case for tokens like + // FIXME There is an edge case for tokens like // vault://ldap/$${USERNAME}/{USERNAME}/password // This seems a pretty unlikely case, so don't treat. if (value != null && !value.isEmpty()) { @@ -161,7 +161,7 @@ private String applyToken(final String oldtoken, String value, * depending on the underlying implementation. * * @return - * A token with the sub-tokens included eplaced. + * A token with the sub-tokens included replaced. */ private String prepareToken(final String oldtoken, final UserContext userContext, final GuacamoleConfiguration config, final TokenFilter filter) { @@ -188,9 +188,7 @@ private String prepareToken(final String oldtoken, final UserContext userContext /** * Returns a Future which eventually completes with the value of the secret * having the given name. If no such secret exists, the Future will be - * completed with null. The secrets retrieved from this method are independent - * of the context of the particular connection being established, or any - * associated user context. + * completed with null. * * @param userContext * The user context from which the connectable originated. @@ -229,8 +227,8 @@ public Future getValue(final UserContext userContext, } // Use a key including GUAC_USERNAME, to at least prevent a user stealing the - // session of another due to timing issues. The ssh certificates of keys - // of token from vault-token-mapping.yml must have an explicit username associated + // session of another due to timing issues. The ssh certificates of tokens + // from vault-token-mapping.yml must have an explicit username associated // with it final String username = config.getParameter("username"); final String guacUsername = userContext.self().getIdentifier(); @@ -238,7 +236,7 @@ public Future getValue(final UserContext userContext, userContext, config, new TokenFilter()); final String key = guacUsername + "-" + username + "-" + finalName.substring(0, finalName.lastIndexOf('/')); - + return resolveSecret(clients, finalName, username, key); } @@ -279,10 +277,7 @@ public Future getValue(final String token) throws GuacamoleException { /* * Returns a map of token names to corresponding Futures which eventually * complete with the value of that token, where each token is dynamically - * defined based on connection parameters. If a vault implementation allows - * for predictable secrets based on the parameters of a connection, this - * function should be implemented to provide automatic tokens for those - * secrets and remove the need for manual mapping via YAML. + * defined based on connection parameters. * * @param userContext * The user context from which the connectable originated. @@ -315,7 +310,7 @@ public Map> getTokens(final UserContext userContext, final TokenFilter filter) throws GuacamoleException { final Map> tokens = new ConcurrentHashMap<>(); - + // An ordered array of non null HvClients final List clients = hvClientProvider.getHvClients(userContext, connectable); if (clients.size() == 0) { @@ -363,14 +358,14 @@ public Map> getTokens(final UserContext userContext, return tokens; } - + /** * Given a List of possible HvClients to use, attempt to resolve a secret value * with the HvClient that returns a value * * @param clients - * An ordered list of HvClient with application-wide client, ConnectGroup - * client followed by User client values, to ensure teh adminsitrator always + * An ordered list of HvClient with application-wide client, ConnectionGroup + * client followed by User client values, to ensure the adminsitrator always * has the last word on token resolution * * @param FinalName @@ -389,7 +384,7 @@ public Map> getTokens(final UserContext userContext, * A Future which completes with the value of the secret represented by * the given HV notation, or null if there is no such secret. */ - private Future resolveSecret(final List clients, final String finalName, + private Future resolveSecret(final List clients, final String finalName, final String username, final String key) throws GuacamoleException { return clients.get(0).getSecret(finalName, username, key) .handle((value, ex) -> { @@ -423,6 +418,6 @@ private Future resolveSecret(final List clients, final String return CompletableFuture.completedFuture(null); }) - .thenCompose(Function.identity()); + .thenCompose(Function.identity()); } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSshKeys.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSshKeys.java index 8682cbaa2d..12d913e1d3 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSshKeys.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSshKeys.java @@ -39,23 +39,23 @@ */ public class HvSshKeys { /** - * The value of vailt-ssh-type to use for RSA certificate + * The value of vault-ssh-type to use for RSA certificate */ public static final String RSA = "rsa"; - - + + /** - * The value of vailt-ssh-type to use for ED25519 certificate + * The value of vault-ssh-type to use for ED25519 certificate */ public static final String ED25519 = "ed25519"; - + /** * Logger for this class. */ private static final Logger logger = LoggerFactory.getLogger(HvSshKeys.class); /** - * The PEM encoded private SSH key + * The OpenSSH encoded private SSH key */ private final String privateSsh; @@ -130,6 +130,9 @@ private KeyPair generateEd25519WithFallback() { * * @return * A java.security.KeyPair containing the RSA key pair + * + * @throws IllegalStateException + * If the RSA key could not be returned */ private KeyPair generateRsa() { try { @@ -142,7 +145,7 @@ private KeyPair generateRsa() { throw new IllegalStateException("Failed to generate RSA SSH keypair", e); } } - + /** * Returns the generated SSH public certificate * diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvTunnelEventListener.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvTunnelEventListener.java index fc867e7730..361f0d69ee 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvTunnelEventListener.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvTunnelEventListener.java @@ -44,7 +44,7 @@ public HvTunnelEventListener() { * add a checkin task to the sessions * * @param event - * A tunnel close event + * A tunnel connect/close event */ @Override public void handleEvent(final Object event) throws GuacamoleException { diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnection.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnection.java index aef38eabbb..ac2780088f 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnection.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnection.java @@ -27,7 +27,7 @@ /** * A Connection that explicitly adds a blank entry for any defined HV - * connection attributes. This ensures that any such field will always + * connection attribute. This ensures that any such field will always * be displayed to the user when editing a connection through the UI. */ public class HvConnection extends DelegatingConnection { diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/FileTokenAuthentication.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/FileTokenAuthentication.java index 2f7dbd9e44..33b3a78020 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/FileTokenAuthentication.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/FileTokenAuthentication.java @@ -43,7 +43,7 @@ public final class FileTokenAuthentication implements ClientAuthentication { * is reread from a file on renewal requests. This allows integration * with a VaultAgent for complex authentication methods * - * @param String tokenPath + * @param tokenPath * A path to a readable file containing the token */ public FileTokenAuthentication(final String tokenPath) { diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/UsernamePasswordAuthentication.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/UsernamePasswordAuthentication.java index 00f24677af..48e55a6e3b 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/UsernamePasswordAuthentication.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/UsernamePasswordAuthentication.java @@ -87,8 +87,8 @@ public UsernamePasswordAuthentication( * the vault * * @throws VaultException - * In case of a recoverable login issue, throws a VaultExecption, so tha the - * SessionManager knows to try the authtication again + * In case of a recoverable login issue, throws a VaultException, so that the + * SessionManager knows to try the authentication again */ @Override public VaultToken login() throws VaultException { From 788d2c9437f2d4ab7d1d645327a8845cdc303480 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Fri, 12 Jun 2026 10:12:11 +0200 Subject: [PATCH 17/27] GUACAMOLE-2137: Remove unused import removed in previous commit --- .../org/apache/guacamole/vault/hv/HvAuthenticationProvider.java | 1 - 1 file changed, 1 deletion(-) diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProvider.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProvider.java index 86d174dd1f..2333887abc 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProvider.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProvider.java @@ -24,7 +24,6 @@ import org.apache.guacamole.net.auth.Credentials; import org.apache.guacamole.net.auth.UserContext; import org.apache.guacamole.vault.VaultAuthenticationProvider; -import org.apache.guacamole.vault.hv.user.HvUserContext; /** * VaultAuthenticationProvider implementation which reads secrets from From e26b6593a82fb0a20e8a18c8f1762e9c872bab31 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Fri, 12 Jun 2026 10:14:02 +0200 Subject: [PATCH 18/27] GUACAMOLE-2137: Rework signed SSH certificates - Add ECDSA ec256 to Guacamole signed certifcates - Add ability to get the Vault to issue the certificate - Add URL query parameters to modify certificate type on a token by token basis. --- .../vault/hv/conf/HvConfigurationService.java | 8 +- .../guacamole/vault/hv/secret/HvClient.java | 148 ++++++++++++++---- .../guacamole/vault/hv/secret/HvSshKeys.java | 38 ++++- 3 files changed, 154 insertions(+), 40 deletions(-) diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java index fbdbf60cf0..8794d4b637 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java @@ -338,7 +338,7 @@ public int getTokenRenewalDelay() throws GuacamoleException { /** * The type of SSH certificates are will be generated. Must be either - * 'rsa' for 4096-bit RSA keys or 'ed25519'. + * 'rsa' for 4096-bit RSA keys, 'ec' for ECDSA NIST P-256 keys or 'ed25519'. * * @return * The ssh type to use. @@ -348,8 +348,8 @@ public int getTokenRenewalDelay() throws GuacamoleException { */ public String getSshType() throws GuacamoleException { final String type = environment.getProperty(VAULT_SSH_TYPE, DEFAULT_SSH_TYPE); - if (! HvSshKeys.RSA.equals(type) & ! HvSshKeys.ED25519.equals(type)) { - throw new GuacamoleException("Only ssh certificate types 'rsa' (4096-bit) and 'ed25519' are supported"); + if (! HvSshKeys.RSA.equals(type) && ! HvSshKeys.ED25519.equals(type) && ! HvSshKeys.EC256.equals(type)) { + throw new GuacamoleException("Only ssh certificate types 'rsa' (4096-bit), 'ec' (256-bit) and 'ed25519' are supported"); } return type; } @@ -546,7 +546,7 @@ public int getTokenRenewalDelay() throws GuacamoleException { /** * The type of SSH certificates are will be generated. Must be either - * 'rsa' for 4096-bit RSA keys or 'ed25519'. + * 'rsa' for 4096-bit RSA keys, 'ec' for ECDSA NIST P-256 keys or 'ed25519'. * * @return * The ssh type to use. diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java index 5aab6b6d74..b3af6ce035 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java @@ -30,6 +30,7 @@ import java.nio.file.Paths; import java.time.Duration; import java.time.Instant; +import java.util.Arrays; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; @@ -78,6 +79,24 @@ public class HvClient { */ public static final String VAULT_LDAP_SESSION = "checkin"; + /** + * A token query parameter to modify the ssh type + */ + public static final String VAULT_SSH_KEY_TYPE = "key_type"; + + /** + * A token query parameter to modify the ssh key bits + */ + public static final String VAULT_SSH_KEY_BITS = "key_bits"; + + /** + * Map of valid query parameter keys and valid values of these keys in vault + * token query parameters + */ + public static final Map VAULT_QUERY_PARAMS = Map.of( + VAULT_SSH_KEY_TYPE, new String[] {HvSshKeys.RSA, HvSshKeys.EC256, HvSshKeys.ED25519}, + VAULT_SSH_KEY_BITS, new String[] {"256", "384", "521", "2048", "4096"}); + /** * Logger for this class. */ @@ -336,6 +355,33 @@ else if (session.isInitialized()) { } } + /** + * Parse a string of query parameters and return a Map of the key/value of + * the parameters for the valid query parameters + * + * @param query + * The query string extracted from the end of the token + * + * @return + * A Map the the query parameters extracted + */ + private Map parseQueryParam(final String query) { + Map queryMap = new ConcurrentHashMap<>(); + if (query != null && !query.isBlank()) { + for (String pair : query.split("&")) { + final int idx = pair.indexOf("="); + final String key = idx > 0 ? pair.substring(0, idx) : pair; + final String val = idx > 0 && pair.length() > idx + 1 ? pair.substring(idx +1) : null; + + VAULT_QUERY_PARAMS.forEach((k, v) -> { + if (val != null && k.equals(key) && Arrays.asList(v).contains(val)) + queryMap.put(key, val); + }); + } + } + return queryMap; + } + /** * Returns the value of the secret stored within OpenBao/Hashicorp Vault. * @@ -367,17 +413,27 @@ public CompletableFuture getSecret(final String notation, final String u } /* - * vault://path/to/secret <-- the Guacamole token name and its modifier - * ^^^^^^^ <-- this is the path - * ^^^^^^ <-- this is the secret (or key in HV terms) + * vault://path/to/secret?k=v <-- the Guacamole token name and its modifier + * ^^^^^^^ <-- this is the path + * ^^^^^^ <-- this is the secret (or key in HV terms) + * ^^^^ <-- These are the query parameters */ int lastSlashIndex = notation.lastIndexOf('/'); if (lastSlashIndex == -1) { lastSlashIndex = VAULT_TOKEN_PREFIX.length(); } - + int lastQueryIndex = notation.lastIndexOf('?'); + final String query; + if (lastQueryIndex == -1 || lastQueryIndex < lastSlashIndex) { + lastQueryIndex = notation.length(); + query = ""; + } + else { + query = notation.substring(lastQueryIndex + 1); + } + final Map queryMap = parseQueryParam(query); final String path = notation.substring(VAULT_TOKEN_PREFIX.length(), lastSlashIndex); - final String secret = notation.substring(lastSlashIndex + 1); + final String secret = notation.substring(lastSlashIndex + 1, lastQueryIndex); final JsonNode cachedSecrets = cache.getIfPresent(key); @@ -406,12 +462,13 @@ public CompletableFuture getSecret(final String notation, final String u final String type = getSecretsEngine(path).get("type").asText(); final String mountPath = getSecretsEngine(path).get("path").asText(); final String newpath = path.substring(mountPath.length()); - logger.debug("Vault {}, {}, {}, {}", type, mountPath, path, secret); + logger.debug("Vault {}, {}, {}, {}, {}", type, mountPath, path, secret, queryMap.keySet()); final JsonNode jsonNode; switch (type) { case "ssh": - jsonNode = getValueSSH(mountPath, newpath, username); + // Only the SSH engine takes query arguments at the moment + jsonNode = getValueSSH(mountPath, newpath, username, queryMap); break; case "ldap": jsonNode = getValueLDAP(mountPath, newpath); @@ -428,7 +485,6 @@ public CompletableFuture getSecret(final String notation, final String u default: throw new IllegalArgumentException("Unknown secret engine for the token: '" + type +"'"); } - return jsonNode; }) .orTimeout(cacheLifetime, TimeUnit.MILLISECONDS); @@ -503,7 +559,7 @@ private JsonNode getValueKV(final String mountPath, final String path, final Vau } /** - * Retrieves a an ssh one-time password or signed SSH certificate + * Retrieves an ssh one-time password or signed SSH certificate * * @param mountPath * The mountPath of the SSH secret engine on the vault server. @@ -521,11 +577,10 @@ private JsonNode getValueKV(final String mountPath, final String path, final Vau * @throws VaultException * If the secrets cannot be retrieved from the Vault. */ - private JsonNode getValueSSH(final String mountPath, final String path, final String username) throws VaultException { + private JsonNode getValueSSH(final String mountPath, final String path, + final String username, final Map queryMap) throws VaultException { + // SSH One-time passwords if (path.startsWith("creds/")) { - if (username == null || username.isEmpty()) { - throw new VaultException("The username can not be empty for SSH signed certificates"); - } final VaultResponse response = vaultTemplate.write(mountPath + path, Map.of("ip", "0.0.0.0")); @@ -539,40 +594,65 @@ private JsonNode getValueSSH(final String mountPath, final String path, final St return objectMapper.valueToTree(retval); } - if (path.startsWith("sign/")) { - - final HvSshKeys sshKeys; - final Map request; - try { - sshKeys = new HvSshKeys(vaultInfo.getSshType()); + if (!path.startsWith("sign/") && !path.startsWith("issue/")) { + throw new VaultException("Unknown SSH type on path: " + mountPath + path); + } + + if (username == null || username.isEmpty()) { + throw new VaultException("The username can not be empty for SSH signed certificates"); + } + + final HvSshKeys sshKeys; + final Map request; + try { + final String sshType = queryMap.getOrDefault(VAULT_SSH_KEY_TYPE, vaultInfo.getSshType()); + + if (path.startsWith("sign/")) { + sshKeys = new HvSshKeys(sshType); request = Map.of( "public_key", sshKeys.getPublic(), "valid_principals", username, "extensions", Map.of("permit-pty", ""), "ttl", vaultInfo.getSshConnectionTimeout()); } - catch (GuacamoleException e) { - throw new VaultException("Error reading Vault configuration : " + e.getMessage()); + else { + final String keyBits = queryMap.getOrDefault(VAULT_SSH_KEY_BITS, + (sshType == HvSshKeys.EC256 ? "256" : "4096")); + sshKeys = null; + request = Map.of( + "valid_principals", username, + "extensions", Map.of("permit-pty", ""), + "key_type", sshType, + "key_bits", keyBits, + "ttl", vaultInfo.getSshConnectionTimeout()); } + } + catch (GuacamoleException e) { + throw new VaultException("Error reading Vault configuration : " + e.getMessage()); + } - final VaultResponse vaultResponse = vaultTemplate.write(mountPath + path, request); + final VaultResponse vaultResponse = vaultTemplate.write(mountPath + path, request); - if (vaultResponse == null || vaultResponse.getData() == null) { - throw new VaultException("No response from Vault SSH engine"); - } - final Map data = vaultResponse.getData(); - final String signedCert = (String) data.get("signed_key"); + if (vaultResponse == null || vaultResponse.getData() == null) { + throw new VaultException("No response from Vault SSH engine"); + } - if (signedCert == null) { - throw new VaultException("Vault did not return a signed SSH certificate"); - } + final Map data = vaultResponse.getData(); + final String signedKey = (String) data.get("signed_key"); + final String privateKey = (path.startsWith("sign/") ? sshKeys.getPrivate() : (String) data.get("private_key")); - return objectMapper.valueToTree(Map.of("private", sshKeys.getPrivate(), - "public", signedCert, - "unsigned", sshKeys.getPublic())); + if (signedKey == null || privateKey == null) { + throw new VaultException("Vault did not return a signed SSH certificate"); } - throw new VaultException("Unknown SSH type on path: " + mountPath + path); + if (path.startsWith("sign/")) { + return objectMapper.valueToTree(Map.of("private", privateKey, + "public", signedKey, + "unsigned", sshKeys.getPublic())); + } + else { + return objectMapper.valueToTree(Map.of("private", privateKey, "public", signedKey)); + } } /** diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSshKeys.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSshKeys.java index 12d913e1d3..7821af85f3 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSshKeys.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSshKeys.java @@ -25,6 +25,7 @@ import java.security.KeyPair; import java.security.KeyPairGenerator; import java.security.GeneralSecurityException; +import java.security.spec.ECGenParameterSpec; import org.apache.guacamole.vault.hv.conf.HvConfigurationService; import org.apache.sshd.common.config.keys.writer.openssh.OpenSSHKeyPairResourceWriter; import org.apache.sshd.common.config.keys.PublicKeyEntry; @@ -43,12 +44,17 @@ public class HvSshKeys { */ public static final String RSA = "rsa"; - /** * The value of vault-ssh-type to use for ED25519 certificate */ public static final String ED25519 = "ed25519"; + /** + * The value of vault-ssh-type to use for EC256 (ECDSA with NIST P-256 + * curve) certificate + */ + public static final String EC256 = "ec"; + /** * Logger for this class. */ @@ -87,6 +93,9 @@ public HvSshKeys(final String type) { else if (ED25519.equals(type)) { keyPair = generateEd25519WithFallback(); } + else if (EC256.equals(type)) { + keyPair = generateEC256WithFallback(); + } else { throw new IllegalArgumentException("Unrecognized SSH encryption : "+ type); } @@ -125,6 +134,30 @@ private KeyPair generateEd25519WithFallback() { return keyPair; } + /** + * Generate a ec256 (NIST P-256 curve) key-pair + * + * @return + * A java.security.KeyPair containing the ec256 key pair or RSA if failure + */ + private KeyPair generateEC256WithFallback() { + KeyPair keyPair; + try { + final KeyPairGenerator keyPairGenerator = SecurityUtils.getKeyPairGenerator("EC"); + + // EC256 = secp256r1 (aka NIST P-256) + final ECGenParameterSpec ecSpec = new ECGenParameterSpec("secp256r1"); + keyPairGenerator.initialize(ecSpec); + + keyPair = keyPairGenerator.generateKeyPair(); + } catch (GeneralSecurityException e) { + logger.warn("EC256 not available via SSHD ECDSA (NIST P-256). Falling back to RSA : {}", e.getMessage()); + keyPair = generateRsa(); + } + + return keyPair; + } + /** * Generate a 4096-bit RSA key-pair * @@ -142,7 +175,8 @@ private KeyPair generateRsa() { return keyPairGenerator.generateKeyPair(); } catch (GeneralSecurityException e) { - throw new IllegalStateException("Failed to generate RSA SSH keypair", e); + logger.debug("Failed to generate RSA SSH keypair", e); + throw new IllegalStateException("Failed to generate RSA SSH keypair: " + e.getMessage()); } } From 0df17577484da6c0e3f8b69421c17ed00cccfa9d Mon Sep 17 00:00:00 2001 From: David Bateman Date: Mon, 15 Jun 2026 16:09:44 +0200 Subject: [PATCH 19/27] GUACAMOLE-2137: Always include the secret record mount path in the cache key, to allow tokens from different secret records on the same connection --- .../org/apache/guacamole/vault/hv/secret/HvSecretService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java index 491b0559e1..76d4ce0bf9 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java @@ -337,7 +337,8 @@ public Map> getTokens(final UserContext userContext, final String notation = tokenMatcher.group(1); final String finalName = prepareToken(notation, userContext, config, filter); - tokens.put(notation, resolveSecret(clients, finalName, username, key)); + tokens.put(notation, resolveSecret(clients, finalName, username, key + + finalName.substring(0, finalName.lastIndexOf('/')))); } } From eb28a80966fc4002f25a88aea775c40a95132757 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Tue, 16 Jun 2026 10:32:11 +0200 Subject: [PATCH 20/27] GUACAMOLE-2137: Don't hard-code jackson databind version --- extensions/guacamole-vault/modules/guacamole-vault-hv/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/pom.xml b/extensions/guacamole-vault/modules/guacamole-vault-hv/pom.xml index 35410b742c..304fe1c19a 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/pom.xml +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/pom.xml @@ -58,7 +58,7 @@ com.fasterxml.jackson.core jackson-databind - 2.19.0 + ${jackson-databind.version} From 37c59508563161f03839833b845e41b69dd890df Mon Sep 17 00:00:00 2001 From: David Bateman Date: Fri, 19 Jun 2026 09:43:17 +0200 Subject: [PATCH 21/27] GUACAMOLE-2137: Allow use of 'signed_key' and 'private_key' in ssh secret engine for consistency with the vault itself --- .../java/org/apache/guacamole/vault/hv/secret/HvClient.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java index b3af6ce035..fc9b0d7206 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java @@ -647,7 +647,9 @@ private JsonNode getValueSSH(final String mountPath, final String path, if (path.startsWith("sign/")) { return objectMapper.valueToTree(Map.of("private", privateKey, + "private_key", privateKey, "public", signedKey, + "signed_key", signedKey, "unsigned", sshKeys.getPublic())); } else { From 7d6e8d3fa5685018d34c5eab8056105b97525c84 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Fri, 19 Jun 2026 15:25:19 +0200 Subject: [PATCH 22/27] GUACAMOLE-2137: Remove some unused code. Use ParameterizedTypeReference to avoid casting warning --- .../vault/hv/HvAuthenticationProvider.java | 3 --- .../vault/hv/conf/HvConfigurationService.java | 7 ------- .../vault/hv/secret/HvClientProvider.java | 2 +- .../vault/hv/secret/HvSecretService.java | 20 +------------------ .../vault/hv/user/HvConnectionGroup.java | 7 ------- .../vault/hv/user/HvDirectoryService.java | 8 -------- .../hv/vault/TtlAwareSessionManager.java | 10 ++++------ 7 files changed, 6 insertions(+), 51 deletions(-) diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProvider.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProvider.java index 2333887abc..fa04f4388b 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProvider.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/HvAuthenticationProvider.java @@ -20,9 +20,6 @@ package org.apache.guacamole.vault.hv; import org.apache.guacamole.GuacamoleException; -import org.apache.guacamole.net.auth.AuthenticatedUser; -import org.apache.guacamole.net.auth.Credentials; -import org.apache.guacamole.net.auth.UserContext; import org.apache.guacamole.vault.VaultAuthenticationProvider; /** diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java index 8794d4b637..68f9d83a12 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java @@ -31,8 +31,6 @@ import org.apache.guacamole.properties.URIGuacamoleProperty; import org.apache.guacamole.vault.conf.VaultConfigurationService; import org.apache.guacamole.vault.hv.secret.HvSshKeys; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * Service class to retrieve configuration information for an OpenBao @@ -41,11 +39,6 @@ @Singleton public class HvConfigurationService extends VaultConfigurationService { - /** - * Logger for this class. - */ - private static final Logger logger = LoggerFactory.getLogger(VaultConfigurationService.class); - /** * The default cache lifetime in milliseconds. */ diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientProvider.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientProvider.java index c1ee7ea637..be237261a0 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientProvider.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClientProvider.java @@ -344,7 +344,7 @@ public static void treatLdapSession(final Object event) { final String tunnelId = ((TunnelConnectEvent) event).getTunnel().getUUID().toString(); for (final Map.Entry entry : hvClientMap.entrySet()) { final HvClient client = entry.getValue(); - if (client.treatLdapSession(client.VAULT_LDAP_SESSION, tunnelId)) { + if (client.treatLdapSession(HvClient.VAULT_LDAP_SESSION, tunnelId)) { break; } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java index 76d4ce0bf9..660dbb3add 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java @@ -38,7 +38,6 @@ import org.apache.guacamole.net.auth.UserContext; import org.apache.guacamole.protocol.GuacamoleConfiguration; import org.apache.guacamole.token.TokenFilter; -import org.apache.guacamole.vault.hv.conf.HvConfigurationService; import org.apache.guacamole.vault.secret.VaultSecretService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -57,11 +56,6 @@ public class HvSecretService implements VaultSecretService { */ private static final Logger logger = LoggerFactory.getLogger(HvSecretService.class); - /** - * Service for retrieving configuration information. - */ - private final HvConfigurationService confService; - /** * Provider for HV client instances. */ @@ -71,15 +65,11 @@ public class HvSecretService implements VaultSecretService { * Public constructor for Guice, so that we can instantiate the existing * Vault clients early, to avoid problems with expiring tokens * - * @param confService - * Service for retrieving configuration information - * * @param hvClientFactory * Factory for creating HV client instances */ @Inject - public HvSecretService(final HvConfigurationService confService, final HvClientProvider hvClientProvider) { - this.confService = confService; + public HvSecretService(final HvClientProvider hvClientProvider) { this.hvClientProvider = hvClientProvider; // Instantiate the HvClient early to start Token renewal of main Vault account. @@ -169,17 +159,9 @@ private String prepareToken(final String oldtoken, final UserContext userContext final String guacUsername = userContext == null ? "" : userContext.self().getIdentifier(); token = applyToken(token, guacUsername, "{GUAC_USERNAME}", filter); - - final String hostname = config.getParameter("hostname"); token = applyToken(token, config.getParameter("hostname"), "{HOSTNAME}", filter); - - final String username = config.getParameter("username"); token = applyToken(token, config.getParameter("username"), "{USERNAME}", filter); - - final String gatewayHostname = config.getParameter("gateway-hostname"); token = applyToken(token, config.getParameter("gateway-hostname"), "{GATEWAY}", filter); - - final String gatewayUsername = config.getParameter("gateway-username"); token = applyToken(token, config.getParameter("gateway-username"), "{GATEWAY_USER}", filter); return token; diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnectionGroup.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnectionGroup.java index 2f8f548a14..5f34ecce0b 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnectionGroup.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvConnectionGroup.java @@ -24,8 +24,6 @@ import org.apache.guacamole.net.auth.ConnectionGroup; import org.apache.guacamole.net.auth.DelegatingConnectionGroup; import org.apache.guacamole.vault.hv.conf.HvAttributeService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * A HV-specific connection group implementation that always exposes @@ -34,11 +32,6 @@ */ public class HvConnectionGroup extends DelegatingConnectionGroup { - /** - * Logger for this class. - */ - private static final Logger logger = LoggerFactory.getLogger(HvConnectionGroup.class); - /** * Create a new HvConnectionGroup wrapping the provided ConnectionGroup record. * diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectoryService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectoryService.java index 7812a1f9b3..df71d18346 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectoryService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/user/HvDirectoryService.java @@ -26,7 +26,6 @@ import org.apache.guacamole.net.auth.DecoratingDirectory; import org.apache.guacamole.net.auth.Directory; import org.apache.guacamole.net.auth.User; -import org.apache.guacamole.vault.hv.conf.HvAttributeService; import org.apache.guacamole.vault.user.VaultDirectoryService; /** @@ -41,13 +40,6 @@ public class HvDirectoryService extends VaultDirectoryService { @Inject private HvUser.HvUserFactory hvUserFactory; - /** - * Service for retrieving any custom attributes defined for the - * current vault implementation and processing of said attributes. - */ - @Inject - private HvAttributeService attributeService; - @Override public Directory getConnectionDirectory(final Directory underlyingDirectory) throws GuacamoleException { diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/TtlAwareSessionManager.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/TtlAwareSessionManager.java index 7d723ca732..7172e14c8f 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/TtlAwareSessionManager.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/vault/TtlAwareSessionManager.java @@ -25,6 +25,8 @@ import java.util.Optional; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.atomic.AtomicReference; + +import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; @@ -53,10 +55,6 @@ * - Handle both LoginToken and VaultToken types. */ public final class TtlAwareSessionManager implements SessionManager { - /** - * The outcome of the renew token process - */ - private enum Outcome { CONTINUE, REAUTH, STOP } /** * Logger for this class. @@ -270,13 +268,13 @@ private TokenInfo getTokenInfo(final VaultToken token) { headers.set("X-Vault-Token", token.getToken()); final HttpEntity requestEntity = new HttpEntity<>(headers); - final ResponseEntity response; + final ResponseEntity> response; try { response = restOperations.exchange( "/auth/token/lookup-self", HttpMethod.GET, requestEntity, - Map.class + new ParameterizedTypeReference>() {} ); } catch (RestClientException e) { From 084676c1ab0d5632dc1fe96b2549bddf516b3372 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Mon, 22 Jun 2026 09:35:57 +0200 Subject: [PATCH 23/27] GUACAMOLE-2137: Don't hardcode sparing-framework dependency versions --- doc/licenses/spring-framework/dep-coordinates.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/licenses/spring-framework/dep-coordinates.txt b/doc/licenses/spring-framework/dep-coordinates.txt index 8cc4518918..c9d1cb7d8b 100644 --- a/doc/licenses/spring-framework/dep-coordinates.txt +++ b/doc/licenses/spring-framework/dep-coordinates.txt @@ -4,5 +4,5 @@ org.springframework:spring-beans:jar:* org.springframework:spring-context:jar:* org.springframework:spring-core:jar:* org.springframework:spring-expression:jar:* -org.springframework:spring-jcl:jar:5.3.29 -org.springframework:spring-web:jar:5.3.29 +org.springframework:spring-jcl:jar:* +org.springframework:spring-web:jar:* From 86f76641f690abd2c634b8c3f768222956ec7cc1 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Mon, 22 Jun 2026 09:37:57 +0200 Subject: [PATCH 24/27] GUACAMOLE-2137: Move all SSH constants to HvSshKey class. Also add 'signed_key' and 'private_key' secret values to issued SSH certificates --- .../guacamole/vault/hv/secret/HvClient.java | 23 +++++++------------ .../guacamole/vault/hv/secret/HvSshKeys.java | 10 ++++++++ 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java index fc9b0d7206..1e20884bfd 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java @@ -79,23 +79,13 @@ public class HvClient { */ public static final String VAULT_LDAP_SESSION = "checkin"; - /** - * A token query parameter to modify the ssh type - */ - public static final String VAULT_SSH_KEY_TYPE = "key_type"; - - /** - * A token query parameter to modify the ssh key bits - */ - public static final String VAULT_SSH_KEY_BITS = "key_bits"; - /** * Map of valid query parameter keys and valid values of these keys in vault * token query parameters */ public static final Map VAULT_QUERY_PARAMS = Map.of( - VAULT_SSH_KEY_TYPE, new String[] {HvSshKeys.RSA, HvSshKeys.EC256, HvSshKeys.ED25519}, - VAULT_SSH_KEY_BITS, new String[] {"256", "384", "521", "2048", "4096"}); + HvSshKeys.VAULT_SSH_KEY_TYPE, new String[] {HvSshKeys.RSA, HvSshKeys.EC256, HvSshKeys.ED25519}, + HvSshKeys.VAULT_SSH_KEY_BITS, new String[] {"256", "384", "521", "2048", "4096"}); /** * Logger for this class. @@ -605,7 +595,7 @@ private JsonNode getValueSSH(final String mountPath, final String path, final HvSshKeys sshKeys; final Map request; try { - final String sshType = queryMap.getOrDefault(VAULT_SSH_KEY_TYPE, vaultInfo.getSshType()); + final String sshType = queryMap.getOrDefault(HvSshKeys.VAULT_SSH_KEY_TYPE, vaultInfo.getSshType()); if (path.startsWith("sign/")) { sshKeys = new HvSshKeys(sshType); @@ -616,7 +606,7 @@ private JsonNode getValueSSH(final String mountPath, final String path, "ttl", vaultInfo.getSshConnectionTimeout()); } else { - final String keyBits = queryMap.getOrDefault(VAULT_SSH_KEY_BITS, + final String keyBits = queryMap.getOrDefault(HvSshKeys.VAULT_SSH_KEY_BITS, (sshType == HvSshKeys.EC256 ? "256" : "4096")); sshKeys = null; request = Map.of( @@ -653,7 +643,10 @@ private JsonNode getValueSSH(final String mountPath, final String path, "unsigned", sshKeys.getPublic())); } else { - return objectMapper.valueToTree(Map.of("private", privateKey, "public", signedKey)); + return objectMapper.valueToTree(Map.of("private", privateKey, + "private_key", privateKey, + "public", signedKey, + "signed_key", signedKey)); } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSshKeys.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSshKeys.java index 7821af85f3..cd0925f1ef 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSshKeys.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSshKeys.java @@ -55,6 +55,16 @@ public class HvSshKeys { */ public static final String EC256 = "ec"; + /** + * A token query parameter to modify the ssh type + */ + public static final String VAULT_SSH_KEY_TYPE = "key_type"; + + /** + * A token query parameter to modify the ssh key bits + */ + public static final String VAULT_SSH_KEY_BITS = "key_bits"; + /** * Logger for this class. */ From 91dc039b142eb6b4ad92d5a88d14f7153f85a6dd Mon Sep 17 00:00:00 2001 From: David Bateman Date: Mon, 22 Jun 2026 10:42:51 +0200 Subject: [PATCH 25/27] GUACAMOLE-2137: Ensure resolveSecret returns null if there are no vaults configured --- .../org/apache/guacamole/vault/hv/secret/HvSecretService.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java index 660dbb3add..07cd699492 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java @@ -369,6 +369,9 @@ public Map> getTokens(final UserContext userContext, */ private Future resolveSecret(final List clients, final String finalName, final String username, final String key) throws GuacamoleException { + if (clients.size() == 0) { + return CompletableFuture.completedFuture(null); + } return clients.get(0).getSecret(finalName, username, key) .handle((value, ex) -> { if (ex == null) { From 4d011128cea0f7f761e8196f37f95a7c9b7f7981 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Wed, 24 Jun 2026 09:25:32 +0200 Subject: [PATCH 26/27] GUACAMOLE-2137: Minor coding fixes suggested by PMD --- .../guacamole/vault/hv/secret/HvClient.java | 17 +++++++++-------- .../vault/hv/secret/HvSecretService.java | 6 +++--- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java index 1e20884bfd..7ec3e15169 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java @@ -356,10 +356,10 @@ else if (session.isInitialized()) { * A Map the the query parameters extracted */ private Map parseQueryParam(final String query) { - Map queryMap = new ConcurrentHashMap<>(); + final Map queryMap = new ConcurrentHashMap<>(); if (query != null && !query.isBlank()) { - for (String pair : query.split("&")) { - final int idx = pair.indexOf("="); + for (final String pair : query.split("&")) { + final int idx = pair.indexOf('='); final String key = idx > 0 ? pair.substring(0, idx) : pair; final String val = idx > 0 && pair.length() > idx + 1 ? pair.substring(idx +1) : null; @@ -584,7 +584,8 @@ private JsonNode getValueSSH(final String mountPath, final String path, return objectMapper.valueToTree(retval); } - if (!path.startsWith("sign/") && !path.startsWith("issue/")) { + final boolean isSign = path.startsWith("sign/"); + if (!isSign && !path.startsWith("issue/")) { throw new VaultException("Unknown SSH type on path: " + mountPath + path); } @@ -597,7 +598,7 @@ private JsonNode getValueSSH(final String mountPath, final String path, try { final String sshType = queryMap.getOrDefault(HvSshKeys.VAULT_SSH_KEY_TYPE, vaultInfo.getSshType()); - if (path.startsWith("sign/")) { + if (isSign) { sshKeys = new HvSshKeys(sshType); request = Map.of( "public_key", sshKeys.getPublic(), @@ -607,7 +608,7 @@ private JsonNode getValueSSH(final String mountPath, final String path, } else { final String keyBits = queryMap.getOrDefault(HvSshKeys.VAULT_SSH_KEY_BITS, - (sshType == HvSshKeys.EC256 ? "256" : "4096")); + sshType == HvSshKeys.EC256 ? "256" : "4096"); sshKeys = null; request = Map.of( "valid_principals", username, @@ -629,13 +630,13 @@ private JsonNode getValueSSH(final String mountPath, final String path, final Map data = vaultResponse.getData(); final String signedKey = (String) data.get("signed_key"); - final String privateKey = (path.startsWith("sign/") ? sshKeys.getPrivate() : (String) data.get("private_key")); + final String privateKey = isSign ? sshKeys.getPrivate() : (String) data.get("private_key"); if (signedKey == null || privateKey == null) { throw new VaultException("Vault did not return a signed SSH certificate"); } - if (path.startsWith("sign/")) { + if (isSign) { return objectMapper.valueToTree(Map.of("private", privateKey, "private_key", privateKey, "public", signedKey, diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java index 07cd699492..37c2153ca3 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSecretService.java @@ -204,7 +204,7 @@ public Future getValue(final UserContext userContext, // An ordered array of non null HvClients final List clients = hvClientProvider.getHvClients(userContext, connectable); - if (clients.size() == 0) { + if (clients.isEmpty()) { return null; } @@ -295,7 +295,7 @@ public Map> getTokens(final UserContext userContext, // An ordered array of non null HvClients final List clients = hvClientProvider.getHvClients(userContext, connectable); - if (clients.size() == 0) { + if (clients.isEmpty()) { return tokens; } @@ -369,7 +369,7 @@ public Map> getTokens(final UserContext userContext, */ private Future resolveSecret(final List clients, final String finalName, final String username, final String key) throws GuacamoleException { - if (clients.size() == 0) { + if (clients.isEmpty()) { return CompletableFuture.completedFuture(null); } return clients.get(0).getSecret(finalName, username, key) From d3b3b5e1e3a7f9284912223e12cd46c0ef2018db Mon Sep 17 00:00:00 2001 From: David Bateman Date: Fri, 10 Jul 2026 14:18:54 +0200 Subject: [PATCH 27/27] GUACAMOLE-2137: Also allow key_bits for SSH signed certificates in the same way as for issued certificates --- .../vault/hv/conf/HvConfigurationService.java | 7 ++-- .../guacamole/vault/hv/secret/HvClient.java | 8 ++-- .../guacamole/vault/hv/secret/HvSshKeys.java | 42 ++++++++++++------- 3 files changed, 34 insertions(+), 23 deletions(-) diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java index 68f9d83a12..f442dcb23c 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/conf/HvConfigurationService.java @@ -331,7 +331,8 @@ public int getTokenRenewalDelay() throws GuacamoleException { /** * The type of SSH certificates are will be generated. Must be either - * 'rsa' for 4096-bit RSA keys, 'ec' for ECDSA NIST P-256 keys or 'ed25519'. + * 'rsa' for 4096-bit RSA keys, 'ec' for ECDSA NIST P-256/P-384/P-521 keys + * or 'ed25519'. * * @return * The ssh type to use. @@ -341,8 +342,8 @@ public int getTokenRenewalDelay() throws GuacamoleException { */ public String getSshType() throws GuacamoleException { final String type = environment.getProperty(VAULT_SSH_TYPE, DEFAULT_SSH_TYPE); - if (! HvSshKeys.RSA.equals(type) && ! HvSshKeys.ED25519.equals(type) && ! HvSshKeys.EC256.equals(type)) { - throw new GuacamoleException("Only ssh certificate types 'rsa' (4096-bit), 'ec' (256-bit) and 'ed25519' are supported"); + if (! HvSshKeys.RSA.equals(type) && ! HvSshKeys.ED25519.equals(type) && ! HvSshKeys.EC.equals(type)) { + throw new GuacamoleException("Only ssh certificate types 'rsa' (4096 or 2048-bit), 'ec' (256, 384 or 521-bit) and 'ed25519' are supported"); } return type; } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java index 7ec3e15169..bb7e5d7ad5 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvClient.java @@ -84,7 +84,7 @@ public class HvClient { * token query parameters */ public static final Map VAULT_QUERY_PARAMS = Map.of( - HvSshKeys.VAULT_SSH_KEY_TYPE, new String[] {HvSshKeys.RSA, HvSshKeys.EC256, HvSshKeys.ED25519}, + HvSshKeys.VAULT_SSH_KEY_TYPE, new String[] {HvSshKeys.RSA, HvSshKeys.EC, HvSshKeys.ED25519}, HvSshKeys.VAULT_SSH_KEY_BITS, new String[] {"256", "384", "521", "2048", "4096"}); /** @@ -597,9 +597,11 @@ private JsonNode getValueSSH(final String mountPath, final String path, final Map request; try { final String sshType = queryMap.getOrDefault(HvSshKeys.VAULT_SSH_KEY_TYPE, vaultInfo.getSshType()); + final String keyBits = queryMap.getOrDefault(HvSshKeys.VAULT_SSH_KEY_BITS, + (sshType == HvSshKeys.EC ? "256" : "4096")); if (isSign) { - sshKeys = new HvSshKeys(sshType); + sshKeys = new HvSshKeys(sshType, Integer.parseInt(keyBits)); request = Map.of( "public_key", sshKeys.getPublic(), "valid_principals", username, @@ -607,8 +609,6 @@ private JsonNode getValueSSH(final String mountPath, final String path, "ttl", vaultInfo.getSshConnectionTimeout()); } else { - final String keyBits = queryMap.getOrDefault(HvSshKeys.VAULT_SSH_KEY_BITS, - sshType == HvSshKeys.EC256 ? "256" : "4096"); sshKeys = null; request = Map.of( "valid_principals", username, diff --git a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSshKeys.java b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSshKeys.java index cd0925f1ef..9997523427 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSshKeys.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-hv/src/main/java/org/apache/guacamole/vault/hv/secret/HvSshKeys.java @@ -50,10 +50,10 @@ public class HvSshKeys { public static final String ED25519 = "ed25519"; /** - * The value of vault-ssh-type to use for EC256 (ECDSA with NIST P-256 - * curve) certificate + * The value of vault-ssh-type to use for EC (ECDSA with NIST P-256 + * P-384 or P-521 curve) certificate */ - public static final String EC256 = "ec"; + public static final String EC = "ec"; /** * A token query parameter to modify the ssh type @@ -84,7 +84,7 @@ public class HvSshKeys { * Class instantiation to return generated SSH keys. Default type */ public HvSshKeys() { - this(HvConfigurationService.DEFAULT_SSH_TYPE); + this(HvConfigurationService.DEFAULT_SSH_TYPE, 0); } /** @@ -94,17 +94,17 @@ public HvSshKeys() { * The type of ssh key to generate. Can be "rsa" or "ed25519" only. * Generated RSA keys are 4096 bit only */ - public HvSshKeys(final String type) { + public HvSshKeys(final String type, final int keyBits) { final KeyPair keyPair; if (RSA.equals(type)) { - keyPair = generateRsa(); + keyPair = generateRsa(keyBits); } else if (ED25519.equals(type)) { keyPair = generateEd25519WithFallback(); } - else if (EC256.equals(type)) { - keyPair = generateEC256WithFallback(); + else if (EC.equals(type)) { + keyPair = generateECWithFallback(keyBits); } else { throw new IllegalArgumentException("Unrecognized SSH encryption : "+ type); @@ -138,7 +138,7 @@ private KeyPair generateEd25519WithFallback() { } catch (GeneralSecurityException e) { logger.warn("Ed25519 not available via SSHD EdDSA. Falling back to RSA : {}", e.getMessage()); - keyPair = generateRsa(); + keyPair = generateRsa(4096); } return keyPair; @@ -150,19 +150,28 @@ private KeyPair generateEd25519WithFallback() { * @return * A java.security.KeyPair containing the ec256 key pair or RSA if failure */ - private KeyPair generateEC256WithFallback() { + private KeyPair generateECWithFallback(final int keyBits) { KeyPair keyPair; try { final KeyPairGenerator keyPairGenerator = SecurityUtils.getKeyPairGenerator("EC"); - // EC256 = secp256r1 (aka NIST P-256) - final ECGenParameterSpec ecSpec = new ECGenParameterSpec("secp256r1"); + final ECGenParameterSpec ecSpec; + if (keyBits == 384) + // EC384 = secp384r1 (aka NIST P-384) + ecSpec = new ECGenParameterSpec("secp384r1"); + else if (keyBits == 521) + // EC521 = secp521r1 (aka NIST P-521) + ecSpec = new ECGenParameterSpec("secp521r1"); + else + // EC256 = secp256r1 (aka NIST P-256) + ecSpec = new ECGenParameterSpec("secp256r1"); + keyPairGenerator.initialize(ecSpec); keyPair = keyPairGenerator.generateKeyPair(); } catch (GeneralSecurityException e) { - logger.warn("EC256 not available via SSHD ECDSA (NIST P-256). Falling back to RSA : {}", e.getMessage()); - keyPair = generateRsa(); + logger.warn("EC not available via SSHD ECDSA (NIST P-256, P-384 or P-521). Falling back to RSA : {}", e.getMessage()); + keyPair = generateRsa(4096); } return keyPair; @@ -177,11 +186,12 @@ private KeyPair generateEC256WithFallback() { * @throws IllegalStateException * If the RSA key could not be returned */ - private KeyPair generateRsa() { + private KeyPair generateRsa(final int keyBits) { try { final KeyPairGenerator keyPairGenerator = SecurityUtils.getKeyPairGenerator("RSA"); - keyPairGenerator.initialize(4096); + // Only support 2048 and 4096 bit RSA + keyPairGenerator.initialize(keyBits == 2048 ? 2048 : 4096); return keyPairGenerator.generateKeyPair(); } catch (GeneralSecurityException e) {