From e177097a92551bb764daadeffab5049714918b29 Mon Sep 17 00:00:00 2001 From: Subba Reddy Alamuru Date: Tue, 17 Feb 2026 10:39:46 +0530 Subject: [PATCH 01/16] GUACAMOLE-2196: Add OpenBao vault integration extension --- .../guacamole-vault-openbao/.ratignore | 0 .../modules/guacamole-vault-openbao/README.md | 209 ++++++++++++++++++ .../modules/guacamole-vault-openbao/pom.xml | 70 ++++++ .../OpenBaoAuthenticationProvider.java | 54 +++++ .../OpenBaoAuthenticationProviderModule.java | 78 +++++++ .../vault/openbao/conf/OpenBaoConfig.java | 64 ++++++ .../conf/OpenBaoConfigurationService.java | 122 ++++++++++ .../vault/openbao/secret/OpenBaoClient.java | 164 ++++++++++++++ .../openbao/secret/OpenBaoSecretService.java | 172 ++++++++++++++ .../openbao/user/OpenBaoAttributeService.java | 58 +++++ .../openbao/user/OpenBaoDirectoryService.java | 80 +++++++ .../resource-templates/guac-manifest.json | 12 + extensions/guacamole-vault/pom.xml | 1 + .../build.d/010-map-guacamole-extensions.sh | 1 + 14 files changed, 1085 insertions(+) create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/.ratignore create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/README.md create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/pom.xml create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProvider.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProviderModule.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfig.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfigurationService.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/user/OpenBaoAttributeService.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/user/OpenBaoDirectoryService.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/resource-templates/guac-manifest.json diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/.ratignore b/extensions/guacamole-vault/modules/guacamole-vault-openbao/.ratignore new file mode 100644 index 0000000000..e69de29bb2 diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/README.md b/extensions/guacamole-vault/modules/guacamole-vault-openbao/README.md new file mode 100644 index 0000000000..ba6996d49a --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/README.md @@ -0,0 +1,209 @@ +# OpenBao Vault Extension for Apache Guacamole + +This extension integrates Apache Guacamole with [OpenBao](https://openbao.org/) vault to automatically retrieve connection credentials from OpenBao at connection time. + +## Overview + +The OpenBao vault extension allows Guacamole to retrieve credentials from an OpenBao server using token-based authentication. Connection parameters configured with special tokens like `${OPENBAO_SECRET}` are automatically replaced with values retrieved from OpenBao when a user connects. + +## Features + +- **Automatic Credential Retrieval**: Fetches credentials from OpenBao without requiring users to re-enter passwords +- **Token-Based Resolution**: Uses `${OPENBAO_SECRET}` and `${GUAC_USERNAME}` tokens in connection parameters +- **KV v2 Support**: Works with OpenBao KV v2 secrets engine +- **Simple Configuration**: Minimal configuration required in `guacamole.properties` +- **Secure**: Uses OpenBao's token-based authentication for secure API access + +## How It Works + +1. User logs into Guacamole with their username +2. User initiates a connection configured with `${OPENBAO_SECRET}` token +3. Extension queries OpenBao API to retrieve the secret for that username +4. Password is extracted and injected into the connection parameters +5. Connection proceeds with the retrieved credentials + +## Configuration + +### OpenBao Server Setup + +Before using this extension, you need: + +1. An OpenBao server running and accessible from the Guacamole server +2. A KV v2 secrets engine mounted (eg path: `guacamole-credentails`) +3. An OpenBao authentication token with read access to the secrets +4. Secrets stored with a `password` field in the data + +Example secret structure: +```json +{ + "data": { + "data": { + "username": "user1", + "password": "SecretPassword123" + } + } +} +``` + +### Guacamole Configuration + +Add the following properties to `guacamole.properties`: + +```properties +# OpenBao server URL (required) +openbao-server-url: http://openbao.example.com:8200 + +# OpenBao authentication token (required) +openbao-token: s.YourTokenHere + +# KV mount path (optional, default: guacamole-credentails) +openbao-mount-path: guacamole-credentails +``` + +**Note**: The extension uses hardcoded defaults for: +- KV version: `2` (KV v2 secrets engine) +- Connection timeout: `5000ms` (5 seconds) +- Request timeout: `10000ms` (10 seconds) + +### Connection Configuration + +When creating connections in Guacamole, use these token patterns: + +- **`${OPENBAO_SECRET}`**: Replaced with the password from OpenBao +- **`${GUAC_USERNAME}`**: Replaced with the logged-in Guacamole username + +Example RDP connection: +- Username: `${GUAC_USERNAME}` +- Password: `${OPENBAO_SECRET}` +- Hostname: `192.168.1.100` + +## Secret Path Mapping + +The extension maps Guacamole usernames directly to OpenBao secret paths: + +``` +Guacamole username: "john" +OpenBao secret path: /v1/guacamole-credentails/data/john +``` + +For each user, create a corresponding secret in OpenBao at the path matching their Guacamole username. + +## Building + +Build the extension from the guacamole-client source tree: + +```bash +cd extensions/guacamole-vault +mvn clean package +``` + +The built extension will be located at: +``` +modules/guacamole-vault-openbao/target/guacamole-vault-openbao-.jar +``` + +## Installation + +1. Copy the built JAR to the Guacamole extensions directory: + ```bash + cp guacamole-vault-openbao-*.jar /etc/guacamole/extensions/ + ``` + +2. Ensure `guacamole-vault-base-*.jar` is also present in the extensions directory (it's a dependency) + +3. Configure `guacamole.properties` as described above + +4. Restart Guacamole (e.g., restart Tomcat) + +## Security Considerations + +1. **Protect the OpenBao Token**: Use a dedicated token with minimal permissions (read-only access to required secret paths) + +2. **Use TLS in Production**: Always use HTTPS URLs for OpenBao in production: + ```properties + openbao-server-url: https://openbao.example.com:8200 + ``` + +3. **Network Security**: Restrict OpenBao access to the Guacamole server using firewall rules + +4. **Audit Logging**: Enable OpenBao audit logging to track credential access + +5. **Token Rotation**: Regularly rotate OpenBao tokens and update the configuration + +## Troubleshooting + +### Extension Not Loading + +Check the Guacamole logs (typically in Tomcat's `catalina.out`) for errors. Common issues: + +- Missing `guacamole-vault-base` dependency +- Incorrect permissions on JAR files +- Configuration errors in `guacamole.properties` + +### Secret Not Found + +Error: `Secret not found in OpenBao for username: john` + +Solutions: +1. Verify the secret exists in OpenBao at the expected path +2. Check that the Guacamole username matches the secret name in OpenBao +3. Verify the token has read access to the secret + +### Permission Denied + +Error: `Permission denied accessing OpenBao. Check token permissions.` + +Solutions: +1. Verify the token has appropriate policies attached +2. Check that the token hasn't expired +3. Ensure the token has read access to the KV mount path + +### Connection Timeout + +Error: `Failed to communicate with OpenBao` + +Solutions: +1. Verify OpenBao is accessible from the Guacamole server +2. Check firewall rules between Guacamole and OpenBao +3. Verify the OpenBao URL is correct in the configuration + +## Example Deployment + +1. **Setup OpenBao**: + ```bash + # Start OpenBao + bao server -dev + + # Enable KV v2 engine + bao secrets enable -path=guacamole-credentails kv-v2 + + # Create a secret + bao kv put guacamole-credentails/john password=SecretPass123 + ``` + +2. **Configure Guacamole**: + ```properties + openbao-server-url: http://openbao.example.com:8200 + openbao-token: s.yourtokenhere + openbao-mount-path: guacamole-credentails + ``` + +3. **Create Connection**: + - Name: Windows Server + - Protocol: RDP + - Hostname: 192.168.1.100 + - Username: `${GUAC_USERNAME}` + - Password: `${OPENBAO_SECRET}` + +4. **Connect**: Log in as user "john" and connect to the Windows Server connection. The password will be automatically retrieved from OpenBao. + +## License + +This extension is licensed under the Apache License, Version 2.0. See the LICENSE file for details. + +## Support + +For issues or questions: +- Apache Guacamole: https://guacamole.apache.org/ +- OpenBao: https://openbao.org/ +- Issue Tracker: https://issues.apache.org/jira/browse/GUACAMOLE/ diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/pom.xml b/extensions/guacamole-vault/modules/guacamole-vault-openbao/pom.xml new file mode 100644 index 0000000000..41fc201711 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/pom.xml @@ -0,0 +1,70 @@ + + + + + 4.0.0 + org.apache.guacamole + guacamole-vault-openbao + jar + guacamole-vault-openbao + http://guacamole.apache.org/ + + + org.apache.guacamole + guacamole-vault + ${revision} + ../../ + + + + + + + org.apache.guacamole + guacamole-ext + + + + + org.apache.guacamole + guacamole-vault-base + ${revision} + + + + + org.apache.httpcomponents.client5 + httpclient5 + 5.2.1 + + + + + com.google.code.gson + gson + 2.10.1 + + + + + diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProvider.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProvider.java new file mode 100644 index 0000000000..075335812d --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProvider.java @@ -0,0 +1,54 @@ +/* + * 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.openbao; + +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.vault.VaultAuthenticationProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * OpenBao authentication provider that retrieves RDP passwords from OpenBao. + * This provider integrates with the Guacamole vault framework to automatically + * fetch passwords from OpenBao based on the logged-in username. + */ +public class OpenBaoAuthenticationProvider extends VaultAuthenticationProvider { + + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(OpenBaoAuthenticationProvider.class); + + /** + * Creates a new OpenBaoAuthenticationProvider. + * + * @throws GuacamoleException + * If an error occurs during initialization. + */ + public OpenBaoAuthenticationProvider() throws GuacamoleException { + super(new OpenBaoAuthenticationProviderModule()); + logger.info("OpenBaoAuthenticationProvider initialized"); + } + + @Override + public String getIdentifier() { + return "openbao"; + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProviderModule.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProviderModule.java new file mode 100644 index 0000000000..2092d13417 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProviderModule.java @@ -0,0 +1,78 @@ +/* + * 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.openbao; + +import com.google.inject.Scopes; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.vault.VaultAuthenticationProviderModule; +import org.apache.guacamole.vault.conf.VaultConfigurationService; +import org.apache.guacamole.vault.openbao.conf.OpenBaoConfigurationService; +import org.apache.guacamole.vault.openbao.secret.OpenBaoClient; +import org.apache.guacamole.vault.openbao.secret.OpenBaoSecretService; +import org.apache.guacamole.vault.openbao.user.OpenBaoAttributeService; +import org.apache.guacamole.vault.openbao.user.OpenBaoDirectoryService; +import org.apache.guacamole.vault.secret.VaultSecretService; +import org.apache.guacamole.vault.conf.VaultAttributeService; +import org.apache.guacamole.vault.user.VaultDirectoryService; + +/** + * Guice module for configuring OpenBao vault integration. + * Binds the OpenBao-specific implementations to the vault base interfaces. + */ +public class OpenBaoAuthenticationProviderModule extends VaultAuthenticationProviderModule { + + /** + * Creates a new OpenBaoAuthenticationProviderModule. + * + * @throws GuacamoleException + * If an error occurs while reading guacamole.properties. + */ + public OpenBaoAuthenticationProviderModule() throws GuacamoleException { + super(); + } + + @Override + protected void configureVault() { + + // Bind configuration service + bind(VaultConfigurationService.class) + .to(OpenBaoConfigurationService.class) + .in(Scopes.SINGLETON); + + // Bind secret service + bind(VaultSecretService.class) + .to(OpenBaoSecretService.class) + .in(Scopes.SINGLETON); + + // Bind attribute service + bind(VaultAttributeService.class) + .to(OpenBaoAttributeService.class) + .in(Scopes.SINGLETON); + + // Bind directory service + bind(VaultDirectoryService.class) + .to(OpenBaoDirectoryService.class) + .in(Scopes.SINGLETON); + + // Bind OpenBao client + bind(OpenBaoClient.class) + .in(Scopes.SINGLETON); + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfig.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfig.java new file mode 100644 index 0000000000..d7e2648ae9 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfig.java @@ -0,0 +1,64 @@ +/* + * 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.openbao.conf; + +import org.apache.guacamole.properties.StringGuacamoleProperty; + +/** + * Configuration properties for OpenBao vault integration. + */ +public class OpenBaoConfig { + + /** + * OpenBao server URL (e.g., "http://localhost:8200"). + * This property is REQUIRED and must be configured in guacamole.properties. + */ + public static final StringGuacamoleProperty OPENBAO_SERVER_URL = + new StringGuacamoleProperty() { + @Override + public String getName() { + return "openbao-server-url"; + } + }; + + /** + * OpenBao authentication token. + * This property is REQUIRED for authenticating with OpenBao. + */ + public static final StringGuacamoleProperty OPENBAO_TOKEN = + new StringGuacamoleProperty() { + @Override + public String getName() { + return "openbao-token"; + } + }; + + /** + * OpenBao KV secrets engine mount path (default: "rdp-creds"). + * This is the mount point where RDP credentials are stored. + */ + public static final StringGuacamoleProperty OPENBAO_MOUNT_PATH = + new StringGuacamoleProperty() { + @Override + public String getName() { + return "openbao-mount-path"; + } + }; +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfigurationService.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfigurationService.java new file mode 100644 index 0000000000..d3a898e5a9 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfigurationService.java @@ -0,0 +1,122 @@ +/* + * 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.openbao.conf; + +import com.google.inject.Inject; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.environment.Environment; +import org.apache.guacamole.vault.conf.VaultConfigurationService; + +/** + * Service for retrieving OpenBao configuration from guacamole.properties. + */ +public class OpenBaoConfigurationService extends VaultConfigurationService { + + /** + * The Guacamole server environment. + */ + @Inject + private Environment environment; + + /** + * Creates a new OpenBaoConfigurationService. + */ + public OpenBaoConfigurationService() { + super("openbao-token-mapping.yml", "guacamole.properties.openbao"); + } + + /** + * Returns the OpenBao server URL. + * + * @return The OpenBao server URL (e.g., "http://localhost:8200"). + * @throws GuacamoleException + * If the property is not defined in guacamole.properties. + */ + public String getServerUrl() throws GuacamoleException { + return environment.getRequiredProperty(OpenBaoConfig.OPENBAO_SERVER_URL); + } + + /** + * Returns the OpenBao authentication token. + * + * @return The OpenBao authentication token. + * @throws GuacamoleException + * If the property is not defined in guacamole.properties. + */ + public String getToken() throws GuacamoleException { + return environment.getRequiredProperty(OpenBaoConfig.OPENBAO_TOKEN); + } + + /** + * Returns the OpenBao KV secrets engine mount path. + * + * @return The mount path (default: "rdp-creds"). + * @throws GuacamoleException + * If an error occurs reading the property. + */ + public String getMountPath() throws GuacamoleException { + return environment.getProperty( + OpenBaoConfig.OPENBAO_MOUNT_PATH, + "rdp-creds" + ); + } + + /** + * Returns the OpenBao KV version. + * Hardcoded to "2" for KV v2 secrets engine. + * + * @return The KV version "2". + */ + public String getKvVersion() { + return "2"; + } + + /** + * Returns the connection timeout in milliseconds. + * Hardcoded to 5000ms (5 seconds). + * + * @return The connection timeout of 5000ms. + */ + public int getConnectionTimeout() { + return 5000; + } + + /** + * Returns the request timeout in milliseconds. + * Hardcoded to 10000ms (10 seconds). + * + * @return The request timeout of 10000ms. + */ + public int getRequestTimeout() { + return 10000; + } + + @Override + public boolean getSplitWindowsUsernames() throws GuacamoleException { + // Not needed for OpenBao - return false + return false; + } + + @Override + public boolean getMatchUserRecordsByDomain() throws GuacamoleException { + // Not needed for OpenBao - return false + return false; + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java new file mode 100644 index 0000000000..a466572a88 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java @@ -0,0 +1,164 @@ +/* + * 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.openbao.secret; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import com.google.inject.Inject; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.GuacamoleServerException; +import org.apache.guacamole.vault.openbao.conf.OpenBaoConfigurationService; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.util.Timeout; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; + +/** + * Client for communicating with OpenBao REST API. + */ +public class OpenBaoClient { + + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(OpenBaoClient.class); + + /** + * Service for retrieving OpenBao configuration. + */ + @Inject + private OpenBaoConfigurationService configService; + + /** + * Gson instance for JSON parsing. + */ + private final Gson gson = new Gson(); + + /** + * Retrieves a secret from OpenBao by username. + * + * @param username + * The Guacamole username to look up in OpenBao. + * + * @return + * The JSON response from OpenBao. + * + * @throws GuacamoleException + * If the secret cannot be retrieved from OpenBao. + */ + public JsonObject getSecret(String username) throws GuacamoleException { + + String serverUrl = configService.getServerUrl(); + String token = configService.getToken(); + String mountPath = configService.getMountPath(); + String kvVersion = configService.getKvVersion(); + + // Build the API path based on KV version + // KV v2: /v1/{mount-path}/data/{username} + // KV v1: /v1/{mount-path}/{username} + String apiPath; + if ("2".equals(kvVersion)) { + apiPath = String.format("/v1/%s/data/%s", mountPath, username); + } else { + apiPath = String.format("/v1/%s/%s", mountPath, username); + } + + String fullUrl = serverUrl + apiPath; + + logger.info("Fetching secret from OpenBao: {}", fullUrl); + + try (CloseableHttpClient httpClient = HttpClients.createDefault()) { + + HttpGet httpGet = new HttpGet(fullUrl); + httpGet.setHeader("X-Vault-Token", token); + httpGet.setHeader("Accept", "application/json"); + + // Set timeouts + httpGet.setConfig(org.apache.hc.client5.http.config.RequestConfig.custom() + .setConnectionRequestTimeout(Timeout.ofMilliseconds(configService.getConnectionTimeout())) + .setResponseTimeout(Timeout.ofMilliseconds(configService.getRequestTimeout())) + .build()); + + org.apache.hc.core5.http.ClassicHttpResponse response = httpClient.executeOpen(null, httpGet, null); + try { + int statusCode = response.getCode(); + String responseBody = EntityUtils.toString(response.getEntity()); + + if (statusCode == 200) { + logger.info("OpenBao response status: {} - successfully retrieved password for {}", statusCode, username); + JsonObject jsonResponse = gson.fromJson(responseBody, JsonObject.class); + return jsonResponse; + } else if (statusCode == 404) { + logger.warn("Secret not found in OpenBao for username: {}", username); + throw new GuacamoleServerException("Secret not found in OpenBao for username: " + username); + } else if (statusCode == 403) { + logger.error("Permission denied accessing OpenBao. Check token permissions."); + throw new GuacamoleServerException("Permission denied accessing OpenBao. Check token permissions."); + } else { + logger.error("OpenBao returned error status {}: {}", statusCode, responseBody); + throw new GuacamoleServerException("OpenBao error (HTTP " + statusCode + "): " + responseBody); + } + } finally { + response.close(); + } + + } catch (IOException | org.apache.hc.core5.http.ParseException e) { + logger.error("Failed to communicate with OpenBao at {}: {}", fullUrl, e.getMessage()); + throw new GuacamoleServerException("Failed to communicate with OpenBao", e); + } + } + + /** + * Extracts the password field from an OpenBao KV v2 response. + * + * @param response + * The JSON response from OpenBao. + * + * @return + * The password string, or null if not found. + */ + public String extractPassword(JsonObject response) { + try { + // For KV v2: response.data.data.password + if (response.has("data")) { + JsonObject data = response.getAsJsonObject("data"); + if (data.has("data")) { + JsonObject innerData = data.getAsJsonObject("data"); + if (innerData.has("password")) { + return innerData.get("password").getAsString(); + } + } + } + + logger.warn("Password field not found in OpenBao response"); + return null; + + } catch (Exception e) { + logger.error("Error extracting password from OpenBao response", e); + return null; + } + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java new file mode 100644 index 0000000000..f3a4efc98c --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.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.openbao.secret; + +import com.google.gson.JsonObject; +import com.google.inject.Inject; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.net.auth.Connectable; +import org.apache.guacamole.net.auth.UserContext; +import org.apache.guacamole.protocol.GuacamoleConfiguration; +import org.apache.guacamole.token.TokenFilter; +import org.apache.guacamole.vault.secret.VaultSecretService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; + +/** + * OpenBao implementation of VaultSecretService. + * Retrieves RDP passwords from OpenBao based on the logged-in Guacamole username. + */ +public class OpenBaoSecretService implements VaultSecretService { + + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(OpenBaoSecretService.class); + + /** + * Client for communicating with OpenBao. + */ + @Inject + private OpenBaoClient openBaoClient; + + /** + * Constructor that logs when the service is created. + */ + public OpenBaoSecretService() { + logger.info("OpenBaoSecretService initialized"); + } + + /** + * The token pattern for OpenBao secrets: ${OPENBAO_SECRET} + */ + public static final String OPENBAO_SECRET_TOKEN = "${OPENBAO_SECRET}"; + + /** + * The token pattern for Guacamole username: ${GUAC_USERNAME} + */ + public static final String GUAC_USERNAME_TOKEN = "${GUAC_USERNAME}"; + + @Override + public String canonicalize(String token) { + // Return the canonical form for tokens we recognize + if (token == null) + return null; + + // Remove ${} wrapper and return just the token name + if (OPENBAO_SECRET_TOKEN.equals(token)) { + return "OPENBAO_SECRET"; + } + + if (GUAC_USERNAME_TOKEN.equals(token)) { + return "GUAC_USERNAME"; + } + + // Not our token + return null; + } + + @Override + public Future getValue(String token) throws GuacamoleException { + // This method is called for simple token lookups without user context + logger.warn("getValue(String) called without user context - cannot determine username"); + return CompletableFuture.completedFuture(null); + } + + @Override + public Future getValue(UserContext userContext, Connectable connectable, String token) + throws GuacamoleException { + + logger.info("getValue() called with token: {}", token); + + // Get the logged-in Guacamole username + String username = userContext.self().getIdentifier(); + + // Handle GUAC_USERNAME token - return the Guacamole username + if ("GUAC_USERNAME".equals(token)) { + logger.info("getValue() returning username: '{}'", username); + return CompletableFuture.completedFuture(username); + } + + // Handle OPENBAO_SECRET token - fetch password from OpenBao + if ("OPENBAO_SECRET".equals(token)) { + logger.info("Retrieving OpenBao secret for username: {}", username); + + try { + // Fetch the secret from OpenBao using the username + JsonObject response = openBaoClient.getSecret(username); + + // Extract the password field + String password = openBaoClient.extractPassword(response); + + if (password != null) { + logger.info("Successfully retrieved password from OpenBao for user: {} (length: {})", username, password.length()); + return CompletableFuture.completedFuture(password); + } else { + logger.warn("Password field not found in OpenBao for user: {}", username); + return CompletableFuture.completedFuture(null); + } + + } catch (GuacamoleException e) { + logger.error("Failed to retrieve secret from OpenBao for user: {}", username, e); + // Return null instead of throwing to allow connection attempt with empty password + return CompletableFuture.completedFuture(null); + } + } + + // Not a recognized token + logger.warn("Token '{}' not recognized, returning null", token); + return CompletableFuture.completedFuture(null); + } + + @Override + public Map> getTokens(UserContext userContext, + Connectable connectable, + GuacamoleConfiguration config, + TokenFilter tokenFilter) throws GuacamoleException { + + Map> tokens = new java.util.HashMap<>(); + String username = userContext.self().getIdentifier(); + + // Add GUAC_USERNAME token (always available) + tokens.put("GUAC_USERNAME", CompletableFuture.completedFuture(username)); + + // Add OPENBAO_SECRET token (fetch from OpenBao) + try { + JsonObject response = openBaoClient.getSecret(username); + String password = openBaoClient.extractPassword(response); + if (password != null) { + tokens.put("OPENBAO_SECRET", CompletableFuture.completedFuture(password)); + logger.info("Added token OPENBAO_SECRET with password from OpenBao (length: {})", password.length()); + } else { + logger.warn("Password not found in OpenBao for user: {}", username); + } + } catch (Exception e) { + logger.error("Failed to get secret from OpenBao for user: {}", username, e); + } + + logger.info("Returning {} tokens: {}", tokens.size(), tokens.keySet()); + return tokens; + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/user/OpenBaoAttributeService.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/user/OpenBaoAttributeService.java new file mode 100644 index 0000000000..06ee6679a6 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/user/OpenBaoAttributeService.java @@ -0,0 +1,58 @@ +/* + * 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.openbao.user; + +import org.apache.guacamole.form.Form; +import org.apache.guacamole.vault.conf.VaultAttributeService; + +import java.util.Collection; +import java.util.Collections; + +/** + * OpenBao implementation of VaultAttributeService. + * Defines attributes that trigger OpenBao secret lookups. + */ +public class OpenBaoAttributeService implements VaultAttributeService { + + @Override + public Collection
getConnectionAttributes() { + // No additional connection attributes needed for OpenBao + // The password field in RDP connections will automatically use OPENBAO:password token + return Collections.emptyList(); + } + + @Override + public Collection getConnectionGroupAttributes() { + // No additional connection group attributes + return Collections.emptyList(); + } + + @Override + public Collection getUserAttributes() { + // No additional user attributes + return Collections.emptyList(); + } + + @Override + public Collection getUserPreferenceAttributes() { + // No additional user preference attributes + return Collections.emptyList(); + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/user/OpenBaoDirectoryService.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/user/OpenBaoDirectoryService.java new file mode 100644 index 0000000000..31c24a9a84 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/user/OpenBaoDirectoryService.java @@ -0,0 +1,80 @@ +/* + * 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.openbao.user; + +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.net.auth.ActiveConnection; +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.SharingProfile; +import org.apache.guacamole.net.auth.User; +import org.apache.guacamole.net.auth.UserGroup; +import org.apache.guacamole.vault.user.VaultDirectoryService; + +/** + * OpenBao implementation of VaultDirectoryService. + * Since OpenBao only provides secrets (not user/group/connection management), + * all directory methods simply pass through the underlying directories unchanged. + */ +public class OpenBaoDirectoryService extends VaultDirectoryService { + + @Override + public Directory getUserDirectory(Directory underlyingUserDirectory) + throws GuacamoleException { + // OpenBao doesn't manage users, just return the underlying directory + return underlyingUserDirectory; + } + + @Override + public Directory getUserGroupDirectory(Directory underlyingUserGroupDirectory) + throws GuacamoleException { + // OpenBao doesn't manage user groups, just return the underlying directory + return underlyingUserGroupDirectory; + } + + @Override + public Directory getConnectionDirectory(Directory underlyingConnectionDirectory) + throws GuacamoleException { + // OpenBao doesn't manage connections, just return the underlying directory + return underlyingConnectionDirectory; + } + + @Override + public Directory getConnectionGroupDirectory( + Directory underlyingConnectionGroupDirectory) throws GuacamoleException { + // OpenBao doesn't manage connection groups, just return the underlying directory + return underlyingConnectionGroupDirectory; + } + + @Override + public Directory getActiveConnectionDirectory( + Directory underlyingActiveConnectionDirectory) throws GuacamoleException { + // OpenBao doesn't manage active connections, just return the underlying directory + return underlyingActiveConnectionDirectory; + } + + @Override + public Directory getSharingProfileDirectory( + Directory underlyingSharingProfileDirectory) throws GuacamoleException { + // OpenBao doesn't manage sharing profiles, just return the underlying directory + return underlyingSharingProfileDirectory; + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/resource-templates/guac-manifest.json b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/resource-templates/guac-manifest.json new file mode 100644 index 0000000000..32b272cc52 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/resource-templates/guac-manifest.json @@ -0,0 +1,12 @@ +{ + + "guacamoleVersion" : "${project.version}", + + "name" : "OpenBao Vault", + "namespace" : "openbao", + + "authProviders" : [ + "org.apache.guacamole.vault.openbao.OpenBaoAuthenticationProvider" + ] + +} diff --git a/extensions/guacamole-vault/pom.xml b/extensions/guacamole-vault/pom.xml index 14313ff7a4..1a2d005a56 100644 --- a/extensions/guacamole-vault/pom.xml +++ b/extensions/guacamole-vault/pom.xml @@ -45,6 +45,7 @@ modules/guacamole-vault-ksm + modules/guacamole-vault-openbao diff --git a/guacamole-docker/build.d/010-map-guacamole-extensions.sh b/guacamole-docker/build.d/010-map-guacamole-extensions.sh index 3804120e8a..da9708381b 100644 --- a/guacamole-docker/build.d/010-map-guacamole-extensions.sh +++ b/guacamole-docker/build.d/010-map-guacamole-extensions.sh @@ -115,5 +115,6 @@ map_extensions <<'EOF' guacamole-display-statistics................DISPLAY_STATISTICS_ guacamole-history-recording-storage.........RECORDING_ guacamole-vault/ksm.........................KSM_ + guacamole-vault/openbao.....................OPENBAO_ EOF From 5b83ec6178951cd2faa43d98767bacbabcbdb6d0 Mon Sep 17 00:00:00 2001 From: Subba Reddy Alamuru Date: Mon, 20 Apr 2026 12:06:13 +0530 Subject: [PATCH 02/16] work on review comments --- .../modules/guacamole-vault-openbao/README.md | 55 ++- .../vault/openbao/conf/OpenBaoConfig.java | 46 +- .../conf/OpenBaoConfigurationService.java | 71 ++- .../vault/openbao/secret/OpenBaoClient.java | 413 ++++++++++++++---- .../openbao/secret/OpenBaoSecretService.java | 194 +++++--- 5 files changed, 622 insertions(+), 157 deletions(-) diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/README.md b/extensions/guacamole-vault/modules/guacamole-vault-openbao/README.md index ba6996d49a..6c2cb03046 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/README.md +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/README.md @@ -53,13 +53,35 @@ Add the following properties to `guacamole.properties`: # OpenBao server URL (required) openbao-server-url: http://openbao.example.com:8200 -# OpenBao authentication token (required) +# OpenBao authentication token (required unless AppRole is configured) openbao-token: s.YourTokenHere # KV mount path (optional, default: guacamole-credentails) openbao-mount-path: guacamole-credentails ``` +#### AppRole Authentication (optional) + +As an alternative to a static `openbao-token`, the extension can +authenticate via the AppRole auth method, which typically issues +shorter-lived tokens: + +```properties +# Required for AppRole +openbao-role-id: +openbao-secret-id: + +# Optional; defaults to "approle" +openbao-approle-path: approle +``` + +When both `openbao-role-id` and `openbao-secret-id` are set, the +extension performs an AppRole login at +`/v1/auth//login` and uses the returned +`client_token` for all subsequent requests. The token is cached and +refreshed automatically on a 403 response. If AppRole is configured, +`openbao-token` is ignored. + **Note**: The extension uses hardcoded defaults for: - KV version: `2` (KV v2 secrets engine) - Connection timeout: `5000ms` (5 seconds) @@ -69,14 +91,41 @@ openbao-mount-path: guacamole-credentails When creating connections in Guacamole, use these token patterns: -- **`${OPENBAO_SECRET}`**: Replaced with the password from OpenBao -- **`${GUAC_USERNAME}`**: Replaced with the logged-in Guacamole username +- **`${OPENBAO_SECRET}`**: Replaced with the `password` field of the + secret stored at `/data/`. +- **`${GUAC_USERNAME}`**: Replaced with the logged-in Guacamole username. Example RDP connection: - Username: `${GUAC_USERNAME}` - Password: `${OPENBAO_SECRET}` - Hostname: `192.168.1.100` +#### Arbitrary Secret Paths (via token mapping) + +For secrets that are not stored at the per-user path, an additional +name format can be used in the vault token mapping YAML +(`openbao-token-mapping.yml`): + +``` +openbao:[:] +``` + +- `` is the path under the configured mount, without the + `/data/` prefix (KV v2 is handled automatically). +- `` selects which field to return from the secret's data; + defaults to `password` if omitted. + +Example `openbao-token-mapping.yml`: + +```yaml +DB_PASSWORD: "openbao:db/prod-ro:password" +SSH_KEY: "openbao:ssh-keys/${GUAC_USERNAME}:private_key" +JUMPBOX_PW: "openbao:shared/jumpbox" +``` + +This mechanism is additive. Existing configurations that use only +`${OPENBAO_SECRET}` continue to work unchanged. + ## Secret Path Mapping The extension maps Guacamole usernames directly to OpenBao secret paths: diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfig.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfig.java index d7e2648ae9..b651f478c7 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfig.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfig.java @@ -20,6 +20,7 @@ package org.apache.guacamole.vault.openbao.conf; import org.apache.guacamole.properties.StringGuacamoleProperty; +import org.apache.guacamole.properties.URIGuacamoleProperty; /** * Configuration properties for OpenBao vault integration. @@ -30,8 +31,8 @@ public class OpenBaoConfig { * OpenBao server URL (e.g., "http://localhost:8200"). * This property is REQUIRED and must be configured in guacamole.properties. */ - public static final StringGuacamoleProperty OPENBAO_SERVER_URL = - new StringGuacamoleProperty() { + public static final URIGuacamoleProperty OPENBAO_SERVER_URL = + new URIGuacamoleProperty() { @Override public String getName() { return "openbao-server-url"; @@ -39,8 +40,9 @@ public String getName() { }; /** - * OpenBao authentication token. - * This property is REQUIRED for authenticating with OpenBao. + * OpenBao authentication token. Required unless AppRole + * authentication is configured via {@link #OPENBAO_ROLE_ID} and + * {@link #OPENBAO_SECRET_ID}. */ public static final StringGuacamoleProperty OPENBAO_TOKEN = new StringGuacamoleProperty() { @@ -50,6 +52,42 @@ public String getName() { } }; + /** + * OpenBao AppRole role ID. Optional. When both this property and + * {@link #OPENBAO_SECRET_ID} are set, AppRole authentication is used + * instead of a static token. + */ + public static final StringGuacamoleProperty OPENBAO_ROLE_ID = + new StringGuacamoleProperty() { + @Override + public String getName() { + return "openbao-role-id"; + } + }; + + /** + * OpenBao AppRole secret ID. Optional. See {@link #OPENBAO_ROLE_ID}. + */ + public static final StringGuacamoleProperty OPENBAO_SECRET_ID = + new StringGuacamoleProperty() { + @Override + public String getName() { + return "openbao-secret-id"; + } + }; + + /** + * OpenBao AppRole auth mount path (default: "approle"). Only relevant + * when {@link #OPENBAO_ROLE_ID} / {@link #OPENBAO_SECRET_ID} are set. + */ + public static final StringGuacamoleProperty OPENBAO_APPROLE_PATH = + new StringGuacamoleProperty() { + @Override + public String getName() { + return "openbao-approle-path"; + } + }; + /** * OpenBao KV secrets engine mount path (default: "rdp-creds"). * This is the mount point where RDP credentials are stored. diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfigurationService.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfigurationService.java index d3a898e5a9..3167293b06 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfigurationService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfigurationService.java @@ -20,6 +20,7 @@ package org.apache.guacamole.vault.openbao.conf; import com.google.inject.Inject; +import java.net.URI; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.environment.Environment; import org.apache.guacamole.vault.conf.VaultConfigurationService; @@ -43,25 +44,79 @@ public OpenBaoConfigurationService() { } /** - * Returns the OpenBao server URL. + * Returns the OpenBao server URL as a parsed URI. * - * @return The OpenBao server URL (e.g., "http://localhost:8200"). + * @return The OpenBao server URI (e.g., "http://localhost:8200"). * @throws GuacamoleException - * If the property is not defined in guacamole.properties. + * If the property is not defined in guacamole.properties or is + * not a valid URI. */ - public String getServerUrl() throws GuacamoleException { + public URI getServerUrl() throws GuacamoleException { return environment.getRequiredProperty(OpenBaoConfig.OPENBAO_SERVER_URL); } /** - * Returns the OpenBao authentication token. + * Returns the OpenBao authentication token, or null if AppRole + * authentication is configured instead (see {@link #getRoleId()} and + * {@link #getSecretId()}). * - * @return The OpenBao authentication token. + * @return The OpenBao authentication token, or null. * @throws GuacamoleException - * If the property is not defined in guacamole.properties. + * If an error occurs reading the property. */ public String getToken() throws GuacamoleException { - return environment.getRequiredProperty(OpenBaoConfig.OPENBAO_TOKEN); + return environment.getProperty(OpenBaoConfig.OPENBAO_TOKEN); + } + + /** + * Returns the OpenBao AppRole role ID, or null if not configured. + * + * @return The configured AppRole role ID, or null. + * @throws GuacamoleException + * If an error occurs reading the property. + */ + public String getRoleId() throws GuacamoleException { + return environment.getProperty(OpenBaoConfig.OPENBAO_ROLE_ID); + } + + /** + * Returns the OpenBao AppRole secret ID, or null if not configured. + * + * @return The configured AppRole secret ID, or null. + * @throws GuacamoleException + * If an error occurs reading the property. + */ + public String getSecretId() throws GuacamoleException { + return environment.getProperty(OpenBaoConfig.OPENBAO_SECRET_ID); + } + + /** + * Returns the OpenBao AppRole auth backend mount path (default: "approle"). + * + * @return The AppRole auth mount path. + * @throws GuacamoleException + * If an error occurs reading the property. + */ + public String getAppRolePath() throws GuacamoleException { + return environment.getProperty( + OpenBaoConfig.OPENBAO_APPROLE_PATH, + "approle" + ); + } + + /** + * Returns true if AppRole authentication has been configured (both + * role ID and secret ID are present). + * + * @return true if AppRole should be used, false to use a static token. + * @throws GuacamoleException + * If an error occurs reading the properties. + */ + public boolean isAppRoleConfigured() throws GuacamoleException { + String roleId = getRoleId(); + String secretId = getSecretId(); + return roleId != null && !roleId.isEmpty() + && secretId != null && !secretId.isEmpty(); } /** diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java index a466572a88..ad3eec6f2a 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java @@ -21,21 +21,27 @@ import com.google.gson.Gson; import com.google.gson.JsonObject; +import com.google.gson.JsonSyntaxException; import com.google.inject.Inject; +import java.io.IOException; +import java.net.URI; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.GuacamoleServerException; import org.apache.guacamole.vault.openbao.conf.OpenBaoConfigurationService; import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.config.RequestConfig; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; -import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.ParseException; import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.io.entity.StringEntity; import org.apache.hc.core5.util.Timeout; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.IOException; - /** * Client for communicating with OpenBao REST API. */ @@ -46,6 +52,12 @@ public class OpenBaoClient { */ private static final Logger logger = LoggerFactory.getLogger(OpenBaoClient.class); + /** + * Gson instance for JSON parsing. Gson is thread-safe, so a single + * static instance is reused across all calls. + */ + private static final Gson GSON = new Gson(); + /** * Service for retrieving OpenBao configuration. */ @@ -53,112 +65,363 @@ public class OpenBaoClient { private OpenBaoConfigurationService configService; /** - * Gson instance for JSON parsing. + * Shared HTTP client. Lazily created on first use and reused for the + * lifetime of this instance; Apache HttpClient 5 is thread-safe and + * designed to be reused across requests. + */ + private volatile CloseableHttpClient httpClient; + + /** + * Cached AppRole token. Populated on first successful AppRole login + * and re-used until invalidated (e.g. on a 403 response). + */ + private volatile String cachedAppRoleToken; + + /** + * Returns the shared {@link CloseableHttpClient}, creating it on first + * access. Thread-safe double-checked initialization. + * + * @return + * The shared HTTP client instance. */ - private final Gson gson = new Gson(); + private CloseableHttpClient getHttpClient() { + CloseableHttpClient client = httpClient; + if (client == null) { + synchronized (this) { + client = httpClient; + if (client == null) { + client = HttpClients.createDefault(); + httpClient = client; + } + } + } + return client; + } /** - * Retrieves a secret from OpenBao by username. + * Builds a {@link RequestConfig} from the configured connection and + * request timeouts. * - * @param username - * The Guacamole username to look up in OpenBao. + * @return + * A request configuration reflecting current timeouts. + */ + private RequestConfig buildRequestConfig() { + return RequestConfig.custom() + .setConnectionRequestTimeout( + Timeout.ofMilliseconds(configService.getConnectionTimeout())) + .setResponseTimeout( + Timeout.ofMilliseconds(configService.getRequestTimeout())) + .build(); + } + + /** + * Validates that the configured server URL, mount path, and auth + * credentials are present and non-empty, throwing a + * {@link GuacamoleServerException} with a clear message otherwise. * * @return - * The JSON response from OpenBao. + * The validated server URL as a string (without trailing slash). * * @throws GuacamoleException - * If the secret cannot be retrieved from OpenBao. + * If a required property is missing, empty, or invalid. + */ + private String validatedServerUrl() throws GuacamoleException { + + URI serverUri = configService.getServerUrl(); + if (serverUri == null || serverUri.toString().trim().isEmpty()) { + throw new GuacamoleServerException( + "OpenBao server URL (\"openbao-server-url\") is not configured."); + } + + String mountPath = configService.getMountPath(); + if (mountPath == null || mountPath.trim().isEmpty()) { + throw new GuacamoleServerException( + "OpenBao mount path (\"openbao-mount-path\") must not be empty."); + } + + // Strip trailing slash to keep path concatenation predictable + String url = serverUri.toString(); + if (url.endsWith("/")) + url = url.substring(0, url.length() - 1); + + return url; + } + + /** + * Resolves a usable OpenBao auth token, either from the configured + * static {@code openbao-token} or, if AppRole is configured, by + * performing an AppRole login. AppRole tokens are cached for the + * lifetime of this client and refreshed only when explicitly + * invalidated via {@link #invalidateCachedToken()}. + * + * @return + * A non-empty OpenBao auth token. + * + * @throws GuacamoleException + * If no valid authentication credentials are configured or if + * AppRole login fails. */ - public JsonObject getSecret(String username) throws GuacamoleException { + private String resolveAuthToken() throws GuacamoleException { + + if (configService.isAppRoleConfigured()) { + String token = cachedAppRoleToken; + if (token == null || token.isEmpty()) { + synchronized (this) { + token = cachedAppRoleToken; + if (token == null || token.isEmpty()) { + token = loginWithAppRole(); + cachedAppRoleToken = token; + } + } + } + return token; + } - String serverUrl = configService.getServerUrl(); String token = configService.getToken(); + if (token == null || token.trim().isEmpty()) { + throw new GuacamoleServerException( + "OpenBao authentication is not configured. Set " + + "\"openbao-token\", or both \"openbao-role-id\" and " + + "\"openbao-secret-id\"."); + } + + return token; + } + + /** + * Invalidates any cached AppRole token, forcing a fresh login on the + * next request. Called when a 403 indicates the token has expired or + * been revoked. + */ + private synchronized void invalidateCachedToken() { + cachedAppRoleToken = null; + } + + /** + * Performs an AppRole login against OpenBao and returns the issued + * client token. + * + * @return + * The client token returned by OpenBao. + * + * @throws GuacamoleException + * If the login fails or the response cannot be parsed. + */ + private String loginWithAppRole() throws GuacamoleException { + + String serverUrl = validatedServerUrl(); + String roleId = configService.getRoleId(); + String secretId = configService.getSecretId(); + String approlePath = configService.getAppRolePath(); + + String loginUrl = serverUrl + "/v1/auth/" + approlePath + "/login"; + + JsonObject payload = new JsonObject(); + payload.addProperty("role_id", roleId); + payload.addProperty("secret_id", secretId); + + HttpPost httpPost = new HttpPost(loginUrl); + httpPost.setHeader("Accept", "application/json"); + httpPost.setEntity(new StringEntity(payload.toString(), ContentType.APPLICATION_JSON)); + httpPost.setConfig(buildRequestConfig()); + + logger.info("Authenticating to OpenBao using AppRole at {}", loginUrl); + + try (ClassicHttpResponse response = getHttpClient().executeOpen(null, httpPost, null)) { + int statusCode = response.getCode(); + String responseBody = EntityUtils.toString(response.getEntity()); + + if (statusCode != 200) { + throw new GuacamoleServerException( + "OpenBao AppRole login failed (HTTP " + statusCode + "): " + + responseBody); + } + + JsonObject json = GSON.fromJson(responseBody, JsonObject.class); + if (json == null || !json.has("auth")) + throw new GuacamoleServerException( + "OpenBao AppRole login response missing \"auth\" object."); + + JsonObject auth = json.getAsJsonObject("auth"); + if (!auth.has("client_token")) + throw new GuacamoleServerException( + "OpenBao AppRole login response missing \"auth.client_token\"."); + + return auth.get("client_token").getAsString(); + } + catch (IOException | ParseException | JsonSyntaxException e) { + throw new GuacamoleServerException( + "Failed to communicate with OpenBao during AppRole login", e); + } + } + + /** + * Retrieves the secret at the given path, relative to the configured + * mount. For a KV v2 engine this resolves to + * {@code /v1//data/}; for KV v1 it resolves to + * {@code /v1//}. + * + * @param secretPath + * The path (relative to the mount) of the secret to retrieve. + * + * @return + * The parsed JSON response from OpenBao. + * + * @throws GuacamoleException + * If the secret cannot be retrieved from OpenBao. + */ + public JsonObject getSecret(String secretPath) throws GuacamoleException { + + if (secretPath == null || secretPath.trim().isEmpty()) { + throw new GuacamoleServerException( + "OpenBao secret path must not be empty."); + } + + String serverUrl = validatedServerUrl(); String mountPath = configService.getMountPath(); String kvVersion = configService.getKvVersion(); - // Build the API path based on KV version - // KV v2: /v1/{mount-path}/data/{username} - // KV v1: /v1/{mount-path}/{username} + // Strip any leading slash so URL concatenation stays predictable + String normalizedPath = secretPath.startsWith("/") + ? secretPath.substring(1) : secretPath; + String apiPath; - if ("2".equals(kvVersion)) { - apiPath = String.format("/v1/%s/data/%s", mountPath, username); - } else { - apiPath = String.format("/v1/%s/%s", mountPath, username); - } + if ("2".equals(kvVersion)) + apiPath = String.format("/v1/%s/data/%s", mountPath, normalizedPath); + else + apiPath = String.format("/v1/%s/%s", mountPath, normalizedPath); String fullUrl = serverUrl + apiPath; + logger.debug("Fetching secret from OpenBao: {}", fullUrl); - logger.info("Fetching secret from OpenBao: {}", fullUrl); - - try (CloseableHttpClient httpClient = HttpClients.createDefault()) { - - HttpGet httpGet = new HttpGet(fullUrl); - httpGet.setHeader("X-Vault-Token", token); - httpGet.setHeader("Accept", "application/json"); - - // Set timeouts - httpGet.setConfig(org.apache.hc.client5.http.config.RequestConfig.custom() - .setConnectionRequestTimeout(Timeout.ofMilliseconds(configService.getConnectionTimeout())) - .setResponseTimeout(Timeout.ofMilliseconds(configService.getRequestTimeout())) - .build()); - - org.apache.hc.core5.http.ClassicHttpResponse response = httpClient.executeOpen(null, httpGet, null); - try { - int statusCode = response.getCode(); - String responseBody = EntityUtils.toString(response.getEntity()); - - if (statusCode == 200) { - logger.info("OpenBao response status: {} - successfully retrieved password for {}", statusCode, username); - JsonObject jsonResponse = gson.fromJson(responseBody, JsonObject.class); - return jsonResponse; - } else if (statusCode == 404) { - logger.warn("Secret not found in OpenBao for username: {}", username); - throw new GuacamoleServerException("Secret not found in OpenBao for username: " + username); - } else if (statusCode == 403) { - logger.error("Permission denied accessing OpenBao. Check token permissions."); - throw new GuacamoleServerException("Permission denied accessing OpenBao. Check token permissions."); - } else { - logger.error("OpenBao returned error status {}: {}", statusCode, responseBody); - throw new GuacamoleServerException("OpenBao error (HTTP " + statusCode + "): " + responseBody); - } - } finally { - response.close(); + JsonObject result = executeGet(fullUrl, resolveAuthToken()); + if (result != null) + return result; + + // A null result from executeGet means we got a 403 with an + // AppRole token; retry once with a freshly-issued token. + if (configService.isAppRoleConfigured()) { + invalidateCachedToken(); + logger.info("OpenBao AppRole token may have expired; retrying with a fresh token."); + JsonObject retry = executeGet(fullUrl, resolveAuthToken()); + if (retry != null) + return retry; + } + + throw new GuacamoleServerException( + "Permission denied accessing OpenBao. Check token permissions."); + } + + /** + * Issues a GET against {@code fullUrl} authenticated with the given + * token. Returns the parsed JSON response on success, or {@code null} + * if the response was a 403 (caller may choose to refresh credentials + * and retry). + * + * @param fullUrl + * The fully-qualified URL to GET. + * + * @param token + * The OpenBao auth token to present via {@code X-Vault-Token}. + * + * @return + * The parsed JSON response, or {@code null} on 403. + * + * @throws GuacamoleException + * On non-200, non-403 responses or communication failures. + */ + private JsonObject executeGet(String fullUrl, String token) + throws GuacamoleException { + + HttpGet httpGet = new HttpGet(fullUrl); + httpGet.setHeader("X-Vault-Token", token); + httpGet.setHeader("Accept", "application/json"); + httpGet.setConfig(buildRequestConfig()); + + try (ClassicHttpResponse response = getHttpClient().executeOpen(null, httpGet, null)) { + int statusCode = response.getCode(); + String responseBody = EntityUtils.toString(response.getEntity()); + + if (statusCode == 200) { + logger.debug("OpenBao response 200 for {}", fullUrl); + return GSON.fromJson(responseBody, JsonObject.class); + } + + if (statusCode == 404) { + throw new GuacamoleServerException( + "Secret not found in OpenBao at: " + fullUrl); + } + + if (statusCode == 403) { + // Signal to caller so it can retry after refreshing auth. + return null; } - } catch (IOException | org.apache.hc.core5.http.ParseException e) { - logger.error("Failed to communicate with OpenBao at {}: {}", fullUrl, e.getMessage()); - throw new GuacamoleServerException("Failed to communicate with OpenBao", e); + throw new GuacamoleServerException( + "OpenBao error (HTTP " + statusCode + "): " + responseBody); + } + catch (IOException | ParseException | JsonSyntaxException e) { + logger.error("Failed to communicate with OpenBao at {}: {}", + fullUrl, e.getMessage()); + throw new GuacamoleServerException( + "Failed to communicate with OpenBao", e); } } /** - * Extracts the password field from an OpenBao KV v2 response. + * Extracts the {@code password} field from an OpenBao secret response. * * @param response - * The JSON response from OpenBao. + * The JSON response previously returned by {@link #getSecret(String)}. * * @return - * The password string, or null if not found. + * The password string, or null if not present. */ public String extractPassword(JsonObject response) { - try { - // For KV v2: response.data.data.password - if (response.has("data")) { - JsonObject data = response.getAsJsonObject("data"); - if (data.has("data")) { - JsonObject innerData = data.getAsJsonObject("data"); - if (innerData.has("password")) { - return innerData.get("password").getAsString(); - } - } - } + return extractField(response, "password"); + } - logger.warn("Password field not found in OpenBao response"); + /** + * Extracts an arbitrary string field from an OpenBao secret response. + * Supports both KV v2 ({@code data.data.}) and KV v1 + * ({@code data.}) layouts. + * + * @param response + * The JSON response previously returned by {@link #getSecret(String)}. + * + * @param fieldName + * The name of the field to extract from the secret's data object. + * + * @return + * The field value, or null if not present or not a string. + */ + public String extractField(JsonObject response, String fieldName) { + + if (response == null || fieldName == null) return null; - } catch (Exception e) { - logger.error("Error extracting password from OpenBao response", e); + if (!response.has("data")) + return null; + + JsonObject data = response.getAsJsonObject("data"); + + // KV v2 nests the user-supplied data under an inner "data" object. + JsonObject values; + if (data.has("data") && data.get("data").isJsonObject()) + values = data.getAsJsonObject("data"); + else + values = data; + + if (!values.has(fieldName)) { + logger.debug("Field \"{}\" not found in OpenBao secret", fieldName); return null; } + + if (!values.get(fieldName).isJsonPrimitive()) { + logger.debug("Field \"{}\" in OpenBao secret is not a primitive", fieldName); + return null; + } + + return values.get(fieldName).getAsString(); } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java index f3a4efc98c..6024c7cb09 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java @@ -21,6 +21,10 @@ import com.google.gson.JsonObject; import com.google.inject.Inject; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.net.auth.Connectable; import org.apache.guacamole.net.auth.UserContext; @@ -30,13 +34,13 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Future; - /** - * OpenBao implementation of VaultSecretService. - * Retrieves RDP passwords from OpenBao based on the logged-in Guacamole username. + * OpenBao implementation of VaultSecretService. Resolves the legacy + * {@code ${OPENBAO_SECRET}} and {@code ${GUAC_USERNAME}} tokens as well + * as an arbitrary-path syntax of the form + * {@code openbao:[:]}, which, when supplied as a secret + * name via the YAML token mapping, retrieves the given field from the + * named secret under the configured mount. */ public class OpenBaoSecretService implements VaultSecretService { @@ -45,6 +49,28 @@ public class OpenBaoSecretService implements VaultSecretService { */ private static final Logger logger = LoggerFactory.getLogger(OpenBaoSecretService.class); + /** + * Prefix identifying the arbitrary-path secret name syntax: + * {@code openbao:[:]}. + */ + private static final String OPENBAO_PREFIX = "openbao:"; + + /** + * The secret name used for the legacy per-user password lookup. + */ + private static final String OPENBAO_SECRET_NAME = "OPENBAO_SECRET"; + + /** + * The secret name used to resolve to the logged-in Guacamole username. + */ + private static final String GUAC_USERNAME_NAME = "GUAC_USERNAME"; + + /** + * The default field fetched from an OpenBao secret when no explicit + * {@code :field} suffix is supplied. + */ + private static final String DEFAULT_FIELD = "password"; + /** * Client for communicating with OpenBao. */ @@ -58,38 +84,30 @@ public OpenBaoSecretService() { logger.info("OpenBaoSecretService initialized"); } - /** - * The token pattern for OpenBao secrets: ${OPENBAO_SECRET} - */ - public static final String OPENBAO_SECRET_TOKEN = "${OPENBAO_SECRET}"; - - /** - * The token pattern for Guacamole username: ${GUAC_USERNAME} - */ - public static final String GUAC_USERNAME_TOKEN = "${GUAC_USERNAME}"; - @Override public String canonicalize(String token) { - // Return the canonical form for tokens we recognize + if (token == null) return null; - // Remove ${} wrapper and return just the token name - if (OPENBAO_SECRET_TOKEN.equals(token)) { - return "OPENBAO_SECRET"; - } + // Existing names (including their ${...} form) are passed through. + if (OPENBAO_SECRET_NAME.equals(token) || ("${" + OPENBAO_SECRET_NAME + "}").equals(token)) + return OPENBAO_SECRET_NAME; - if (GUAC_USERNAME_TOKEN.equals(token)) { - return "GUAC_USERNAME"; - } + if (GUAC_USERNAME_NAME.equals(token) || ("${" + GUAC_USERNAME_NAME + "}").equals(token)) + return GUAC_USERNAME_NAME; + + // Arbitrary-path form: let it flow through unchanged so getValue() + // can parse it. + if (token.startsWith(OPENBAO_PREFIX)) + return token; - // Not our token return null; } @Override public Future getValue(String token) throws GuacamoleException { - // This method is called for simple token lookups without user context + // Without user context we cannot resolve per-user lookups. logger.warn("getValue(String) called without user context - cannot determine username"); return CompletableFuture.completedFuture(null); } @@ -98,75 +116,117 @@ public Future getValue(String token) throws GuacamoleException { public Future getValue(UserContext userContext, Connectable connectable, String token) throws GuacamoleException { - logger.info("getValue() called with token: {}", token); + if (token == null) + return CompletableFuture.completedFuture(null); - // Get the logged-in Guacamole username String username = userContext.self().getIdentifier(); - // Handle GUAC_USERNAME token - return the Guacamole username - if ("GUAC_USERNAME".equals(token)) { - logger.info("getValue() returning username: '{}'", username); + // Legacy: ${GUAC_USERNAME} → username + if (GUAC_USERNAME_NAME.equals(token)) return CompletableFuture.completedFuture(username); - } - // Handle OPENBAO_SECRET token - fetch password from OpenBao - if ("OPENBAO_SECRET".equals(token)) { - logger.info("Retrieving OpenBao secret for username: {}", username); - - try { - // Fetch the secret from OpenBao using the username - JsonObject response = openBaoClient.getSecret(username); - - // Extract the password field - String password = openBaoClient.extractPassword(response); - - if (password != null) { - logger.info("Successfully retrieved password from OpenBao for user: {} (length: {})", username, password.length()); - return CompletableFuture.completedFuture(password); - } else { - logger.warn("Password field not found in OpenBao for user: {}", username); - return CompletableFuture.completedFuture(null); - } + // Legacy: ${OPENBAO_SECRET} → password from /data/ + if (OPENBAO_SECRET_NAME.equals(token)) + return CompletableFuture.completedFuture( + fetchField(username, DEFAULT_FIELD, username)); + + // Additive: openbao:[:] + if (token.startsWith(OPENBAO_PREFIX)) { + String spec = token.substring(OPENBAO_PREFIX.length()); + int sep = spec.lastIndexOf(':'); + + String path; + String field; + if (sep > 0 && sep < spec.length() - 1) { + path = spec.substring(0, sep); + field = spec.substring(sep + 1); + } + else { + path = spec; + field = DEFAULT_FIELD; + } - } catch (GuacamoleException e) { - logger.error("Failed to retrieve secret from OpenBao for user: {}", username, e); - // Return null instead of throwing to allow connection attempt with empty password + if (path.isEmpty()) { + logger.warn("Empty path supplied for token: {}", token); return CompletableFuture.completedFuture(null); } + + return CompletableFuture.completedFuture(fetchField(path, field, username)); } - // Not a recognized token - logger.warn("Token '{}' not recognized, returning null", token); + logger.debug("Token \"{}\" not recognized by OpenBao secret service", token); return CompletableFuture.completedFuture(null); } + /** + * Retrieves {@code field} from the OpenBao secret at {@code path}, + * logging the supplied {@code contextLabel} for diagnostics. Returns + * null rather than throwing when retrieval fails, to preserve the + * existing behavior of allowing Guacamole to proceed (possibly with + * an empty credential). + * + * @param path + * Path of the secret to fetch, relative to the configured mount. + * + * @param field + * Name of the field to extract. + * + * @param contextLabel + * Identifier used only for log messages. + * + * @return + * The field value, or null on failure or if the field is absent. + */ + private String fetchField(String path, String field, String contextLabel) { + try { + JsonObject response = openBaoClient.getSecret(path); + String value = openBaoClient.extractField(response, field); + + if (value == null) + logger.warn("Field \"{}\" not found in OpenBao secret at \"{}\" (context: {})", + field, path, contextLabel); + + return value; + } + catch (GuacamoleException e) { + logger.error("Failed to retrieve secret \"{}\" from OpenBao (context: {}): {}", + path, contextLabel, e.getMessage()); + logger.debug("Underlying exception:", e); + return null; + } + } + @Override public Map> getTokens(UserContext userContext, Connectable connectable, GuacamoleConfiguration config, TokenFilter tokenFilter) throws GuacamoleException { - Map> tokens = new java.util.HashMap<>(); + Map> tokens = new HashMap<>(); String username = userContext.self().getIdentifier(); - // Add GUAC_USERNAME token (always available) - tokens.put("GUAC_USERNAME", CompletableFuture.completedFuture(username)); + // GUAC_USERNAME is always available. + tokens.put(GUAC_USERNAME_NAME, CompletableFuture.completedFuture(username)); - // Add OPENBAO_SECRET token (fetch from OpenBao) + // Best-effort pre-population of OPENBAO_SECRET from the per-user + // secret at /data/. Missing/unreachable secrets + // are logged but do not abort token resolution. try { JsonObject response = openBaoClient.getSecret(username); String password = openBaoClient.extractPassword(response); - if (password != null) { - tokens.put("OPENBAO_SECRET", CompletableFuture.completedFuture(password)); - logger.info("Added token OPENBAO_SECRET with password from OpenBao (length: {})", password.length()); - } else { + if (password != null) + tokens.put(OPENBAO_SECRET_NAME, + CompletableFuture.completedFuture(password)); + else logger.warn("Password not found in OpenBao for user: {}", username); - } - } catch (Exception e) { - logger.error("Failed to get secret from OpenBao for user: {}", username, e); + } + catch (GuacamoleException e) { + logger.warn("Failed to pre-populate OPENBAO_SECRET for user {}: {}", + username, e.getMessage()); + logger.debug("Underlying exception:", e); } - logger.info("Returning {} tokens: {}", tokens.size(), tokens.keySet()); + logger.debug("Returning {} OpenBao tokens: {}", tokens.size(), tokens.keySet()); return tokens; } } From c05a4713aca358d958013009bde5b4f1e32c73e3 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Fri, 24 Apr 2026 16:54:00 +0200 Subject: [PATCH 03/16] GUACAMOLE-2196: Work in progress more complete OpenBao Vault module. It compiles but is untested --- .../apache-commons-logging-1.3.5/README | 8 + .../dep-coordinates.txt | 1 + doc/licenses/caffeine-3.1.8/README | 7 + .../caffeine-3.1.8/dep-coordinates.txt | 1 + doc/licenses/io-micrometer-1.14.7/README | 8 + .../io-micrometer-1.14.7/dep-coordinates.txt | 2 + doc/licenses/org-jspecify-1.0.0/README | 8 + .../org-jspecify-1.0.0/dep-coordinates.txt | 1 + doc/licenses/spring-framework-5.3.29/README | 8 + .../dep-coordinates.txt | 7 + doc/licenses/spring-vault-core-2.3.4/README | 8 + .../dep-coordinates.txt | 1 + extensions/guacamole-auth-ban/pom.xml | 2 +- .../guacamole-vault-openbao/.ratignore | 3 + .../modules/guacamole-vault-openbao/LICENSE | 48 + .../modules/guacamole-vault-openbao/README.md | 55 +- .../include/openbao-optional.properties.in | 37 + .../docs/include/openbao.properties.in | 6 + .../docs/openbao.md.j2 | 395 ++++++++ .../modules/guacamole-vault-openbao/pom.xml | 36 +- .../vault/openbao/conf/OpenBaoConfig.java | 102 --- .../conf/OpenBaoConfigurationService.java | 299 ++++-- .../secret/FileTokenAuthentication.java | 168 ++++ .../vault/openbao/secret/OpenBaoClient.java | 864 ++++++++++++------ .../openbao/secret/OpenBaoSecretService.java | 234 ++--- .../vault/openbao/secret/OpenBaoSshKeys.java | 262 ++++++ .../openbao/secret/TimeoutVaultTemplate.java | 87 ++ .../UsernamePasswordAuthentication.java | 87 ++ ...UsernamePasswordAuthenticationOptions.java | 85 ++ 29 files changed, 2172 insertions(+), 658 deletions(-) create mode 100644 doc/licenses/apache-commons-logging-1.3.5/README create mode 100644 doc/licenses/apache-commons-logging-1.3.5/dep-coordinates.txt create mode 100644 doc/licenses/caffeine-3.1.8/README create mode 100644 doc/licenses/caffeine-3.1.8/dep-coordinates.txt create mode 100644 doc/licenses/io-micrometer-1.14.7/README create mode 100644 doc/licenses/io-micrometer-1.14.7/dep-coordinates.txt create mode 100644 doc/licenses/org-jspecify-1.0.0/README create mode 100644 doc/licenses/org-jspecify-1.0.0/dep-coordinates.txt create mode 100644 doc/licenses/spring-framework-5.3.29/README create mode 100644 doc/licenses/spring-framework-5.3.29/dep-coordinates.txt create mode 100644 doc/licenses/spring-vault-core-2.3.4/README create mode 100644 doc/licenses/spring-vault-core-2.3.4/dep-coordinates.txt create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/LICENSE create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao-optional.properties.in create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao.properties.in create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 delete mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfig.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/FileTokenAuthentication.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSshKeys.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/TimeoutVaultTemplate.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/UsernamePasswordAuthentication.java create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/UsernamePasswordAuthenticationOptions.java diff --git a/doc/licenses/apache-commons-logging-1.3.5/README b/doc/licenses/apache-commons-logging-1.3.5/README new file mode 100644 index 0000000000..e3ae7b7a03 --- /dev/null +++ b/doc/licenses/apache-commons-logging-1.3.5/README @@ -0,0 +1,8 @@ +Commons Logging (http://commons.apache.org/proper/commons-logging/) +-------------------------------------------------------------- + + Version: 1.3.5 + From: 'Apache Software Foundation' (https://www.apache.org/) + License(s): + Apache v2.0 + diff --git a/doc/licenses/apache-commons-logging-1.3.5/dep-coordinates.txt b/doc/licenses/apache-commons-logging-1.3.5/dep-coordinates.txt new file mode 100644 index 0000000000..c7c833f057 --- /dev/null +++ b/doc/licenses/apache-commons-logging-1.3.5/dep-coordinates.txt @@ -0,0 +1 @@ +commons-logging:commons-logging:jar:1.3.5 diff --git a/doc/licenses/caffeine-3.1.8/README b/doc/licenses/caffeine-3.1.8/README new file mode 100644 index 0000000000..2e5cf93923 --- /dev/null +++ b/doc/licenses/caffeine-3.1.8/README @@ -0,0 +1,7 @@ +Caffeine (https://github.com/ben-manes/caffeine) +------------------------------------------------ + + Version: 3.1.8 + From: 'Ben Manes' (https://github.com/ben-manes) + License(s): + Apache v2.0 diff --git a/doc/licenses/caffeine-3.1.8/dep-coordinates.txt b/doc/licenses/caffeine-3.1.8/dep-coordinates.txt new file mode 100644 index 0000000000..0b23b4e4d4 --- /dev/null +++ b/doc/licenses/caffeine-3.1.8/dep-coordinates.txt @@ -0,0 +1 @@ +com.github.ben-manes.caffeine:caffeine:jar:3.1.8 diff --git a/doc/licenses/io-micrometer-1.14.7/README b/doc/licenses/io-micrometer-1.14.7/README new file mode 100644 index 0000000000..cca5249342 --- /dev/null +++ b/doc/licenses/io-micrometer-1.14.7/README @@ -0,0 +1,8 @@ +Micrometer IO (http://micrometer.io/) +-------------------------------------------------------------- + + Version: 1.14.7 + From: 'VMWare' (https://tanzu.vmware.com/) + License(s): + Apache v2.0 + diff --git a/doc/licenses/io-micrometer-1.14.7/dep-coordinates.txt b/doc/licenses/io-micrometer-1.14.7/dep-coordinates.txt new file mode 100644 index 0000000000..37084e1f06 --- /dev/null +++ b/doc/licenses/io-micrometer-1.14.7/dep-coordinates.txt @@ -0,0 +1,2 @@ +io.micrometer:micrometer-observation:jar:1.14.7 +io.micrometer:micrometer-commons:jar:1.14.7 diff --git a/doc/licenses/org-jspecify-1.0.0/README b/doc/licenses/org-jspecify-1.0.0/README new file mode 100644 index 0000000000..789a528355 --- /dev/null +++ b/doc/licenses/org-jspecify-1.0.0/README @@ -0,0 +1,8 @@ +JSpecify (https://jspecify.dev) +-------------------------------------------------------------- + + Version: 1.0.0 + From: 'JSpecify' (https://jspecify.dev) + License(s): + Apache v2.0 + diff --git a/doc/licenses/org-jspecify-1.0.0/dep-coordinates.txt b/doc/licenses/org-jspecify-1.0.0/dep-coordinates.txt new file mode 100644 index 0000000000..36c489c95d --- /dev/null +++ b/doc/licenses/org-jspecify-1.0.0/dep-coordinates.txt @@ -0,0 +1 @@ +org.jspecify:jspecify:jar:1.0.0 diff --git a/doc/licenses/spring-framework-5.3.29/README b/doc/licenses/spring-framework-5.3.29/README new file mode 100644 index 0000000000..25da871418 --- /dev/null +++ b/doc/licenses/spring-framework-5.3.29/README @@ -0,0 +1,8 @@ +Spring Framework (https://spring.io/projects/spring-framework) +-------------------------------------------------------------- + + Version: 5.3.29 + From: 'Spring' (https://spring.io/) + License(s): + Apache v2.0 + diff --git a/doc/licenses/spring-framework-5.3.29/dep-coordinates.txt b/doc/licenses/spring-framework-5.3.29/dep-coordinates.txt new file mode 100644 index 0000000000..1de5225a24 --- /dev/null +++ b/doc/licenses/spring-framework-5.3.29/dep-coordinates.txt @@ -0,0 +1,7 @@ +org.springframework:spring-aop:jar:5.3.29 +org.springframework:spring-beans:jar:5.3.29 +org.springframework:spring-context:jar:5.3.29 +org.springframework:spring-core:jar:5.3.29 +org.springframework:spring-expression:jar:5.3.29 +org.springframework:spring-jcl:jar:5.3.29 +org.springframework:spring-web:jar:5.3.29 diff --git a/doc/licenses/spring-vault-core-2.3.4/README b/doc/licenses/spring-vault-core-2.3.4/README new file mode 100644 index 0000000000..a153153953 --- /dev/null +++ b/doc/licenses/spring-vault-core-2.3.4/README @@ -0,0 +1,8 @@ +Spring Framework (https://spring.io/projects/spring-framework) +-------------------------------------------------------------- + + Version: 2.3.4 + From: 'Spring' (https://spring.io/) + License(s): + Apache v2.0 + diff --git a/doc/licenses/spring-vault-core-2.3.4/dep-coordinates.txt b/doc/licenses/spring-vault-core-2.3.4/dep-coordinates.txt new file mode 100644 index 0000000000..a20922d66d --- /dev/null +++ b/doc/licenses/spring-vault-core-2.3.4/dep-coordinates.txt @@ -0,0 +1 @@ +org.springframework.vault:spring-vault-core:jar:2.3.4 diff --git a/extensions/guacamole-auth-ban/pom.xml b/extensions/guacamole-auth-ban/pom.xml index 31836ef85e..f07d9663dd 100644 --- a/extensions/guacamole-auth-ban/pom.xml +++ b/extensions/guacamole-auth-ban/pom.xml @@ -69,7 +69,7 @@ com.github.ben-manes.caffeine caffeine - 2.9.3 + 3.1.8 diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/.ratignore b/extensions/guacamole-vault/modules/guacamole-vault-openbao/.ratignore index e69de29bb2..3c95685f9b 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/.ratignore +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/.ratignore @@ -0,0 +1,3 @@ +docs/* +docs/*/* + diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/LICENSE b/extensions/guacamole-vault/modules/guacamole-vault-openbao/LICENSE new file mode 100644 index 0000000000..698c5bc04f --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/LICENSE @@ -0,0 +1,48 @@ +Apache Guacamole Vault Extension +================================ + +Licensed to the Apache Software Foundation (ASF) under one or more +contributor license agreements. + +This product includes software developed at +The Apache Software Foundation (http://www.apache.org/). + +--- + +Apache License, Version 2.0 +=========================== + +[ FULL Apache 2.0 license text here ] + +--- + +This product includes the following third‑party software: + +Spring Framework +---------------- +Copyright © 2002‑2025 VMware, Inc. +License: Apache License, Version 2.0 + +[ Apache 2.0 license text OR reference if already included above ] + +Spring Vault +------------ +Copyright © 2014‑2025 VMware, Inc. +License: Apache License, Version 2.0 + +Micrometer +---------- +Copyright © 2018‑2025 VMware, Inc. +License: Apache License, Version 2.0 + +Apache Commons Logging +---------------------- +Copyright © The Apache Software Foundation +License: Apache License, Version 2.0 + +Bouncy Castle +------------- +Copyright © The Legion of the Bouncy Castle +License: MIT License + +[ FULL MIT license text here ] diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/README.md b/extensions/guacamole-vault/modules/guacamole-vault-openbao/README.md index 6c2cb03046..ba6996d49a 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/README.md +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/README.md @@ -53,35 +53,13 @@ Add the following properties to `guacamole.properties`: # OpenBao server URL (required) openbao-server-url: http://openbao.example.com:8200 -# OpenBao authentication token (required unless AppRole is configured) +# OpenBao authentication token (required) openbao-token: s.YourTokenHere # KV mount path (optional, default: guacamole-credentails) openbao-mount-path: guacamole-credentails ``` -#### AppRole Authentication (optional) - -As an alternative to a static `openbao-token`, the extension can -authenticate via the AppRole auth method, which typically issues -shorter-lived tokens: - -```properties -# Required for AppRole -openbao-role-id: -openbao-secret-id: - -# Optional; defaults to "approle" -openbao-approle-path: approle -``` - -When both `openbao-role-id` and `openbao-secret-id` are set, the -extension performs an AppRole login at -`/v1/auth//login` and uses the returned -`client_token` for all subsequent requests. The token is cached and -refreshed automatically on a 403 response. If AppRole is configured, -`openbao-token` is ignored. - **Note**: The extension uses hardcoded defaults for: - KV version: `2` (KV v2 secrets engine) - Connection timeout: `5000ms` (5 seconds) @@ -91,41 +69,14 @@ refreshed automatically on a 403 response. If AppRole is configured, When creating connections in Guacamole, use these token patterns: -- **`${OPENBAO_SECRET}`**: Replaced with the `password` field of the - secret stored at `/data/`. -- **`${GUAC_USERNAME}`**: Replaced with the logged-in Guacamole username. +- **`${OPENBAO_SECRET}`**: Replaced with the password from OpenBao +- **`${GUAC_USERNAME}`**: Replaced with the logged-in Guacamole username Example RDP connection: - Username: `${GUAC_USERNAME}` - Password: `${OPENBAO_SECRET}` - Hostname: `192.168.1.100` -#### Arbitrary Secret Paths (via token mapping) - -For secrets that are not stored at the per-user path, an additional -name format can be used in the vault token mapping YAML -(`openbao-token-mapping.yml`): - -``` -openbao:[:] -``` - -- `` is the path under the configured mount, without the - `/data/` prefix (KV v2 is handled automatically). -- `` selects which field to return from the secret's data; - defaults to `password` if omitted. - -Example `openbao-token-mapping.yml`: - -```yaml -DB_PASSWORD: "openbao:db/prod-ro:password" -SSH_KEY: "openbao:ssh-keys/${GUAC_USERNAME}:private_key" -JUMPBOX_PW: "openbao:shared/jumpbox" -``` - -This mechanism is additive. Existing configurations that use only -`${OPENBAO_SECRET}` continue to work unchanged. - ## Secret Path Mapping The extension maps Guacamole usernames directly to OpenBao secret paths: diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao-optional.properties.in b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao-optional.properties.in new file mode 100644 index 0000000000..2ac2e4a632 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao-optional.properties.in @@ -0,0 +1,37 @@ +# A username of an account in the Vault for use with Guacamole +vault-username: guacamole + +# A password for the account in the Vault used by Guacamole +vault-password: my-secret-password + +# The lifetime of the data in the Guacamole cache in milliseconds. +# This is used to avoid multiple concurrent requests for the same +# Vault record with different secret values. After this time the +# secret values are cleared from Guacamole +vault-cache-lifetime: 5000 + +# The maximum time that a request to the vault server can take in +# milliseconds. After this time a null value is returned for the +# secret requested +vault-request-timeout: 5000 + +# The maximum time allowed for a connection to the vault server in +# milliseconds. After this time a null value is returned for the +# secret requested +vault-connection-timeout: 5000 + +# The duration validity of the certificates generated for signed +# certification SSH connections. After this time the signed certificate +# can not be reused for a connection. +# +# It should be noted that in the caseof drift of the clock between the +# ssh clients and the Vault server, a certificate might be invalidate +# incorrectly. This value must be sufficiently large to account for +# clock drift. +vault-ssh-connection-timeout: 10000 + +# The type of the SSH certificates generated by Guacamole for signed +# SSH certificate access. Valid types are `ed25519` and `rsa`. Only +# 4096-bit RSA certificates are supported +vault-ssh-type: ed25519 + diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao.properties.in b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao.properties.in new file mode 100644 index 0000000000..7b795bd5fe --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao.properties.in @@ -0,0 +1,6 @@ +# The URI of the OpenBao or Hashicorp Vault server to use. +vault-uri: http://localhost:8200 + +# The authentication token to use to access the vault +vault-token: s.IPzVb3b5dThhjIrBX245szisjTQcylwSjMEeyJqcidaH8Hf + diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 new file mode 100644 index 0000000000..c89bfc23a5 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 @@ -0,0 +1,395 @@ +{# vim: set filetype=markdown.jinja : #} +{%- import 'include/ext-macros.md.j2' as ext with context -%} + +Retrieving secrets from a vault +=============================== + +Guacamole supports reading secrets such as connection-specific passwords from a +key vault, automatically injecting those secrets into connection configurations +using [parameter tokens](parameter-tokens) or Guacamole configuration +properties via an additional, vault-specific configuration file analogous to +`guacamole.properties`. The module supports both [OpenBao](https://openbao.org) +and [Hashicorp Vault](https://www.hashicorp.com/products/vault) as the vault +provider. + +```{include} include/warn-config-changes.md +``` + +(vault-downloading)= + +Installing/Enabling the vault extension +--------------------------------------- + +{{ ext.install('openbao', 'guacamole-vault', 'openbao/guacamole-vault-openbao') }} + +(adding-guac-to-vlt)= + +### Adding Guacamole to Hashicorp or OpenBao + +Allowing an application like Guacamole to access secrets via and OpenBao +or Hashicorp Vault involves creating appropriate mount paths for the +secret engines needed and the needed authentication parameters. It is +not the objective of this document to discuss the creation and maintenance +of Vault mount paths and the user is refered to the [OpenBao](https://openbao.org/docs) +or [Hashicorp Vault](https://developer.hashicorp.com/vault/docs) for this +information. + +The minimum information needed by Guacamole to be setup are in address of the +Vault server, including its associated port, and the means of authentication to the +server. The Vault server uses a standard HTTP REST API and a typical URI might +look like `https://vault.example.com:8200`. + +There are two means of authenticating to the Vault server supported by Guacamole. +However, before discussing these two means of authentication, it is important to +setup an access policy for the use of Guacamole + +Guacamole only needs read access to the secret engine mount path and only to the +paths that are actually used in Guacamole. It therefore makes sense to limit +Guacamole's access to the Vault secrets by a specific policy. It should also be +noted that Guacamole uses the special mount path `sys/mounts` to list the available +mount paths and detect the type of the secret engine used on this mount path. + +So, imagine that Guacamole needs access to the 4 mount paths representing the 4 +secret engines supported by Gaucamole, but only to a subset of the roles on the +mount paths of these secret engines respresented by the path `guacamole` + +``` +$ cat << EOT >> gaucamole.hcl +# Read system mount table +path "sys/mounts" { + capabilities = ["read"] +} + +# Read from guacamole team of ldap mount path for static accounts +path "ldap/static/guacamole/*" { + capabilities = ["read"] +} + +# Read from mount path of the SSH engine allowing generation of signed +# certificates for the accounts managed by guacamole +path "ssh/cert/guacamole/*" { + capabilities = ["read"] +} + +# Read from mount path of the database mount path dediciated to Guacamole +path "db/guacamole/*" { + capabilities = ["read"] +} + +# Read from mount path of the key-values store dediciated to Guacamole +path "kv/guacamole/*" { + capabilities = ["read"] +} +EOT +``` + +We then need to upload this policy to the vault. This can be done with specific +OpenBao or Hashicorp Vault commands, but here we propose to use `curl` so that the +syntax is the same for both Vault suppliers + +``` +$ export VAULT_TOKEN=... +$ curl -sS \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --request PUT \ + --data @gaucamole.hcl \ + http://127.0.0.1:8200/v1/sys/policies/acl/guacamole +``` + +where `VAULT_TOKEN` is a valid Vault token allowing administrative access. It +also assumes that the vault server is running locally and on http.This +creates a policy called `guacamole` in the vault for our use. + +#### Authentication using Tokens + +Guacamole authenticates to OpenBao using a token. This token might be supplied +directly Guacamole in the form of a string. Alternatively, Guacamole might be +supplied with a file where to read the token. This file could be managed by a +[Vault agent](https://openbao.org/docs/agent-and-proxy/agent) allowing more +complex Vault authentication to be delegated to the Vault agent. + +To create a static token for use with Guacamole, the command is + +``` +$ curl -sS \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --request POST \ + http://127.0.0.1:8200/v1/auth/token/create \ + --data '{ + "policies": ["guacamole"], + "ttl": "0", + "renewable": false, + "display_name": "service-guacamole", + "no_parent": true + }' +``` + +This creates a non expiring static token for Guacamole's use. There are security +concerning of using non expiring tokens, and they might not be in conformance +with local password policies. For this reason, AppRole tokens with an provisionning +Agent for Guacamole are preferred. + +If you'd prefer to use an agent to manage AppRole authentication using a sink +file to exchange the token with Guacamole, see the discussion of creating +[OpenBao AppRole](https://openbao.org/docs/auth/approle) or +[Hashicorp AppRole](https://developr.hashicorp/vault/docs/auth/approle) +secretsa, and [Vault agent](https://openbao.org/docs/agent-and-proxy/agent) +for the discussion of the setup of the Agent. + +#### Authentication using Username and Password + +If the authentication to Vault is via a username and password, we need to create +a dediciated account for Guacamole in the vaul. This can be done with a command +like + +``` +curl \ + -H "X-Vault-Token: $VAULT_TOKEN" \ + -X POST \ + http://127.0.0.1:8200/v1/auth/userpass/users/guacamole \ + -d '{ + "password": "strong-password-here", + "policies": ["guacamole"] + }' +``` + +This creates a user `guacmaole` using the same policy as previously and sets the +password assuming the userpass engine is mount on the default path `auth/userpass`. + +It should be noted that the Vault can not rotate the passwords of these user +accounts. So the maintenance and update of the password needs to be done manually, +updating the password configured in Guacamole at the same time. + + +(guac-vault-config)= + +Required configuration +---------------------- + +{% call ext.config('openbao', required=True) %} +Guacamole requires only a single configuration property to configure secret +retrieval from a Vault, `vault-uri`, the address of the vault server. The +option `vault-token` is not strictly necessary, but if it is absent it must +be replaced with `vault-username` and `vault-password` as discussed below. +All other {{ ext.properties() }} are optional. +{% endcall %} + +### Additional Configuration Options + +{% call ext.config('openbao-optional') %} +The following additional, optional {{ ext.properties() }} may be set as desired +to tailor the behavior of the Vault support: +{% endcall %} + + +(completing-vault-install)= + +Completing installation +----------------------- + +```{include} include/ext-completing.md +``` + +Retrieving connection secrets from a vault +------------------------------------------ + +Secrets for connection parameters are provided using [parameter +tokens](parameter-tokens) that can be either automatically or manually defined. +Automatic tokens are [defined dynamically by Guacamole when the connection is +used](vault-dynamic-secrets) based on other configuration values within the +connection, such as the connection's `hostname` or `username`. Manual tokens +are injected by Guacamole based on secrets that are [statically mapped using an +additional configuration file](vault-static-secrets). + +(vault-dynamic-secrets)= + +### Automatic injection of secrets based on connection parameters + +Parameter tokens containing the values of secrets within a record are +automatically injected for connections whose parameter values match specific +criteria, such as having a particular `username` or `hostname`. This happens +whenever a connection is used and is fully dynamic, affecting only the state of +the connection from the perspective of the user accessing it. + +:::{important} +There are limitations to the degree that secrets can be automatically applied +based on connection parameters: + +* Automatic injection of secrets cannot currently be used with balancing + connection groups, as the underlying connection that the balancing + implementation will choose cannot be known before token values must be made + available. + +If automatic injection of secrets cannot work for your use case, consider using +[manually-specified secrets via `openbao-token-mapping.yml`](vault-static-secrets). +::: + +Parameter tokens injected from KSM records usually take the form +{samp}`$\{VAULT:{MOUNT_PATH}/{PATH}/{SECRET}\}`, where `MOUNT_PATH` is the +mount path of the Vault secret engine used, `PATH` is the path to the secret +record and `SECRET` determines what value is retrieved from that record. + +#### Mount path detection + +Guacamole automatically detecting all of the available mount patch on the vault server +and detects their type. The selection of which secret engine that Guacamole used is +therefore based the value of `MOUNT_PATH`. + +It is possible to have a value of the mount path like `subpath1` and a second mount path +`subpath1/subpath2`. Guacamole therefore chooses the secret engine with the longest +match to the start of the token. + + +#### Secret Engines and paths supported + +The following Vault secret engines are supported + +`LDAP` +: The Vault secret engine supports both open source ldap servers and Microsoft Active + Directory. There are three types of LDAP accounts in the Vault. A `static` role has + a fixed usename, a `dynamic` role is an account created and deleted by the vault for + the demanded operation, and a `service` role is a pool of accounts managed by the + vault that can be used. + + Guacamole simplifies the tokens for the LDAP paths in the following manner + +``` +${vault://{MOUNT_PATH}/{static|dynamic|service}/{ROLE}/{username/password}} +``` + + An example of a valid token might then be `${vault://ldap/static/myaccount/username}`, + where the value of `ROLE` here is `myaccount`. + + Please not that Guacamole does not touch the lease times associated with these accounts + in the vault and it does not check in the service accounts after use. + +`SSH` +: The Vault secret engine support both one-time password and signed certificate modes of + the secret engine. This secret engine required a helper command on the client machines + and is defined by its role. Token for one-time passwords are of the form + +``` +${vault://{MOUNT_PATH}/otp/{ROLE}/{username/password}} +``` + + An example of a valid token might then be `${vault://ssh/otp/myaccount/username}`, + where the value of `ROLE` here is `myaccount`. + + Vault does not support the creation of certificates itself, so the role for SSH certificate + creation is left to Guacamole. A valid SSH token for the creation of a certificate is + +``` +${vault://{MOUNT_PATH}/cert/{ROLE}/{public/private}} +``` + + An example of a valid token might then be `${vault://ssh/cert/myaccount/public}`, + where the value of `ROLE` here is `myaccount`, and the public certificate for use with + Guacamole is recovered. For SSH signed certificates le principal name used is determined + by the `username` of the connection and the certificate will be limited for the use of + only this user. Also as Guacamole does not need port forwarding the certicate is created + in a manner that refuses all use of port forwarding. + +`DATABASE` +: Guacamole supports all databases supported by the Vault database secret engine. The form +of the database tokens are + +``` +${vault://{MOUNT_PATH}/{ROLE}/{username/password}} +``` + +`Key-Values` +: The Vault key-values secret engine as a generic secret engine to store arbitrary + secrets. This means that it is adapted to clients that are not managed by one of + the other three secret engines supported by Guacamole. For example, this engine + might be used for a password on an unmanaged account of a firewall. + + Both the path and secret values of key-value tokens are completemy arbitrary and + a valid token might be of the form + +``` +${vault://{MOUNT_PATH}/{PATH}/{SECRET} +``` + + So a token like `vault://kv/org/example/fw1/adminuser` where `kv` is the mount path, + `org/example/fw1` the path the the record and `adminuser` the username of associated + with a generic account. + + +In addition, the following sub-tokens can be used with the vault token to modify +its value when it is referenced. These sub-tokens are of the form `{SUB_TOKEN}` +within the vault token itself. If `{SUB_TOKEN}` is valid literal with the vault +token, you can yse `$${SUB_TOKEN}` to ensure it is interpreted as `{SUB_TOKEN}`. +The allowed sub-token values are: + +`{USER}` +: The record whose "login" field contains a username that matches the value of + the `username` parameter of the connection. If the record has no "login" field, + a "text" or "password" custom field will be used if the label of that field + contains the word "username" (case-insensitive). + +`{SERVER}` +: The record whose "login" field contains a hostname that matches the value of + the `hostname` parameter of the connection. If the record has no "login" field, + a "text" or "password" custom field will be used if the label of that field + contains the word "hostname", "address", or "IP address" (case-insensitive, + ignoring any spaces between "IP" and "address"). If the record is a KeeperPAM + resource with linked credentials, it will use the linked administrative credentials. + +`{GATEWAY}` +: Identical to `SERVER`, except that the value of the `gateway-hostname` + parameter is used. This is only applicable to RDP connections. + +`{GATEWAY_USER}` +: Identical to `USER`, except that the value of the `gateway-username` + parameter is used. This is only applicable to RDP connections. + +For example `vault://ldap/org/example/{SERVER}/{USER}/password` is a valid vault token. + +(vault-static-secrets)= + +### Manual definition of secrets + +Parameter tokens can be manually defined by placing a YAML file within +`GUACAMOLE_HOME` called `openbao-token-mapping.yml`. This file must contain a set +of name/value pairs where each name is the name of a token to define and each +value is a token in the same form as the above. + +For example, the following `openbao-token-mapping.yml` defines two parameter +tokens, `${WINDOWS_ADMIN_PASSWORD}` and `${LINUX_SERVER_KEY}`, each pulling +their values from different parts of different records in KSM: + +```yaml +WINDOWS_ADMIN_PASSWORD: vault://ldap/static/org/host/account/password +LINUX_SERVER_KEY: vault://kv/org/host/server_key.pem +``` + +Token substitution of other parameter tokens like `${GUAC_USERNAME}` is +performed *on the reference to the secret* to allow the reference to vary by +values that may be relevant to the connection. The values of substituted tokens +are URL-encoded before being placed into the reference. In addition, the +sub-tokens as above are allowed within the secret reference. + +(guacamole-properties-vlt)= + +Retrieving configuration properties from a vault +------------------------------------------------ + +Secrets for Guacamole configuration properties are provided through [an +additional file within `GUACAMOLE_HOME` called `guacamole.properties.vlt`](guacamole-properties-vlt). +This file is _identical_ to `guacamole.properties` except that the values of properties +are references to Vault secrets in the format as above. + +Secrets can be used for any Guacamole configuration property that isn't +required to configure the Vault support. + +For example, the following `guacamole.properties.vlt` defines both the +`mysql-username` and `mysql-password` properties using values from a +role in the Vault database secret engine. + +``` +mysql-username: vault://db/guacamole/username +mysql-password: vault://db/guacamole/password +``` + +The secrate engine in this case is mounted on the path `db/` and this secret +engine defines a role `guacamole`. diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/pom.xml b/extensions/guacamole-vault/modules/guacamole-vault-openbao/pom.xml index 41fc201711..88365b729e 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/pom.xml +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/pom.xml @@ -51,20 +51,38 @@ ${revision} - + - org.apache.httpcomponents.client5 - httpclient5 - 5.2.1 + com.fasterxml.jackson.core + jackson-databind - + - com.google.code.gson - gson - 2.10.1 + org.springframework.vault + spring-vault-core + 2.3.4 - + + + org.bouncycastle + bcprov-jdk15to18 + 1.80 + + + + + org.bouncycastle + bcpkix-jdk15to18 + 1.80 + + + + com.github.ben-manes.caffeine + caffeine + 3.1.8 + + diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfig.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfig.java deleted file mode 100644 index b651f478c7..0000000000 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfig.java +++ /dev/null @@ -1,102 +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.openbao.conf; - -import org.apache.guacamole.properties.StringGuacamoleProperty; -import org.apache.guacamole.properties.URIGuacamoleProperty; - -/** - * Configuration properties for OpenBao vault integration. - */ -public class OpenBaoConfig { - - /** - * OpenBao server URL (e.g., "http://localhost:8200"). - * This property is REQUIRED and must be configured in guacamole.properties. - */ - public static final URIGuacamoleProperty OPENBAO_SERVER_URL = - new URIGuacamoleProperty() { - @Override - public String getName() { - return "openbao-server-url"; - } - }; - - /** - * OpenBao authentication token. Required unless AppRole - * authentication is configured via {@link #OPENBAO_ROLE_ID} and - * {@link #OPENBAO_SECRET_ID}. - */ - public static final StringGuacamoleProperty OPENBAO_TOKEN = - new StringGuacamoleProperty() { - @Override - public String getName() { - return "openbao-token"; - } - }; - - /** - * OpenBao AppRole role ID. Optional. When both this property and - * {@link #OPENBAO_SECRET_ID} are set, AppRole authentication is used - * instead of a static token. - */ - public static final StringGuacamoleProperty OPENBAO_ROLE_ID = - new StringGuacamoleProperty() { - @Override - public String getName() { - return "openbao-role-id"; - } - }; - - /** - * OpenBao AppRole secret ID. Optional. See {@link #OPENBAO_ROLE_ID}. - */ - public static final StringGuacamoleProperty OPENBAO_SECRET_ID = - new StringGuacamoleProperty() { - @Override - public String getName() { - return "openbao-secret-id"; - } - }; - - /** - * OpenBao AppRole auth mount path (default: "approle"). Only relevant - * when {@link #OPENBAO_ROLE_ID} / {@link #OPENBAO_SECRET_ID} are set. - */ - public static final StringGuacamoleProperty OPENBAO_APPROLE_PATH = - new StringGuacamoleProperty() { - @Override - public String getName() { - return "openbao-approle-path"; - } - }; - - /** - * OpenBao KV secrets engine mount path (default: "rdp-creds"). - * This is the mount point where RDP credentials are stored. - */ - public static final StringGuacamoleProperty OPENBAO_MOUNT_PATH = - new StringGuacamoleProperty() { - @Override - public String getName() { - return "openbao-mount-path"; - } - }; -} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfigurationService.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfigurationService.java index 3167293b06..9d2e40fc4f 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfigurationService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfigurationService.java @@ -19,16 +19,146 @@ package org.apache.guacamole.vault.openbao.conf; -import com.google.inject.Inject; import java.net.URI; +import com.google.inject.Inject; import org.apache.guacamole.GuacamoleException; 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; /** - * Service for retrieving OpenBao configuration from guacamole.properties. + * Service for retrieving Hashicorp/OpenBao configuration from guacamole.properties. */ public class OpenBaoConfigurationService extends VaultConfigurationService { + /** + * The default cache lifetime in milliseconds. + */ + public static final int DEFAULT_CACHE_LIFETIME = 5000; + + /** + * The default request timeout in milliseconds. + */ + public static final int DEFAULT_REQUEST_TIMEOUT = 5000; + + /** + * The default connection tiemout in milliseconds. + */ + public static final int DEFAULT_CONNECTION_TIMEOUT = 10000; + + /** + * The default ssh connection tiemout in milliseconds. + */ + private static final int DEFAULT_SSH_CONNECTION_TIMEOUT = 10000; + + /** + * The default ssh certificate type + */ + private static final String DEFAULT_SSH_TYPE = "ed25519"; + + /** + * The name of the file which contains the YAML mapping of connection + * parameter token to secrets within Hashicorp/OpenBao Vault. + */ + 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/OpenBao Vault. + */ + private static final String PROPERTIES_FILENAME = "guacamole.properties.vlt"; + + /** + * The URI of the hashicorp or OpenBao vault to use. + */ + private static final URIGuacamoleProperty VAULT_URI = + new URIGuacamoleProperty() { + + @Override + public String getName() { return "vault-uri"; } + }; + + /** + * The authentication token to use to access the vault. + */ + private static final StringGuacamoleProperty VAULT_TOKEN = + new StringGuacamoleProperty() { + + @Override + 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 a connection to the vault server can take in ms. + */ + private static final IntegerGuacamoleProperty VAULT_SSH_CONNECTION_TIMEOUT = + new IntegerGuacamoleProperty() { + + @Override + public String getName() { return "vault-ssh-connection-timeout"; } + }; + + /** + * 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"; } + }; /** * The Guacamole server environment. @@ -39,139 +169,160 @@ public class OpenBaoConfigurationService extends VaultConfigurationService { /** * Creates a new OpenBaoConfigurationService. */ - public OpenBaoConfigurationService() { - super("openbao-token-mapping.yml", "guacamole.properties.openbao"); - } /** - * Returns the OpenBao server URL as a parsed URI. - * - * @return The OpenBao server URI (e.g., "http://localhost:8200"). - * @throws GuacamoleException - * If the property is not defined in guacamole.properties or is - * not a valid URI. + * Creates a new OpenBaoConfigurationService which reads the configuration + * from "vault-token-mapping.yml" and properties from + * "guacamole.properties.vlt". 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 URI getServerUrl() throws GuacamoleException { - return environment.getRequiredProperty(OpenBaoConfig.OPENBAO_SERVER_URL); + + public OpenBaoConfigurationService() { + super(TOKEN_MAPPING_FILENAME, PROPERTIES_FILENAME); } /** - * Returns the OpenBao authentication token, or null if AppRole - * authentication is configured instead (see {@link #getRoleId()} and - * {@link #getSecretId()}). + * The URI of the hashicorp or OpenBao vault to use. + * + * @return URI + * The Hashicorp or OpenBao server URI (e.g., "http://localhost:8200"). * - * @return The OpenBao authentication token, or null. * @throws GuacamoleException - * If an error occurs reading the property. + * If the property is not defined in guacamole.properties or + * guacamole.properties can not be parsed. */ - public String getToken() throws GuacamoleException { - return environment.getProperty(OpenBaoConfig.OPENBAO_TOKEN); + public URI getVaultUri() throws GuacamoleException { + return environment.getRequiredProperty(VAULT_URI); } /** - * Returns the OpenBao AppRole role ID, or null if not configured. + * The authentication token to use to access the vault. + * + * @return String + * The Hashicorp or OpenBao authentication token. * - * @return The configured AppRole role ID, or null. * @throws GuacamoleException - * If an error occurs reading the property. + * If the property is not defined in guacamole.properties or + * guacamole.properties can not be parsed. */ - public String getRoleId() throws GuacamoleException { - return environment.getProperty(OpenBaoConfig.OPENBAO_ROLE_ID); + public String getVaultToken() throws GuacamoleException { + return environment.getProperty(VAULT_TOKEN); } /** - * Returns the OpenBao AppRole secret ID, or null if not configured. + * The authentication Username to use to access the vault. + * + * @return String + * The Hashicorp or OpenBao authentication Username * - * @return The configured AppRole secret ID, or null. * @throws GuacamoleException - * If an error occurs reading the property. + * If the property is not defined in guacamole.properties or + * guacamole.properties can not be parsed. */ - public String getSecretId() throws GuacamoleException { - return environment.getProperty(OpenBaoConfig.OPENBAO_SECRET_ID); + public String getVaultUsername() throws GuacamoleException { + return environment.getProperty(VAULT_USERNAME); } /** - * Returns the OpenBao AppRole auth backend mount path (default: "approle"). + * The authentication Password to use to access the vault. + * + * @return String + * The Hashicorp or OpenBao authentication Password * - * @return The AppRole auth mount path. * @throws GuacamoleException - * If an error occurs reading the property. + * If the property is not defined in guacamole.properties or + * guacamole.properties can not be parsed. */ - public String getAppRolePath() throws GuacamoleException { - return environment.getProperty( - OpenBaoConfig.OPENBAO_APPROLE_PATH, - "approle" - ); + public String getVaultPassword() throws GuacamoleException { + return environment.getProperty(VAULT_PASSWORD); } - /** - * Returns true if AppRole authentication has been configured (both - * role ID and secret ID are present). + * The maximum time that the cached data is considered valid in + * milliseconds. + * + * @return int + * The cache lifetime in milliseconds. * - * @return true if AppRole should be used, false to use a static token. * @throws GuacamoleException - * If an error occurs reading the properties. + * If guacamole.properties can not be parsed. */ - public boolean isAppRoleConfigured() throws GuacamoleException { - String roleId = getRoleId(); - String secretId = getSecretId(); - return roleId != null && !roleId.isEmpty() - && secretId != null && !secretId.isEmpty(); + public int getVaultCacheLifetime() throws GuacamoleException { + return environment.getProperty(VAULT_CACHE_LIFETIME, DEFAULT_CACHE_LIFETIME); } /** - * Returns the OpenBao KV secrets engine mount path. + * The maximum time that a request to the vault server can take in + * milliseconds. + * + * @return int + * The request timeout in milliseconds. * - * @return The mount path (default: "rdp-creds"). * @throws GuacamoleException - * If an error occurs reading the property. + * If guacamole.properties can not be parsed. */ - public String getMountPath() throws GuacamoleException { - return environment.getProperty( - OpenBaoConfig.OPENBAO_MOUNT_PATH, - "rdp-creds" - ); + public int getRequestTimeout() throws GuacamoleException { + return environment.getProperty(VAULT_REQUEST_TIMEOUT, DEFAULT_REQUEST_TIMEOUT); } /** - * Returns the OpenBao KV version. - * Hardcoded to "2" for KV v2 secrets engine. + * The maximum time that a connection to the vault server can take in + * milliseconds. + * + * @return int + * The conenction timeout in milliseconds. * - * @return The KV version "2". + * @throws GuacamoleException + * If guacamole.properties can not be parsed. */ - public String getKvVersion() { - return "2"; + public int getConnectionTimeout() throws GuacamoleException { + return environment.getProperty(VAULT_CONNECTION_TIMEOUT, DEFAULT_CONNECTION_TIMEOUT); } + /** - * Returns the connection timeout in milliseconds. - * Hardcoded to 5000ms (5 seconds). + * The type of SSH certificates are will be generated. Must be either + * 'rsa' of 4096-bit RSA keys or 'ed25519'. + * + * @return String + * The ssh type to use. * - * @return The connection timeout of 5000ms. + * @throws GuacamoleException + * If guacamole.properties can not be parsed. */ - public int getConnectionTimeout() { - return 5000; + 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 certicate types 'rsa' (4096-bit) and 'ed25519' are supported"); + } + return type; } - + /** - * Returns the request timeout in milliseconds. - * Hardcoded to 10000ms (10 seconds). + * The maximum time that a connection to a ssh server can take in + * milliseconds. * - * @return The request timeout of 10000ms. + * @return int + * The ssh conenction timeout in milliseconds. + * + * @throws GuacamoleException + * If guacamole.properties can not be parsed. */ - public int getRequestTimeout() { - return 10000; + public int getSshConnectionTimeout() throws GuacamoleException { + return environment.getProperty(VAULT_SSH_CONNECTION_TIMEOUT, DEFAULT_SSH_CONNECTION_TIMEOUT); } @Override public boolean getSplitWindowsUsernames() throws GuacamoleException { - // Not needed for OpenBao - return false + // Not needed for Hashicorp/OpenBao - return false return false; } @Override public boolean getMatchUserRecordsByDomain() throws GuacamoleException { - // Not needed for OpenBao - return false + // Not needed for Hashicorp/OpenBao - return false return false; } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/FileTokenAuthentication.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/FileTokenAuthentication.java new file mode 100644 index 0000000000..ad89e7049e --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/FileTokenAuthentication.java @@ -0,0 +1,168 @@ +/* + * 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.openbao.secret; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Map; +import org.apache.guacamole.vault.openbao.conf.OpenBaoConfigurationService; +import org.springframework.vault.authentication.AuthenticationSteps; +import org.springframework.vault.authentication.AuthenticationStepsFactory; +import org.springframework.vault.authentication.AuthenticationSteps.HttpRequest; +import static org.springframework.vault.authentication.AuthenticationSteps.HttpRequestBuilder.get; +import org.springframework.vault.authentication.ClientAuthentication; +import org.springframework.vault.authentication.LoginToken; +import org.springframework.vault.client.VaultHttpHeaders; +import org.springframework.vault.support.VaultResponse; +import org.springframework.vault.support.VaultToken; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class FileTokenAuthentication implements ClientAuthentication, AuthenticationStepsFactory { + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(FileTokenAuthentication.class); + + /** + * 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() { + try { + String token = Files.readString(tokenPath).trim(); + return VaultToken.of(token); + } + catch (IOException e) { + throw new IllegalStateException( + "Cannot read Vault token sink: " + tokenPath, e); + } + } + + /** + * Create 'AuthenticationSteps' for token authentication given a VaultToken + * + * @param VaultToken token + * token must not be null. + * + * @param boolean selfLookup + * Set true to perform a self-lookup using the given VaultToken. + * Self-lookup will create a LoginToken and provide renewability + * and TTL + * + * @return AuthenticationSteps + * The AuthenticationSteps for token authentication. + */ + public static AuthenticationSteps createAuthenticationSteps(VaultToken token, boolean selfLookup) { + if (token == null) { + throw new IllegalStateException("VaultToken must not be null"); + } + + if (selfLookup) { + HttpRequest httpRequest = get("auth/token/lookup-self").with(VaultHttpHeaders.from(token)) + .as(VaultResponse.class); + + return AuthenticationSteps.fromHttpRequest(httpRequest) + .login(response -> LoginTokenFrom(token.toCharArray(), response.getRequiredData())); + } + + return AuthenticationSteps.just(token); + } + + /** + * Reimplementation of LoginTokenUtil.from as it is private + * + * @param char[] token + * The token converted to a char[] + * + * @param Map auth + * A map of the authentication response. + * + * @return LoginToken + */ + private static LoginToken LoginTokenFrom(char[] token, Map auth ) { + if (auth == null) { + throw new IllegalStateException("VaultToken must not be null"); + } + + Boolean renewable = (Boolean) auth.get("renewable"); + Number leaseDuration = (Number) auth.get("lease_duration"); + String accessor = (String) auth.get("accessor"); + String type = (String) auth.get("type"); + + if (leaseDuration == null) { + leaseDuration = (Number) auth.get("ttl"); + } + + if (type == null) { + type = (String) auth.get("token_type"); + } + + LoginToken.LoginTokenBuilder builder = LoginToken.builder(); + builder.token(token); + + if (accessor != null && !accessor.trim().isEmpty()) { + builder.accessor(accessor); + } + + if (leaseDuration != null) { + builder.leaseDuration(Duration.ofSeconds(leaseDuration.longValue())); + } + + if (renewable != null) { + builder.renewable(renewable); + } + + if (type != null && !type.trim().isEmpty()) { + builder.type(type); + } + + return builder.build(); + } + + + @Override + public AuthenticationSteps getAuthenticationSteps() { + VaultToken token = login(); + return createAuthenticationSteps(token, false); + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java index ad3eec6f2a..d2b08c5e0e 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java @@ -19,409 +19,723 @@ package org.apache.guacamole.vault.openbao.secret; -import com.google.gson.Gson; -import com.google.gson.JsonObject; -import com.google.gson.JsonSyntaxException; -import com.google.inject.Inject; +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 javax.inject.Inject; +import javax.inject.Singleton; 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.util.Collections; +import java.util.function.Supplier; +import java.util.HashMap; +import java.util.Map; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.GuacamoleServerException; import org.apache.guacamole.vault.openbao.conf.OpenBaoConfigurationService; -import org.apache.hc.client5.http.classic.methods.HttpGet; -import org.apache.hc.client5.http.classic.methods.HttpPost; -import org.apache.hc.client5.http.config.RequestConfig; -import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; -import org.apache.hc.client5.http.impl.classic.HttpClients; -import org.apache.hc.core5.http.ClassicHttpResponse; -import org.apache.hc.core5.http.ContentType; -import org.apache.hc.core5.http.ParseException; -import org.apache.hc.core5.http.io.entity.EntityUtils; -import org.apache.hc.core5.http.io.entity.StringEntity; -import org.apache.hc.core5.util.Timeout; +import org.apache.guacamole.vault.openbao.secret.FileTokenAuthentication; +import org.apache.guacamole.vault.openbao.secret.UsernamePasswordAuthentication; +import org.apache.guacamole.vault.openbao.secret.UsernamePasswordAuthenticationOptions; +import org.apache.guacamole.vault.openbao.secret.TimeoutVaultTemplate; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.vault.authentication.ClientAuthentication; +import org.springframework.vault.authentication.SimpleSessionManager; +import org.springframework.vault.authentication.TokenAuthentication; +import org.springframework.vault.client.VaultEndpoint; +import org.springframework.vault.core.lease.event.SecretLeaseErrorEvent; +import org.springframework.vault.core.lease.event.SecretLeaseExpiredEvent; +import org.springframework.vault.core.lease.SecretLeaseContainer; +import org.springframework.vault.core.VaultKeyValueOperations; +import org.springframework.vault.VaultException; +import org.springframework.vault.support.VaultResponse; +import org.springframework.web.client.RestTemplate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -/** - * Client for communicating with OpenBao REST API. - */ -public class OpenBaoClient { +public class OpenBaoClient { /** * Logger for this class. */ private static final Logger logger = LoggerFactory.getLogger(OpenBaoClient.class); - /** - * Gson instance for JSON parsing. Gson is thread-safe, so a single - * static instance is reused across all calls. - */ - private static final Gson GSON = new Gson(); - /** * Service for retrieving OpenBao configuration. */ @Inject private OpenBaoConfigurationService configService; + + /** + * A singleton ObjectMapper for converting a Map to a JSON string when + * returning a complex token. + */ + private static final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * The prefix of the Guacamole token to resolve on the vault server. + */ + static final String VAULT_TOKEN_PREFIX = "vault://"; /** - * Shared HTTP client. Lazily created on first use and reused for the - * lifetime of this instance; Apache HttpClient 5 is thread-safe and - * designed to be reused across requests. + * Cache of secrets recently fetched */ - private volatile CloseableHttpClient httpClient; + private Cache> cache; /** - * Cached AppRole token. Populated on first successful AppRole login - * and re-used until invalidated (e.g. on a 403 response). + * A cache of the valid mount paths and their type */ - private volatile String cachedAppRoleToken; + private Map mountpaths; /** - * Returns the shared {@link CloseableHttpClient}, creating it on first - * access. Thread-safe double-checked initialization. - * - * @return - * The shared HTTP client instance. + * Vault template that will be used with all of the mount paths */ - private CloseableHttpClient getHttpClient() { - CloseableHttpClient client = httpClient; - if (client == null) { - synchronized (this) { - client = httpClient; - if (client == null) { - client = HttpClients.createDefault(); - httpClient = client; - } - } - } - return client; - } + private TimeoutVaultTemplate vaultTemplate; + + /** + * Vault client authentication object + */ + private ClientAuthentication authentication; /** - * Builds a {@link RequestConfig} from the configured connection and - * request timeouts. - * - * @return - * A request configuration reflecting current timeouts. + * A Vault lease container used to automatically renew AppRole + * authenication tokens. */ - private RequestConfig buildRequestConfig() { - return RequestConfig.custom() - .setConnectionRequestTimeout( - Timeout.ofMilliseconds(configService.getConnectionTimeout())) - .setResponseTimeout( - Timeout.ofMilliseconds(configService.getRequestTimeout())) - .build(); - } + private SecretLeaseContainer leaseContainer; /** - * Validates that the configured server URL, mount path, and auth - * credentials are present and non-empty, throwing a - * {@link GuacamoleServerException} with a clear message otherwise. - * - * @return - * The validated server URL as a string (without trailing slash). - * - * @throws GuacamoleException - * If a required property is missing, empty, or invalid. + * Complete the instantiation of the class after injection of confService */ - private String validatedServerUrl() throws GuacamoleException { + @Inject + public void init() { + try { + VaultEndpoint endpoint = VaultEndpoint.from(configService.getVaultUri()); + + + if (configService.getVaultToken() != null) { + if (isTokenReadableFile(configService.getVaultToken())) { + this.authentication = new FileTokenAuthentication(configService.getVaultToken()); + } + else { + this.authentication = new TokenAuthentication(configService.getVaultToken()); + } + } + else if (configService.getVaultUsername() != null && configService.getVaultPassword() != null) { + UsernamePasswordAuthenticationOptions options = + UsernamePasswordAuthenticationOptions.builder() + .username(configService.getVaultUsername()) + .password(configService.getVaultPassword()) + .build(); + + this.authentication = + new UsernamePasswordAuthentication(options, endpoint, new RestTemplate()); + } + else { + logger.error("Either a vault token or Username/Password must be supplied"); + } - URI serverUri = configService.getServerUrl(); - if (serverUri == null || serverUri.toString().trim().isEmpty()) { - throw new GuacamoleServerException( - "OpenBao server URL (\"openbao-server-url\") is not configured."); + this.vaultTemplate = new TimeoutVaultTemplate(endpoint, + new SimpleClientHttpRequestFactory(), + new SimpleSessionManager(this.authentication)); } - - String mountPath = configService.getMountPath(); - if (mountPath == null || mountPath.trim().isEmpty()) { - throw new GuacamoleServerException( - "OpenBao mount path (\"openbao-mount-path\") must not be empty."); + catch (Exception e) { + logger.error("Error initiatizing Vault client: ", e); } - // Strip trailing slash to keep path concatenation predictable - String url = serverUri.toString(); - if (url.endsWith("/")) - url = url.substring(0, url.length() - 1); + // The access token generated above might have a limited + // lifetime. Setup automatic renewal. + this.leaseContainer = + new SecretLeaseContainer(vaultTemplate); + this.leaseContainer.addLeaseListener(event -> { + if (event instanceof SecretLeaseErrorEvent) { + Throwable cause = ((SecretLeaseErrorEvent) event).getException(); + logger.error("Vault lease error", cause); + } + else if (event instanceof SecretLeaseExpiredEvent) { + SecretLeaseExpiredEvent expired = (SecretLeaseExpiredEvent) event; + logger.warn("Vault lease expired: {}", expired.getSource().getPath()); + } + else { + logger.debug("Vault access token renewed"); + } + }); + this.leaseContainer.start(); + + // 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 elesewhere as String + // values in memory.. A VaultConverter function could deal with the + // spring-vault-core part of the problem, but not Gaucamole. For now just + // ensure that the values are really deleted and let the garbage collector + // do want it can for the passwords in memory. + try { + cache = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofMillis(configService.getVaultCacheLifetime())) + .maximumSize(1_000_000) + .removalListener((String k, Map v, RemovalCause cause) -> { + if (v != null) { + v.clear(); + }}) + .build(); + } + catch (GuacamoleException e) { + logger.error("Can read the cache lifetime value"); + } - return url; + // Cache the valid mount paths on start up + // FIXME a change to the mount paths of the vault server will require a restart + // of Guacamole + mountpaths = listMountPaths(); } - /** - * Resolves a usable OpenBao auth token, either from the configured - * static {@code openbao-token} or, if AppRole is configured, by - * performing an AppRole login. AppRole tokens are cached for the - * lifetime of this client and refreshed only when explicitly - * invalidated via {@link #invalidateCachedToken()}. + /* + * Function to detect is the the tokenis in fact a readable file + * rather than a token string * - * @return - * A non-empty OpenBao auth token. + * @param String token + * The string with the token returned from configService * - * @throws GuacamoleException - * If no valid authentication credentials are configured or if - * AppRole login fails. + * @return boolean + * True is the token is a readable file */ - private String resolveAuthToken() throws GuacamoleException { - - if (configService.isAppRoleConfigured()) { - String token = cachedAppRoleToken; - if (token == null || token.isEmpty()) { - synchronized (this) { - token = cachedAppRoleToken; - if (token == null || token.isEmpty()) { - token = loginWithAppRole(); - cachedAppRoleToken = token; - } - } - } - return token; + private static boolean isTokenReadableFile(String token) { + try { + Path path = Paths.get(token); + return Files.isRegularFile(path) && Files.isReadable(path); } - - String token = configService.getToken(); - if (token == null || token.trim().isEmpty()) { - throw new GuacamoleServerException( - "OpenBao authentication is not configured. Set " - + "\"openbao-token\", or both \"openbao-role-id\" and " - + "\"openbao-secret-id\"."); + catch (Exception e) { + return false; } - - return token; - } + } /** - * Invalidates any cached AppRole token, forcing a fresh login on the - * next request. Called when a 403 indicates the token has expired or - * been revoked. + * Lists the valid mount paths and their type + * + * @return Map + * The key of the map is the mount path and the value the type. + * The type can be ssh, database, kv_1 or kv_2 */ - private synchronized void invalidateCachedToken() { - cachedAppRoleToken = null; + public Map listMountPaths() { + VaultResponse response = vaultTemplate.read("sys/mounts"); + + if (response == null || response.getData() == null) { + return Collections.emptyMap(); + } + + Map mounts = response.getData(); + Map result = new HashMap<>(); + + mounts.forEach((mountPath, mountInfoObj) -> { + if (mountInfoObj instanceof Map) { + Map mountInfo = (Map) mountInfoObj; + Object type = mountInfo.get("type"); + if (type instanceof String) { + if (type == "ssh" || type == "database") { + result.put(mountPath, (String) type); + } + else if (type == "kv") { + // Need to detect if type 1 or type 2 Key/Value engine + if (mountInfo.get("options") instanceof Map) { + Map options = (Map) mountInfo.get("options"); + Object version = options.get("vesion"); + if (version instanceof String) { + if (version == "2") { + result.put(mountPath, "kv_2"); + } + else { + result.put(mountPath, "kv_1"); + } + } + } + else { + // No options, assume kv_1 + result.put(mountPath, "kv_1"); + } + } + } + } + }); + + return result; } /** - * Performs an AppRole login against OpenBao and returns the issued - * client token. + * Retrieves a value from a vault by its path. It first parses the + * leading mount path from the token, ensures it is valid and uses + * a supported secret engine and then passes of the rest of the + * processing to a method dedicaed to each secret engine. + * + * @param token + * The Guacamole token to look up in OpenBao. + * + * @param paramaters + * The connection parameters of the connection. * * @return - * The client token returned by OpenBao. + * The value associated with the key. * * @throws GuacamoleException - * If the login fails or the response cannot be parsed. + * If the secret cannot be retrieved from OpenBao. */ - private String loginWithAppRole() throws GuacamoleException { - - String serverUrl = validatedServerUrl(); - String roleId = configService.getRoleId(); - String secretId = configService.getSecretId(); - String approlePath = configService.getAppRolePath(); + public String getValue(String token, Map parameters) throws GuacamoleException { + try { + logger.info("Fetching secret from OpenBao: {}", token); - String loginUrl = serverUrl + "/v1/auth/" + approlePath + "/login"; - - JsonObject payload = new JsonObject(); - payload.addProperty("role_id", roleId); - payload.addProperty("secret_id", secretId); + if (! token.startsWith(VAULT_TOKEN_PREFIX)) { + throw new GuacamoleException("Invalid token Vault token: " + token); + } - HttpPost httpPost = new HttpPost(loginUrl); - httpPost.setHeader("Accept", "application/json"); - httpPost.setEntity(new StringEntity(payload.toString(), ContentType.APPLICATION_JSON)); - httpPost.setConfig(buildRequestConfig()); + // Before going further replace the arguments "{USERNAME}", "{HOSTNAME}", + // "{GATEWAY_USERNAME}" and "{GATEWAY_HOSTNAME}" in the token with their + // with values supplied in the parameters + // FIXME Could this be done with the TokenFilter in OpenBaoSecretService ? + // 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 username = parameters.get("username"); + if (username != null && !username.isEmpty() + && token.contains("{USER}") && ! token.contains("$${USER}")) { + token.replace("{USER}", username); - logger.info("Authenticating to OpenBao using AppRole at {}", loginUrl); + } + String hostname = parameters.get("hostname"); + if (hostname != null && !hostname.isEmpty() + && token.contains("{SERVER}") && ! token.contains("$${SERVER}")) { + token.replace("{SERVER}", hostname); - try (ClassicHttpResponse response = getHttpClient().executeOpen(null, httpPost, null)) { - int statusCode = response.getCode(); - String responseBody = EntityUtils.toString(response.getEntity()); + } + String gatewayHostname = parameters.get("gateway-hostname"); + if (gatewayHostname != null && !gatewayHostname.isEmpty() + && token.contains("{GATEWAY}") && ! token.contains("$${GATEWAY}")) { + token.replace("{GATEWAY}", gatewayHostname); - if (statusCode != 200) { - throw new GuacamoleServerException( - "OpenBao AppRole login failed (HTTP " + statusCode + "): " - + responseBody); } + String gatewayUsername = parameters.get("gateway-username"); + if (gatewayUsername != null && !gatewayUsername.isEmpty() + && token.contains("{GATEWAY_USER}") && ! token.contains("$${GATEWAY_USER}")) { + token.replace("{GATEWAY_USER}", gatewayUsername); - JsonObject json = GSON.fromJson(responseBody, JsonObject.class); - if (json == null || !json.has("auth")) - throw new GuacamoleServerException( - "OpenBao AppRole login response missing \"auth\" object."); + } - JsonObject auth = json.getAsJsonObject("auth"); - if (!auth.has("client_token")) - throw new GuacamoleServerException( - "OpenBao AppRole login response missing \"auth.client_token\"."); + // Detect and validate the mount path + String mountPath = null; + String type = null; + for (String _path : mountpaths.keySet()) { + if (token.startsWith(_path) && _path.length() > mountPath.length()) { + mountPath = _path; + type = mountpaths.get(_path); + } + } + if (mountPath == null || mountPath.isEmpty()) { + throw new GuacamoleException("The Vault mount path of the token is invalid: " + token); + } - return auth.get("client_token").getAsString(); + // Find last slash to isolate the secret value in the record + int lastSlashIndex = token.lastIndexOf('/'); + if (lastSlashIndex == -1) + lastSlashIndex = VAULT_TOKEN_PREFIX.length(); + + String path = token.substring(VAULT_TOKEN_PREFIX.length() + mountPath.length(), lastSlashIndex); + String secret = token.substring(lastSlashIndex + 1); + + switch (type) { + case "ssh": + return getValueSSH(mountPath, path, secret, parameters); + case "ldap": + return getValueLDAP(mountPath, path, secret); + case "database": + return getValueDB(mountPath, path, secret); + case "kv_1": + case "kv_2": + return getValueKV(mountPath, path, secret, type); + default: + throw new GuacamoleException("Unknown secret engine for the token: " + token); + } } - catch (IOException | ParseException | JsonSyntaxException e) { - throw new GuacamoleServerException( - "Failed to communicate with OpenBao during AppRole login", e); + catch (VaultException e) { + throw new GuacamoleServerException("Failed to retrieve secret from Vault Server", e); } } /** - * Retrieves the secret at the given path, relative to the configured - * mount. For a KV v2 engine this resolves to - * {@code /v1//data/}; for KV v1 it resolves to - * {@code /v1//}. + * Retrieves a value from a key-value secret engine of a vault. + * + * @param mountPath + * The mountPath of teh key-value secret engine on teh vaut server. * - * @param secretPath - * The path (relative to the mount) of the secret to retrieve. + * @param path + * The path of the secret record + * + * @param secret + * The secret value to return + * + * @param type + * The type of key-value store. Either "kv_1" or "kv_2" * * @return - * The parsed JSON response from OpenBao. + * The value associated with the secret. * * @throws GuacamoleException * If the secret cannot be retrieved from OpenBao. */ - public JsonObject getSecret(String secretPath) throws GuacamoleException { - - if (secretPath == null || secretPath.trim().isEmpty()) { - throw new GuacamoleServerException( - "OpenBao secret path must not be empty."); + private String getValueKV(String mountPath, String path, String secret, String type) throws GuacamoleException { + // Is the key-value already in the cache + Map cacheResponse = cache.getIfPresent(mountPath + "/" + path); + + if (cacheResponse != null) { + // The path is already cached?. Use it + if (cacheResponse.get(secret) instanceof String) { + return (String) cacheResponse.get(secret); + } + else { + try { + // Stored JSON value.. Probably not usable, but return as a string + return objectMapper.writeValueAsString(cacheResponse.get(secret)); + } + catch (JsonProcessingException e) { + throw new GuacamoleException("Error json parsing returned secret: ", e); + } + } } - String serverUrl = validatedServerUrl(); - String mountPath = configService.getMountPath(); - String kvVersion = configService.getKvVersion(); - - // Strip any leading slash so URL concatenation stays predictable - String normalizedPath = secretPath.startsWith("/") - ? secretPath.substring(1) : secretPath; - - String apiPath; - if ("2".equals(kvVersion)) - apiPath = String.format("/v1/%s/data/%s", mountPath, normalizedPath); - else - apiPath = String.format("/v1/%s/%s", mountPath, normalizedPath); - - String fullUrl = serverUrl + apiPath; - logger.debug("Fetching secret from OpenBao: {}", fullUrl); + VaultKeyValueOperations kvOperations; + if ("kv_2".equals(type)) { + kvOperations = vaultTemplate.opsForKeyValue( + mountPath, + VaultKeyValueOperations.KeyValueBackend.KV_2); + } + else { + kvOperations = vaultTemplate.opsForKeyValue( + mountPath, + VaultKeyValueOperations.KeyValueBackend.KV_1); + } - JsonObject result = executeGet(fullUrl, resolveAuthToken()); - if (result != null) - return result; + // Get the values on the path and cache them + VaultResponse response = kvOperations.get(path); + cache.put(mountPath + "/" + path, response.getData()); - // A null result from executeGet means we got a 403 with an - // AppRole token; retry once with a freshly-issued token. - if (configService.isAppRoleConfigured()) { - invalidateCachedToken(); - logger.info("OpenBao AppRole token may have expired; retrying with a fresh token."); - JsonObject retry = executeGet(fullUrl, resolveAuthToken()); - if (retry != null) - return retry; + if (response == null) { + throw new GuacamoleServerException( + "Value not found in OpenBao for path: " + mountPath + "/" + path); } - throw new GuacamoleServerException( - "Permission denied accessing OpenBao. Check token permissions."); + if (response.getData().get(secret) instanceof String) { + return (String) response.getData().get(secret); + } + else { + try { + // Stored JSON value.. Probably not usable, but return as a string + return objectMapper.writeValueAsString(response.getData().get(secret)); + } + catch (JsonProcessingException e) { + throw new GuacamoleException("Error json parsing returned secret: ", e); + } + } } /** - * Issues a GET against {@code fullUrl} authenticated with the given - * token. Returns the parsed JSON response on success, or {@code null} - * if the response was a 403 (caller may choose to refresh credentials - * and retry). + * Retrieves a an ssh one-time password or signed SSH certificate * - * @param fullUrl - * The fully-qualified URL to GET. + * @param mountPath + * The mountPath of the SSH secret engine on the vault server. * - * @param token - * The OpenBao auth token to present via {@code X-Vault-Token}. + * @param path + * The path of the secret record representing the SSH role + * + * @param secret + * The secret value to return + * + * @param parameters + * The connection parameters of the connection * * @return - * The parsed JSON response, or {@code null} on 403. + * The value associated with the secret. * * @throws GuacamoleException - * On non-200, non-403 responses or communication failures. + * If the secret cannot be retrieved from OpenBao. */ - private JsonObject executeGet(String fullUrl, String token) - throws GuacamoleException { + private String getValueSSH(String mountPath, String path, String secret, Map parameters) throws GuacamoleException { + // Is the key-value already in the cache + Map cacheResponse = (Map) cache.getIfPresent(mountPath + "/" + path); + + if (cacheResponse != null) { + String retval; + // The path is already cached?. Use it + if (cacheResponse.get(secret) instanceof String) { + retval = (String) cacheResponse.get(secret); + } + else { + try { + // Stored JSON value.. Probably not usable, but return as a string + return objectMapper.writeValueAsString(cacheResponse.get(secret)); + } + catch (JsonProcessingException e) { + throw new GuacamoleException("Error json parsing returned secret: ", e); + } + } + + if (retval != null || retval == "") { + throw new GuacamoleException("SSH secret '" + secret + "' not found on the path : " + mountPath +"/" + path); + } + return retval; + } + + String username = parameters.get("username"); + if (username == null || username.isEmpty()) { + throw new GuacamoleException("The username can not be empty for SSH connections"); + } - HttpGet httpGet = new HttpGet(fullUrl); - httpGet.setHeader("X-Vault-Token", token); - httpGet.setHeader("Accept", "application/json"); - httpGet.setConfig(buildRequestConfig()); + // Detect to wanting one-time password or sigend certificate + if (path.startsWith("otp/")) { + String hostname = parameters.get("hostname"); + if (hostname == null || hostname.isEmpty()) { + throw new GuacamoleException("The hostname can not be empty for SSH connections with one-time passwords"); + } + + Map request = Map.of( + "username", username, + "ip", hostname + ); - try (ClassicHttpResponse response = getHttpClient().executeOpen(null, httpGet, null)) { - int statusCode = response.getCode(); - String responseBody = EntityUtils.toString(response.getEntity()); + VaultResponse response = + vaultTemplate.write(mountPath + "/creds/" + path.substring(4), request); - if (statusCode == 200) { - logger.debug("OpenBao response 200 for {}", fullUrl); - return GSON.fromJson(responseBody, JsonObject.class); + if (response == null || response.getData() == null) { + throw new GuacamoleException("No response from Vault SSH engine"); } - if (statusCode == 404) { - throw new GuacamoleServerException( - "Secret not found in OpenBao at: " + fullUrl); + String password = (String) response.getData().get("key"); + String user = (String) response.getData().get("username"); + + if (password == null || user == null) { + throw new GuacamoleException("Invalid SSH OTP response"); } - if (statusCode == 403) { - // Signal to caller so it can retry after refreshing auth. - return null; + // Cache the retrieved user and otp + cache.put(mountPath + "/" + path, Map.of("username", user, "password", password)); + + if (secret == "username") { + return user; + } + else if (secret == "password") { + return password; + } + } + else if (path.startsWith("cert/")) { + OpenBaoSshKeys sshKeys = new OpenBaoSshKeys(configService.getSshType()); + + Map request = Map.of( + "public_key", sshKeys.publicSsh, + "valid_principals", username, + "extensions", Map.of( + "permit-port-forwarding", false, + "permit-agent-forwarding", false, + "permit-x11-forwarding", false + ), + "ttl", configService.getSshConnectionTimeout() + ); + + VaultResponse vaultResponse = + vaultTemplate.write(mountPath + "/sign/" + path.substring(5), request); + + if (vaultResponse == null || vaultResponse.getData() == null) { + throw new GuacamoleException("No response from Vault SSH engine"); } - throw new GuacamoleServerException( - "OpenBao error (HTTP " + statusCode + "): " + responseBody); + String signedCert = (String) vaultResponse.getData().get("signed_key"); + + if (signedCert == null) { + throw new GuacamoleException("Vault did not return a signed SSH certificate"); + } + + cache.put(mountPath + "/" + path, Map.of( + "private", sshKeys.privateSshPem, + "public", signedCert)); + + if (secret == "private") { + return sshKeys.privateSshPem; + } + else if (secret == "public") { + return signedCert; + } } - catch (IOException | ParseException | JsonSyntaxException e) { - logger.error("Failed to communicate with OpenBao at {}: {}", - fullUrl, e.getMessage()); - throw new GuacamoleServerException( - "Failed to communicate with OpenBao", e); + else { + throw new GuacamoleException("Unknown SSH type on path: " + mountPath +"/" + path); } + throw new GuacamoleException("SSH secret '" + secret + "' not found on the path : " + mountPath +"/" + path); } /** - * Extracts the {@code password} field from an OpenBao secret response. + * 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 response - * The JSON response previously returned by {@link #getSecret(String)}. + * @param path + * The path of the secret record representing the LDAP role + * + * @param secret + * The secret value to return * * @return - * The password string, or null if not present. + * The value associated with the secret. + * + * @throws GuacamoleException + * If the secret cannot be retrieved from OpenBao. */ - public String extractPassword(JsonObject response) { - return extractField(response, "password"); + private String getValueLDAP(String mountPath, String path, String secret) throws GuacamoleException { + if (secret != "username" && secret != "password") { + throw new GuacamoleException("LDAP secret '" + secret + "' not found on the path : " + mountPath +"/" + path); + } + + // Is the key-value already in the cache + Map cacheResponse = (Map) cache.getIfPresent(mountPath + "/" + path); + + if (cacheResponse != null) { + String retval; + // The path is already cached?. Use it + if (cacheResponse.get(secret) instanceof String) { + retval = (String) cacheResponse.get(secret); + } + else { + try { + // Stored JSON value.. Probably not usable, but return as a string + return objectMapper.writeValueAsString(cacheResponse.get(secret)); + } + catch (JsonProcessingException e) { + throw new GuacamoleException("Error json parsing returned secret: ", e); + } + } + + if (retval == null || retval == "") { + throw new GuacamoleException("SSH secret '" + secret + "' not found on the path : " + mountPath +"/" + path); + } + return retval; + } + + VaultResponse response; + if (path.startsWith("static/")) { + response = vaultTemplate.read(mountPath + "/static-creds/" + path.substring(7)); + } + else if (path.startsWith("dynamic/")) { + response = vaultTemplate.read(mountPath + "/creds/" + path.substring(8)); + } + else if (path.startsWith("service/")) { + response = vaultTemplate.read(mountPath + "/" + path.substring(8) + "/check-out"); + } + else { + throw new GuacamoleException("Unknown LDAP type on path: " + mountPath +"/" + path); + } + + if (response == null || response.getData() == null) { + throw new GuacamoleException("No response from LDAP secrets engine"); + } + + String username; + String password = (String) response.getData().get("password"); + if (path.startsWith("service/")) { + username = (String) response.getData().get("service_account_name"); + } + else { + username = (String) response.getData().get("username"); + } + + if (username == null || password == null) { + throw new IllegalStateException("Invalid LDAP static credential response"); + } + + // Cache the retrieved user and password + cache.put(mountPath + "/" + path, Map.of("username", username, "password", password)); + + if (secret == "username") { + return username; + } + return password; } /** - * Extracts an arbitrary string field from an OpenBao secret response. - * Supports both KV v2 ({@code data.data.}) and KV v1 - * ({@code data.}) layouts. + * Retrieves a username or password from a Databse secret engine. * - * @param response - * The JSON response previously returned by {@link #getSecret(String)}. + * @param mountPath + * The mountPath of the database secret engine on the vault server. * - * @param fieldName - * The name of the field to extract from the secret's data object. + * @param path + * The path of the secret record representing the LDAP role + * + * @param secret + * The secret value to return * * @return - * The field value, or null if not present or not a string. + * The value associated with the secret. + * + * @throws GuacamoleException + * If the secret cannot be retrieved from OpenBao. */ - public String extractField(JsonObject response, String fieldName) { + private String getValueDB(String mountPath, String path, String secret) throws GuacamoleException { + if (secret != "username" && secret != "password") { + throw new GuacamoleException("Database secret '" + secret + "' not found on the path : " + mountPath +"/" + path); + } - if (response == null || fieldName == null) - return null; + // Is the key-value already in the cache + Map cacheResponse = (Map) cache.getIfPresent(mountPath + "/" + path); - if (!response.has("data")) - return null; + if (cacheResponse != null) { + String retval; + // The path is already cached?. Use it + if (cacheResponse.get(secret) instanceof String) { + retval = (String) cacheResponse.get(secret); + } + else { + try { + // Stored JSON value.. Probably not usable, but return as a string + return objectMapper.writeValueAsString(cacheResponse.get(secret)); + } + catch (JsonProcessingException e) { + throw new GuacamoleException("Error json parsing returned secret: ", e); + } + } + + if (retval == null || retval.isEmpty()) { + throw new GuacamoleException("SSH secret '" + secret + "' not found on the path : " + mountPath +"/" + path); + } + return retval; + } + + VaultResponse response = vaultTemplate.read(mountPath + "/creds/" + path); - JsonObject data = response.getAsJsonObject("data"); + if (response == null || response.getData() == null) { + throw new GuacamoleException("No response from Databse secrets engine"); + } - // KV v2 nests the user-supplied data under an inner "data" object. - JsonObject values; - if (data.has("data") && data.get("data").isJsonObject()) - values = data.getAsJsonObject("data"); - else - values = data; + String username = (String) response.getData().get("username"); + String password = (String) response.getData().get("password"); - if (!values.has(fieldName)) { - logger.debug("Field \"{}\" not found in OpenBao secret", fieldName); - return null; + if (username == null || password == null) { + throw new IllegalStateException("Invalid LDAP static credential response"); } - if (!values.get(fieldName).isJsonPrimitive()) { - logger.debug("Field \"{}\" in OpenBao secret is not a primitive", fieldName); - return null; + // Cache the retrieved user and password + cache.put(mountPath + "/" + path, Map.of("username", username, "password", password)); + + if (secret == "username") { + return username; } + return password; + } - return values.get(fieldName).getAsString(); + /** + * Release the automatic renewal of the AppRole token on shutown + */ + public void shutdown() { + leaseContainer.stop(); } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java index 6024c7cb09..61b9e4e163 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java @@ -19,28 +19,28 @@ package org.apache.guacamole.vault.openbao.secret; -import com.google.gson.JsonObject; import com.google.inject.Inject; -import java.util.HashMap; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; +import java.util.HashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.net.auth.Connectable; import org.apache.guacamole.net.auth.UserContext; import org.apache.guacamole.protocol.GuacamoleConfiguration; import org.apache.guacamole.token.TokenFilter; +import org.apache.guacamole.vault.openbao.secret.OpenBaoClient; import org.apache.guacamole.vault.secret.VaultSecretService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** - * OpenBao implementation of VaultSecretService. Resolves the legacy - * {@code ${OPENBAO_SECRET}} and {@code ${GUAC_USERNAME}} tokens as well - * as an arbitrary-path syntax of the form - * {@code openbao:[:]}, which, when supplied as a secret - * name via the YAML token mapping, retrieves the given field from the - * named secret under the configured mount. + * OpenBao implementation of VaultSecretService. + * Retrieves secrets from OpenBao based on parameters of the logged-in user. */ public class OpenBaoSecretService implements VaultSecretService { @@ -49,28 +49,6 @@ public class OpenBaoSecretService implements VaultSecretService { */ private static final Logger logger = LoggerFactory.getLogger(OpenBaoSecretService.class); - /** - * Prefix identifying the arbitrary-path secret name syntax: - * {@code openbao:[:]}. - */ - private static final String OPENBAO_PREFIX = "openbao:"; - - /** - * The secret name used for the legacy per-user password lookup. - */ - private static final String OPENBAO_SECRET_NAME = "OPENBAO_SECRET"; - - /** - * The secret name used to resolve to the logged-in Guacamole username. - */ - private static final String GUAC_USERNAME_NAME = "GUAC_USERNAME"; - - /** - * The default field fetched from an OpenBao secret when no explicit - * {@code :field} suffix is supplied. - */ - private static final String DEFAULT_FIELD = "password"; - /** * Client for communicating with OpenBao. */ @@ -85,148 +63,114 @@ public OpenBaoSecretService() { } @Override - public String canonicalize(String token) { - - if (token == null) - return null; - - // Existing names (including their ${...} form) are passed through. - if (OPENBAO_SECRET_NAME.equals(token) || ("${" + OPENBAO_SECRET_NAME + "}").equals(token)) - return OPENBAO_SECRET_NAME; - - if (GUAC_USERNAME_NAME.equals(token) || ("${" + GUAC_USERNAME_NAME + "}").equals(token)) - return GUAC_USERNAME_NAME; + public String canonicalize(String nameComponent) { + try { - // Arbitrary-path form: let it flow through unchanged so getValue() - // can parse it. - if (token.startsWith(OPENBAO_PREFIX)) - return token; + // As HV notation is essentially a URL, encode all components + // using standard URL escaping + return URLEncoder.encode(nameComponent, "UTF-8"); - return null; + } + catch (UnsupportedEncodingException e) { + throw new UnsupportedOperationException("Unexpected lack of UTF-8 support.", e); + } } + /** + * 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 name + * 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 token) throws GuacamoleException { - // Without user context we cannot resolve per-user lookups. - logger.warn("getValue(String) called without user context - cannot determine username"); - return CompletableFuture.completedFuture(null); + // Empty parameters in this context + Map parameters = Map.of("username", "", + "hostname", "", + "gateway-username", "", + "gateway-hostname", ""); + String value = openBaoClient.getValue(token, parameters); + return CompletableFuture.completedFuture(value); } @Override public Future getValue(UserContext userContext, Connectable connectable, String token) throws GuacamoleException { - - if (token == null) - return CompletableFuture.completedFuture(null); - - String username = userContext.self().getIdentifier(); - - // Legacy: ${GUAC_USERNAME} → username - if (GUAC_USERNAME_NAME.equals(token)) - return CompletableFuture.completedFuture(username); - - // Legacy: ${OPENBAO_SECRET} → password from /data/ - if (OPENBAO_SECRET_NAME.equals(token)) - return CompletableFuture.completedFuture( - fetchField(username, DEFAULT_FIELD, username)); - - // Additive: openbao:[:] - if (token.startsWith(OPENBAO_PREFIX)) { - String spec = token.substring(OPENBAO_PREFIX.length()); - int sep = spec.lastIndexOf(':'); - - String path; - String field; - if (sep > 0 && sep < spec.length() - 1) { - path = spec.substring(0, sep); - field = spec.substring(sep + 1); - } - else { - path = spec; - field = DEFAULT_FIELD; - } - - if (path.isEmpty()) { - logger.warn("Empty path supplied for token: {}", token); - return CompletableFuture.completedFuture(null); - } - - return CompletableFuture.completedFuture(fetchField(path, field, username)); - } - - logger.debug("Token \"{}\" not recognized by OpenBao secret service", token); - return CompletableFuture.completedFuture(null); + // Empty parameters in this context + Map parameters = Map.of("username", "", + "hostname", "", + "gateway-username", "", + "gateway-hostname", ""); + String value = openBaoClient.getValue(token, parameters); + return CompletableFuture.completedFuture(value); } - /** - * Retrieves {@code field} from the OpenBao secret at {@code path}, - * logging the supplied {@code contextLabel} for diagnostics. Returns - * null rather than throwing when retrieval fails, to preserve the - * existing behavior of allowing Guacamole to proceed (possibly with - * an empty credential). + /* + * 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 path - * Path of the secret to fetch, relative to the configured mount. + * @param connectable + * The connection or connection group for which the tokens are being replaced. * - * @param field - * Name of the field to extract. + * @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 contextLabel - * Identifier used only for log messages. + * @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 - * The field value, or null on failure or if the field is absent. + * 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. */ - private String fetchField(String path, String field, String contextLabel) { - try { - JsonObject response = openBaoClient.getSecret(path); - String value = openBaoClient.extractField(response, field); - - if (value == null) - logger.warn("Field \"{}\" not found in OpenBao secret at \"{}\" (context: {})", - field, path, contextLabel); - - return value; - } - catch (GuacamoleException e) { - logger.error("Failed to retrieve secret \"{}\" from OpenBao (context: {}): {}", - path, contextLabel, e.getMessage()); - logger.debug("Underlying exception:", e); - return null; - } - } - @Override public Map> getTokens(UserContext userContext, - Connectable connectable, - GuacamoleConfiguration config, - TokenFilter tokenFilter) throws GuacamoleException { + Connectable connectable, GuacamoleConfiguration config, + TokenFilter filter) throws GuacamoleException { Map> tokens = new HashMap<>(); - String username = userContext.self().getIdentifier(); + Map parameters = config.getParameters(); - // GUAC_USERNAME is always available. - tokens.put(GUAC_USERNAME_NAME, CompletableFuture.completedFuture(username)); + Pattern tokenPattern = Pattern.compile("\\$\\{(" + OpenBaoClient.VAULT_TOKEN_PREFIX + ".+)\\}"); - // Best-effort pre-population of OPENBAO_SECRET from the per-user - // secret at /data/. Missing/unreachable secrets - // are logged but do not abort token resolution. - try { - JsonObject response = openBaoClient.getSecret(username); - String password = openBaoClient.extractPassword(response); - if (password != null) - tokens.put(OPENBAO_SECRET_NAME, - CompletableFuture.completedFuture(password)); - else - logger.warn("Password not found in OpenBao for user: {}", username); - } - catch (GuacamoleException e) { - logger.warn("Failed to pre-populate OPENBAO_SECRET for user {}: {}", - username, e.getMessage()); - logger.debug("Underlying exception:", e); + for (Map.Entry entry : parameters.entrySet()) { + Matcher tokenMatcher = tokenPattern.matcher(entry.getValue()); + while (tokenMatcher.find()) { + String token = tokenMatcher.group(1); + String value = openBaoClient.getValue(token, parameters); + tokens.put(token, CompletableFuture.completedFuture(value)); + } } - logger.debug("Returning {} OpenBao tokens: {}", tokens.size(), tokens.keySet()); return tokens; } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSshKeys.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSshKeys.java new file mode 100644 index 0000000000..639f69891a --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSshKeys.java @@ -0,0 +1,262 @@ +/* + * 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.openbao.secret; + +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.crypto.AsymmetricCipherKeyPair; +import org.bouncycastle.crypto.generators.RSAKeyPairGenerator; +import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; +import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; +import org.bouncycastle.crypto.params.RSAKeyGenerationParameters; +import org.bouncycastle.crypto.params.RSAPrivateCrtKeyParameters; +import org.bouncycastle.crypto.params.RSAKeyParameters; +import org.bouncycastle.crypto.util.PrivateKeyInfoFactory; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; +import java.io.ByteArrayOutputStream; +import java.io.StringWriter; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Map; + +public class OpenBaoSshKeys { + + /** + * The PEM encoded private SSH key + */ + public String privateSshPem; + + /** + * The OpenSSH encoded public SSH key + */ + public String publicSsh; + + /** + * Class instantiation to return generated SSH keys + */ + public OpenBaoSshKeys(String type) { + if (type == "rsa") { + RSAKeyPairGenerator generator = new RSAKeyPairGenerator(); + RSAKeyGenerationParameters params = + new RSAKeyGenerationParameters( + BigInteger.valueOf(0x10001), // public exponent (65537) + new SecureRandom(), + 4096, // key size + 80 // certainty + ); + generator.init(params); + AsymmetricCipherKeyPair keyPair = generator.generateKeyPair(); + publicSsh = toOpenSshPublicKey((RSAKeyParameters) keyPair.getPublic()); + privateSshPem = toOpenSshPrivateKey((RSAPrivateCrtKeyParameters) keyPair.getPrivate(), + (RSAKeyParameters) keyPair.getPublic(), null); + } + else { + SecureRandom random = new SecureRandom(); + + Ed25519PrivateKeyParameters privateKey = + new Ed25519PrivateKeyParameters(random); + + Ed25519PublicKeyParameters publicKey = + privateKey.generatePublicKey(); + + publicSsh = toOpenSshPublicKey(publicKey); + privateSshPem = toOpenSshPrivateKey(privateKey); + } + } + + /** + * Class instantiation to return generated SSH keys. Default type + */ + public OpenBaoSshKeys() { + this("ed25519"); + } + + /** + * Generate OpenSSH encode ED25519 public key + * + * @param Ed25519PublicKeyParameters pubicKey + * The public key generated by BouncyCastle + * + * @return String + * The OpenSSH encoded ED25519 public key + */ + private static String toOpenSshPublicKey(Ed25519PublicKeyParameters publicKey) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + writeBytes(out, "ssh-ed25519".getBytes(StandardCharsets.US_ASCII)); + writeBytes(out, publicKey.getEncoded()); + + String encoded = Base64.getEncoder().encodeToString(out.toByteArray()); + return "ssh-ed25519 " + encoded; + } + + /** + * Generate OpenSSH encode RSA public key + * + * @param RSAKeyParameters publicKey + * The public key generated by BouncyCastle + * + * @return String + * The OpenSSH encoded ED25519 public key + */ + private static String toOpenSshPublicKey(RSAKeyParameters publicKey) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + writeBytes(out, "ssh-rsa".getBytes(StandardCharsets.US_ASCII)); + writeBytes(out, publicKey.getExponent().toByteArray()); + writeBytes(out, publicKey.getModulus().toByteArray()); + String encoded = Base64.getEncoder().encodeToString(out.toByteArray()); + + return "ssh-rsa " + encoded; + } + + /** + * Generate OpenSSH encode ed25519 private key + * + * @param Ed25519PrivateKeyParameters privateKey) + * The private key generated by BouncyCastle + * + * @return String + * The OpenSSH encoded ED25519 private key + */ + private static String toOpenSshPrivateKey(Ed25519PrivateKeyParameters privateKey) { + byte[] raw = privateKey.getEncoded(); + String base64 = Base64.getEncoder().encodeToString(raw); + + return String.format( + "-----BEGIN OPENSSH PRIVATE KEY-----%n%s%n" + + " -----END OPENSSH PRIVATE KEY-----%n", base64); + } + + /** + * Guacamole needs private key in OpenSSH format, which is officially undocumented, + * so this is an ugly function to write RSA private keys in OpenSSH format + * + * @param RSAPrivateCrtKeyParameter privateKey + * The RSA private key generated by BouncyCastle + * + * @param RSAKeyParameters publicKey + * The RSA public key generated by BouncyCastle + * + * @param String comment + * An arbitrary comment that can be added to the private key + * + * @return String + * The OpenSSH encoded RSA private key + */ + private static String toOpenSshPrivateKey( + RSAPrivateCrtKeyParameters privateKey, + RSAKeyParameters publicKey, + String comment) { + + try { + SecureRandom rnd = new SecureRandom(); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + + // header + writeBytes(out, "openssh-key-v1\0".getBytes(StandardCharsets.UTF_8)); + writeBytes(out, "none".getBytes(StandardCharsets.UTF_8)); // cipher + writeBytes(out, "none".getBytes(StandardCharsets.UTF_8)); // kdf + writeBytes(out, new byte[0]); // kdf options + writeInt(out, 1); // number of keys + + // write public key blob + ByteArrayOutputStream pubOut = new ByteArrayOutputStream(); + writeBytes(pubOut, "ssh-rsa".getBytes(StandardCharsets.UTF_8)); + writeBytes(pubOut, publicKey.getExponent().toByteArray()); + writeBytes(pubOut, publicKey.getModulus().toByteArray()); + writeBytes(out, pubOut.toByteArray()); + + // private key block + ByteArrayOutputStream privOut = new ByteArrayOutputStream(); + + int check = rnd.nextInt(); + writeInt(privOut, check); + writeInt(privOut, check); + + writeBytes(privOut, "ssh-rsa".getBytes(StandardCharsets.UTF_8)); + writeBytes(privOut, publicKey.getModulus().toByteArray()); + writeBytes(privOut, publicKey.getExponent().toByteArray()); + writeBytes(privOut, privateKey.getExponent().toByteArray()); + writeBytes(privOut, privateKey.getQInv().toByteArray()); + writeBytes(privOut, privateKey.getP().toByteArray()); + writeBytes(privOut, privateKey.getQ().toByteArray()); + writeBytes(privOut, + (comment == null ? "" : comment).getBytes(StandardCharsets.UTF_8)); + + pad(privOut); + + writeBytes(out, privOut.toByteArray()); + + String base64 = + Base64.getMimeEncoder(70, new byte[]{'\n'}) + .encodeToString(out.toByteArray()); + + return String.format( + "-----BEGIN OPENSSH PRIVATE KEY-----%n%s%n" + + " -----END OPENSSH PRIVATE KEY-----%n", base64); + + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + /** + * Pad output stream to 8 byte boundary + * + * @param ByteArrAyOutputStream + * The byte stream to be padded + */ + private static void pad(ByteArrayOutputStream out) { + int padLen = 8 - (out.size() % 8); + for (int i = 1; i <= padLen; i++) { + out.write(i); + } + } + + /** + * Helper function toan int in OpenSSH key format + * + * @param ByteArrayOutputStream + * The stream on which to write the result + * + * @param int + * The int to convert and write to the stream + */ + private static void writeInt(ByteArrayOutputStream out, int v) { + out.write((v >>> 24) & 0xff); + out.write((v >>> 16) & 0xff); + out.write((v >>> 8) & 0xff); + out.write(v & 0xff); + } + + /** + * Helper function to write bytes to OpenSSH key format + * + * @param ByteArrayOutputStream + * The stream on which to write the result + * + * @param byte[] + * The byte array to convert and write to the stream + */ + private static void writeBytes(ByteArrayOutputStream out, byte[] b) { + writeInt(out, b.length); + out.write(b, 0, b.length); + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/TimeoutVaultTemplate.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/TimeoutVaultTemplate.java new file mode 100644 index 0000000000..534ed94e54 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/TimeoutVaultTemplate.java @@ -0,0 +1,87 @@ +/* + * 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.openbao.secret; + +import javax.inject.Inject; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.vault.openbao.conf.OpenBaoConfigurationService; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.vault.client.VaultEndpoint; +import org.springframework.vault.client.VaultEndpointProvider; +import org.springframework.vault.core.VaultTemplate; +import org.springframework.vault.authentication.SessionManager; +import org.springframework.web.client.RestTemplate; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The point of this class is to override the doCreateRestTemplate method + * when creating a VaultTemplate and allow a request and connection + * timeout to be added to the HttpClient. + */ + +public class TimeoutVaultTemplate extends VaultTemplate { + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(TimeoutVaultTemplate.class); + + /** + * Service for retrieving OpenBao configuration. + */ + @Inject + private OpenBaoConfigurationService configService; + + + public TimeoutVaultTemplate(VaultEndpoint endpoint, + ClientHttpRequestFactory requestFactory, + SessionManager session) { + super(endpoint, requestFactory, session); + } + + @Override + protected RestTemplate doCreateRestTemplate(VaultEndpointProvider endpointProvider, + ClientHttpRequestFactory requestFactory) { + int connectionTimeout; + int requestTimeout; + try { + connectionTimeout = configService.getConnectionTimeout(); + } + catch (GuacamoleException e) { + connectionTimeout = OpenBaoConfigurationService.DEFAULT_CONNECTION_TIMEOUT; + } + try { + requestTimeout = configService.getRequestTimeout(); + } + catch (GuacamoleException e) { + requestTimeout = OpenBaoConfigurationService.DEFAULT_REQUEST_TIMEOUT; + } + if (requestFactory instanceof SimpleClientHttpRequestFactory) { + logger.debug("Setting http request and connection timeouts"); + ((SimpleClientHttpRequestFactory) requestFactory) + .setConnectTimeout(connectionTimeout); + ((SimpleClientHttpRequestFactory) requestFactory) + .setReadTimeout(requestTimeout); + } + + return super.doCreateRestTemplate(endpointProvider, requestFactory); + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/UsernamePasswordAuthentication.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/UsernamePasswordAuthentication.java new file mode 100644 index 0000000000..2bffad0022 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/UsernamePasswordAuthentication.java @@ -0,0 +1,87 @@ +/* + * 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 SecretLeaseContainer + +package org.apache.guacamole.vault.openbao.secret; + +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.VaultException; +import org.springframework.vault.client.VaultEndpoint; +import org.springframework.vault.support.VaultToken; +import org.springframework.vault.support.VaultResponse; +import org.springframework.web.client.RestTemplate; + +public class UsernamePasswordAuthentication implements ClientAuthentication { + + private final UsernamePasswordAuthenticationOptions options; + private final RestTemplate restTemplate; + private final VaultEndpoint endpoint; + + 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; + } + + @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"); + + if (token == null) { + throw new VaultException("No client_token in Vault auth response"); + } + + return VaultToken.of(token); + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/UsernamePasswordAuthenticationOptions.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/UsernamePasswordAuthenticationOptions.java new file mode 100644 index 0000000000..03fe267a7f --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/UsernamePasswordAuthenticationOptions.java @@ -0,0 +1,85 @@ +/* + * 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.openbao.secret; + +import org.springframework.util.Assert; + +public final class UsernamePasswordAuthenticationOptions { + + private final String username; + private final String password; + private final String mountPath; + + 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); + } + } +} From 5944a7020d19c0d1696f561dff83f10307a4b444 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Fri, 24 Apr 2026 17:52:43 +0200 Subject: [PATCH 04/16] GUACAMOLE-2196: Use the OpenBaoConfigurationService defaults everywhere rather than hard code values. Typos --- .../conf/OpenBaoConfigurationService.java | 8 +++---- .../vault/openbao/secret/OpenBaoClient.java | 24 +++++++++---------- .../openbao/secret/OpenBaoSecretService.java | 2 +- .../vault/openbao/secret/OpenBaoSshKeys.java | 5 ++-- 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfigurationService.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfigurationService.java index 9d2e40fc4f..3743e277c3 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfigurationService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfigurationService.java @@ -51,12 +51,12 @@ public class OpenBaoConfigurationService extends VaultConfigurationService { /** * The default ssh connection tiemout in milliseconds. */ - private static final int DEFAULT_SSH_CONNECTION_TIMEOUT = 10000; + public static final int DEFAULT_SSH_CONNECTION_TIMEOUT = 10000; /** * The default ssh certificate type */ - private static final String DEFAULT_SSH_TYPE = "ed25519"; + public static final String DEFAULT_SSH_TYPE = "ed25519"; /** * The name of the file which contains the YAML mapping of connection @@ -141,7 +141,7 @@ public class OpenBaoConfigurationService extends VaultConfigurationService { }; /** - * The maximum time that a connection to the vault server can take in ms. + * The maximum time that an ssh signed certificate is considered to be valid. */ private static final IntegerGuacamoleProperty VAULT_SSH_CONNECTION_TIMEOUT = new IntegerGuacamoleProperty() { @@ -301,7 +301,7 @@ public String getSshType() throws GuacamoleException { } /** - * The maximum time that a connection to a ssh server can take in + * The maximum time that a signed SSH certificate is considered valid in * milliseconds. * * @return int diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java index d2b08c5e0e..7ce8e8141d 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java @@ -103,8 +103,8 @@ public class OpenBaoClient { private ClientAuthentication authentication; /** - * A Vault lease container used to automatically renew AppRole - * authenication tokens. + * A Vault lease container used to automatically renew tokens + * or username/password access. */ private SecretLeaseContainer leaseContainer; @@ -172,7 +172,7 @@ else if (event instanceof SecretLeaseExpiredEvent) { // 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 elesewhere as String + // cache as char[] copies of the password would be eleswhere as String // values in memory.. A VaultConverter function could deal with the // spring-vault-core part of the problem, but not Gaucamole. For now just // ensure that the values are really deleted and let the garbage collector @@ -198,7 +198,7 @@ else if (event instanceof SecretLeaseExpiredEvent) { } /* - * Function to detect is the the tokenis in fact a readable file + * Function to detect if the the token is in fact a readable file * rather than a token string * * @param String token @@ -221,7 +221,7 @@ private static boolean isTokenReadableFile(String token) { * Lists the valid mount paths and their type * * @return Map - * The key of the map is the mount path and the value the type. + * The map of the mount path and with the value being the type. * The type can be ssh, database, kv_1 or kv_2 */ public Map listMountPaths() { @@ -271,8 +271,8 @@ else if (type == "kv") { /** * Retrieves a value from a vault by its path. It first parses the * leading mount path from the token, ensures it is valid and uses - * a supported secret engine and then passes of the rest of the - * processing to a method dedicaed to each secret engine. + * a supported secret engine. It then passes off the rest of the + * processing to a method dedicated to each secret engine. * * @param token * The Guacamole token to look up in OpenBao. @@ -294,7 +294,7 @@ public String getValue(String token, Map parameters) throws Guac throw new GuacamoleException("Invalid token Vault token: " + token); } - // Before going further replace the arguments "{USERNAME}", "{HOSTNAME}", + // Before going further replace the arguments "{USERNAME}", "{SERVER}", // "{GATEWAY_USERNAME}" and "{GATEWAY_HOSTNAME}" in the token with their // with values supplied in the parameters // FIXME Could this be done with the TokenFilter in OpenBaoSecretService ? @@ -369,7 +369,7 @@ public String getValue(String token, Map parameters) throws Guac * Retrieves a value from a key-value secret engine of a vault. * * @param mountPath - * The mountPath of teh key-value secret engine on teh vaut server. + * The mountPath of the key-value secret engine on the vault server. * * @param path * The path of the secret record @@ -597,7 +597,7 @@ private String getValueLDAP(String mountPath, String path, String secret) throws throw new GuacamoleException("LDAP secret '" + secret + "' not found on the path : " + mountPath +"/" + path); } - // Is the key-value already in the cache + // Is the value already in the cache Map cacheResponse = (Map) cache.getIfPresent(mountPath + "/" + path); if (cacheResponse != null) { @@ -705,7 +705,7 @@ private String getValueDB(String mountPath, String path, String secret) throws G } if (retval == null || retval.isEmpty()) { - throw new GuacamoleException("SSH secret '" + secret + "' not found on the path : " + mountPath +"/" + path); + throw new GuacamoleException("Data secret '" + secret + "' not found on the path : " + mountPath +"/" + path); } return retval; } @@ -720,7 +720,7 @@ private String getValueDB(String mountPath, String path, String secret) throws G String password = (String) response.getData().get("password"); if (username == null || password == null) { - throw new IllegalStateException("Invalid LDAP static credential response"); + throw new IllegalStateException("Invalid Database credential response"); } // Cache the retrieved user and password diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java index 61b9e4e163..c8a595f624 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java @@ -66,7 +66,7 @@ public OpenBaoSecretService() { public String canonicalize(String nameComponent) { try { - // As HV notation is essentially a URL, encode all components + // As vault notation is essentially a URL, encode all components // using standard URL escaping return URLEncoder.encode(nameComponent, "UTF-8"); diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSshKeys.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSshKeys.java index 639f69891a..fac5325748 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSshKeys.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSshKeys.java @@ -29,6 +29,7 @@ import org.bouncycastle.crypto.params.RSAKeyParameters; import org.bouncycastle.crypto.util.PrivateKeyInfoFactory; import org.bouncycastle.openssl.jcajce.JcaPEMWriter; +import org.apache.guacamole.vault.openbao.conf.OpenBaoConfigurationService; import java.io.ByteArrayOutputStream; import java.io.StringWriter; import java.math.BigInteger; @@ -86,7 +87,7 @@ public OpenBaoSshKeys(String type) { * Class instantiation to return generated SSH keys. Default type */ public OpenBaoSshKeys() { - this("ed25519"); + this(OpenBaoConfigurationService.DEFAULT_SSH_TYPE); } /** @@ -114,7 +115,7 @@ private static String toOpenSshPublicKey(Ed25519PublicKeyParameters publicKey) { * The public key generated by BouncyCastle * * @return String - * The OpenSSH encoded ED25519 public key + * The OpenSSH encoded RSA public key */ private static String toOpenSshPublicKey(RSAKeyParameters publicKey) { ByteArrayOutputStream out = new ByteArrayOutputStream(); From 13396e6e21b6b18a7c82b94b45daf22ef68e645e Mon Sep 17 00:00:00 2001 From: David Bateman Date: Sat, 2 May 2026 14:36:02 +0200 Subject: [PATCH 05/16] GUACAMOLE-2196: The key-values and ssh secret engines now work. Add the start of a testing script and documentation. The documentation will be transfered to guacamole-manual in the future --- doc/licenses/apache-sshd-2.12.1/README | 8 + .../apache-sshd-2.12.1/dep-coordinates.txt | 1 + extensions/guacamole-auth-ban/pom.xml | 2 +- .../include/openbao-optional.properties.in | 10 +- .../docs/include/openbao.properties.in | 2 +- .../docs/openbao.md.j2 | 314 +++++++++++++++--- .../guacamole-vault-openbao/docs/openbao.sh | 264 +++++++++++++++ .../modules/guacamole-vault-openbao/pom.xml | 42 ++- .../OpenBaoAuthenticationProvider.java | 2 +- .../OpenBaoAuthenticationProviderModule.java | 24 +- .../conf/OpenBaoConfigurationService.java | 17 +- .../secret/FileTokenAuthentication.java | 130 +++----- .../vault/openbao/secret/OpenBaoClient.java | 269 ++++++++------- .../openbao/secret/OpenBaoSecretService.java | 29 +- .../vault/openbao/secret/OpenBaoSshKeys.java | 265 ++++----------- .../openbao/secret/TimeoutVaultTemplate.java | 36 +- .../UsernamePasswordAuthentication.java | 17 +- ...UsernamePasswordAuthenticationOptions.java | 2 +- .../openbao/user/OpenBaoAttributeService.java | 7 +- .../openbao/user/OpenBaoDirectoryService.java | 2 + .../apache/guacamole/token/TokenFilter.java | 23 +- 21 files changed, 929 insertions(+), 537 deletions(-) create mode 100644 doc/licenses/apache-sshd-2.12.1/README create mode 100644 doc/licenses/apache-sshd-2.12.1/dep-coordinates.txt create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.sh diff --git a/doc/licenses/apache-sshd-2.12.1/README b/doc/licenses/apache-sshd-2.12.1/README new file mode 100644 index 0000000000..dff6a3c8b8 --- /dev/null +++ b/doc/licenses/apache-sshd-2.12.1/README @@ -0,0 +1,8 @@ +Apache Mina (https://mina.apache.org/sshd-project) +-------------------------------------- + + Version: 2.12.1 + From: 'Apache Software Foundation' (https://www.apache.org/) + License(s): + Apache v2.0 + diff --git a/doc/licenses/apache-sshd-2.12.1/dep-coordinates.txt b/doc/licenses/apache-sshd-2.12.1/dep-coordinates.txt new file mode 100644 index 0000000000..3f32e8a0f9 --- /dev/null +++ b/doc/licenses/apache-sshd-2.12.1/dep-coordinates.txt @@ -0,0 +1 @@ +org.apache.sshd:sshd-common:jar:2.12.1 diff --git a/extensions/guacamole-auth-ban/pom.xml b/extensions/guacamole-auth-ban/pom.xml index f07d9663dd..31836ef85e 100644 --- a/extensions/guacamole-auth-ban/pom.xml +++ b/extensions/guacamole-auth-ban/pom.xml @@ -69,7 +69,7 @@ com.github.ben-manes.caffeine caffeine - 3.1.8 + 2.9.3 diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao-optional.properties.in b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao-optional.properties.in index 2ac2e4a632..db61667470 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao-optional.properties.in +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao-optional.properties.in @@ -24,11 +24,11 @@ vault-connection-timeout: 5000 # certification SSH connections. After this time the signed certificate # can not be reused for a connection. # -# It should be noted that in the caseof drift of the clock between the -# ssh clients and the Vault server, a certificate might be invalidate -# incorrectly. This value must be sufficiently large to account for -# clock drift. -vault-ssh-connection-timeout: 10000 +# It should be noted that in the case of drift of the clock between the +# ssh clients and the Vault server in seconds, a certificate might be +# invalidated incorrectly. This value must be sufficiently large to +# account for clock drift. +vault-ssh-connection-timeout: 1800 # The type of the SSH certificates generated by Guacamole for signed # SSH certificate access. Valid types are `ed25519` and `rsa`. Only diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao.properties.in b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao.properties.in index 7b795bd5fe..23a9101b15 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao.properties.in +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao.properties.in @@ -2,5 +2,5 @@ vault-uri: http://localhost:8200 # The authentication token to use to access the vault -vault-token: s.IPzVb3b5dThhjIrBX245szisjTQcylwSjMEeyJqcidaH8Hf +vault-token: s.IPzVb3b5dT... diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 index c89bfc23a5..58f26f7d8c 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 @@ -26,22 +26,27 @@ Installing/Enabling the vault extension ### Adding Guacamole to Hashicorp or OpenBao -Allowing an application like Guacamole to access secrets via and OpenBao +Allowing an application like Guacamole to access secrets via an OpenBao or Hashicorp Vault involves creating appropriate mount paths for the -secret engines needed and the needed authentication parameters. It is -not the objective of this document to discuss the creation and maintenance -of Vault mount paths and the user is refered to the [OpenBao](https://openbao.org/docs) +secret engines and the needed authentication parameters. It is not the +objective of this document to discuss the creation and maintenance of +Vault mount paths and the user is refered to the [OpenBao](https://openbao.org/docs) or [Hashicorp Vault](https://developer.hashicorp.com/vault/docs) for this -information. +information. + +This document will discuss examples of setting up the supported +secret engines of the Vault for Guacamole with concrete examples. To remain +independant of both OpenBao and Hashicorp Vault, the configuration below +will be done exclusively with `curl`. + +#### Vault connection information The minimum information needed by Guacamole to be setup are in address of the Vault server, including its associated port, and the means of authentication to the server. The Vault server uses a standard HTTP REST API and a typical URI might look like `https://vault.example.com:8200`. -There are two means of authenticating to the Vault server supported by Guacamole. -However, before discussing these two means of authentication, it is important to -setup an access policy for the use of Guacamole +#### Setup up a Guacamole Access Policy Guacamole only needs read access to the secret engine mount path and only to the paths that are actually used in Guacamole. It therefore makes sense to limit @@ -49,6 +54,11 @@ Guacamole's access to the Vault secrets by a specific policy. It should also be noted that Guacamole uses the special mount path `sys/mounts` to list the available mount paths and detect the type of the secret engine used on this mount path. +To allow proper auditing of the access to the Vault, the created policy should be +dedicated to Guacamole. In case of a breach of Guacamole's Vault authentication, this +means that the Vault secrets accessed by the attacker might be known and the number +of remedial actions needed, controlled. + So, imagine that Guacamole needs access to the 4 mount paths representing the 4 secret engines supported by Gaucamole, but only to a subset of the roles on the mount paths of these secret engines respresented by the path `guacamole` @@ -83,9 +93,7 @@ path "kv/guacamole/*" { EOT ``` -We then need to upload this policy to the vault. This can be done with specific -OpenBao or Hashicorp Vault commands, but here we propose to use `curl` so that the -syntax is the same for both Vault suppliers +We then need to upload this policy to the vault. ``` $ export VAULT_TOKEN=... @@ -97,18 +105,215 @@ $ curl -sS \ ``` where `VAULT_TOKEN` is a valid Vault token allowing administrative access. It -also assumes that the vault server is running locally and on http.This +also assumes that the vault server is running locally and on http. This creates a policy called `guacamole` in the vault for our use. -#### Authentication using Tokens +The mount paths above are examples and should be adapted the paths you actually +use. + +#### Creating a key-values store for guacamole + +The simplest means of storing and accessing secrets in a Vault is using the key-values +secret engine. This secret engine can store arbitrary values associated with a key. +Appropriate uses of this secret engine are for unmanaged accounted of the client machines, +certificates, etc. The secret rotation features of the Vault can not be used with this +secret engine, so the use of this secret engine for password storage means that the user +is entirely responsable for the adherance to any corporate password policies. + +The key-values secret engine, comes in two varieties. The older version 1, where only +the latest values are stored, or the newer version 2 that also stores metadata about the +stored values ,including the last time the values were changed. + +The first step is to enable to secret engine. To enable the version 1 secret engine of a +mount path `kv1`, the command might be + +``` +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --request POST --data '{"type": "kv", "options": {"version": "1"}}' \ + http://127.0.0.1:8200/v1/sys/mounts/kv1 +``` + +and for a version 2 secret engine mounted on the path `kv2` the command might be + +``` +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --request POST --data '{"type": "kv", "options": {"version": "2"}}' \ + http://127.0.0.1:8200/v1/sys/mounts/kv2 +``` + +At this point the secret engines are mounted, but they contain no secrets. To add a secret +to the version 1 secret engine the command is + +``` +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --request POST --data '{"username": "bruce", "password": "wayne"}' \ + http://127.0.0.1:8200/v1/kv1/users/batman +``` + +Here the path of the key is `users/batman`. Complex paths are allowed to seperate groups +of users. The stored secrets are `username` and `password`. However, any string could be +used for these. + +The command for a version 2 key-value store is slightly different. with the equivalent +command to the above being + +``` +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --request POST --data '{"data": {"username": "bruce", "password": "wayne"}}' \ + http://127.0.0.1:8200/v1/kv2/data/users/batman +``` + +#### Creating an SSH secret engine for Guacamole + +The SSH secret engine allows two means of operation: signed SSH certificates or one-time +passwords. The better more modern of these is signed SSH certificates, though Guacamole +supports both SSH methods. + +We first need to enable the ssh secret engine with a command like + +``` +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --request POST --data '{"type": "ssh"}' http://127.0.0.1:8200/v1/sys/mounts/ssh +``` + +which mounts the secret engine on the mount path `ssh`. For certificate signing, the Vault +needs a valid CA certificate and its private key. These might be uploaded to the Vault, but +for this example we let the Vault create these value itself. The command for this is -Guacamole authenticates to OpenBao using a token. This token might be supplied -directly Guacamole in the form of a string. Alternatively, Guacamole might be -supplied with a file where to read the token. This file could be managed by a -[Vault agent](https://openbao.org/docs/agent-and-proxy/agent) allowing more -complex Vault authentication to be delegated to the Vault agent. +``` +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --request POST --data '{"generate_signing_key": true}' \ + http://127.0.0.1:8200/v1/ssh/config/ca +``` + +The returned value of `public_key` must be stored on the target machines, for example in the +file `/etc/ssh/trusted_user_ca.pem`. SSH of these target machines must then be changed to allow +certificates signed with this public key. For OpenSSH this could then be achieved by adding + +``` +TrustedUserCAKeys /etc/ssh/trusted_user_ca.pem +``` + +to the file `/etc/ssh/sshd_config`. + +The next step to allow signed SSH certificates is setting up a role in the SSH secret +engine for Guacamole. Guacamole needs the `permit-pty` option of ssh, and so the creation +of a signing role `signer` might be done with the command + +``` +$ curl -sS \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + --request POST \ + --data '{ + "algorithm_signer": "rsa-sha2-256", + "allow_user_certificates": true, + "allowed_users": "*", + "key_type": "ca", + "max_ttl": "30m", + "allowed_extensions": "permit-tty"}' \ + http://127.0.0.1:8200/v1/ssh/roles/signer +``` + +The `max_ttl` value limits the life of the certificates signed by Guacamole to +30 minutes. Small values here ensure that the risk of abuse of the signed certificates +is limited, but the value must taken into account both the latence of the ssh conection +times and the clock drift between the Vault and the SSH client machines. So there is +a limit to how small this value might be without causing problems. + +The Vault is now ready for Guacamole to use to signed SSH certificates. To allow the use of +one-time passwords, a specific role needs to be created. The command to create a role +`generate_otp` for a default user `testuser` is + +``` +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --request POST --data '{"key_type": "otp", "default_user": "testuser"}' \ + http://127.0.0.1:8200/v1/ssh/roles/generate_otp +``` + +The created role can only be used to create one-time passwords for the account `testuser` +as Guacamole will only request the default user. + +Note that the `cidr_list` option is ommitted here. The problem is that the SSH connections +from Guacamole will appear to come from the `guacd` deamon, and within Guacamole's Vault +secret driver we do not have access to the IP address of `guacd`. So Guacamole requests +one-time passwords without specifying and IP address. This needs an additional change in +the Vault. To allow the role `generate_otp` to be used without IP addresses, the additional +command is + +``` +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --request POST --data '{"roles": "generate_otp"}' \ + http://127.0.0.1:8200/v1/ssh/config/zeroaddress +``` + +The target machine needs to be setup for the Vault one-time password. Firstly SSH must be +configured to use PAM. As an example of the modification needed to test the one-time +passwords the following line might be added to the top of `/etc/pam.d/sshd`. + +``` +auth sufficent pam_exec.so expose_authtok /usr/local/bin/verify_otp.sh +``` + +This is only an example that is guarenteed to work the one-time passwords without +preventing the existing methods from failing. The user will surely need to adapt +this to their installation. + +The final piece needed on the client machine is the script `/usr/local/sbin/verify_otp.sh`. +A possible script that tests that the user corresponds to the user allowed by the one-time +password and that the one-time password is correct in + + +``` +#!/bin/sh +set -u + +IFS= read -r OTP + +USERNAME="${PAM_USER}" + +[ -z "${OTP}" ] && exit 1 +[ -z "${USERNAME}" ] && exit 2 + +RESPONSE=$(curl -s \ + --fail \ + --request POST \ + --header "Content-Type: application/json" \ + --data '{"otp": "'${OTP}'"}' \ + http://127.0.0.1:8200/v1/ssh/verify) +[ "$?" -eq 0 ] || exit 4 + +[ "$(echo $RESPONSE | jq -r .data.username)" = "$USERNAME" ] || exit 5 +``` + +that should be saved at `/usr/local/sbin/verify_otp.sh` and made executable with the +command + +``` +chmod 755 /usr/local/sbin/verify_otp.sh +``` + +:::warning +Using a shell script for a login is not the best security practice, and the user is +encouraged to look at the [Hashicorp Vault SSH Helper](https://github.com/hashicorp/vault-ssh-helper) +::: + +#### Creating an LDAP secret engine for Guacamole + + #TODO + +#### Creating a Database secret engine for Guacamole + + #TODO + +#### Authentication using a Token + +Guacamole can authenticate to OpenBao using a token. This token must use a role +as previously discussed above and it must not expire. Guacamole can not be guarenteed +to be able to renew a static expiring token, especially after a reboot. + +To create a static token for use with Guacamole, a command like -To create a static token for use with Guacamole, the command is ``` $ curl -sS \ @@ -122,25 +327,41 @@ $ curl -sS \ "display_name": "service-guacamole", "no_parent": true }' -``` +``` + +where the `ttl` or zero ensures that the token will not expire. The token to use +is stored in the value of `.auth.client_token`. + +#### Authentication use a Token sink file -This creates a non expiring static token for Guacamole's use. There are security -concerning of using non expiring tokens, and they might not be in conformance -with local password policies. For this reason, AppRole tokens with an provisionning -Agent for Guacamole are preferred. +Guacamole does not implement complex authentication methods, such as AppRole. But these +can still be used with the help of a [Vault agent](https://openbao.org/https://openbao.org/docs/agent-and-proxy/agent). +The Vault Agent manages the authentication and renewal of the authentication and just +writes a standard Vault service token to a sink file. Guacamole can read this file +at startup or in case of an expiring token and always have a valid token. -If you'd prefer to use an agent to manage AppRole authentication using a sink -file to exchange the token with Guacamole, see the discussion of creating -[OpenBao AppRole](https://openbao.org/docs/auth/approle) or +The configuration of the Vault Agent is beyond the scope of this document and the user is +encouraged to read the [OpenBao Vault Agent](https://openbao.org/docs/agent-and-proxy/agent) +or [Hashicorp Vault Agent](https://developer.hashicorp.com/vault/docs/agent-and-proxy/agent) +documentation. These vault agents can be used with AppRole authentication as discussed +for [OpenBao AppRole](https://openbao.org/docs/auth/approle) or [Hashicorp AppRole](https://developr.hashicorp/vault/docs/auth/approle) -secretsa, and [Vault agent](https://openbao.org/docs/agent-and-proxy/agent) -for the discussion of the setup of the Agent. +secrets. #### Authentication using Username and Password -If the authentication to Vault is via a username and password, we need to create -a dediciated account for Guacamole in the vaul. This can be done with a command -like +If the authentication to Vault is via a username and password, we first need to have +the `userpass` authentication method mounted which it isn't be default. Assuming +a auth mount path of `auth/userpass` this can be done with the command + +``` +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --request POST --data '{"type": "userpass"}' \ + http://127.0.0.1:8200/v1/sys/auth/userpass +``` + +We can then create a dediciated account for Guacamole in the Vault. This can be done +with a command like ``` curl \ @@ -154,7 +375,7 @@ curl \ ``` This creates a user `guacmaole` using the same policy as previously and sets the -password assuming the userpass engine is mount on the default path `auth/userpass`. +password assuming the userpass engine is mounted on the default path `auth/userpass`. It should be noted that the Vault can not rotate the passwords of these user accounts. So the maintenance and update of the password needs to be done manually, @@ -224,14 +445,14 @@ If automatic injection of secrets cannot work for your use case, consider using [manually-specified secrets via `openbao-token-mapping.yml`](vault-static-secrets). ::: -Parameter tokens injected from KSM records usually take the form -{samp}`$\{VAULT:{MOUNT_PATH}/{PATH}/{SECRET}\}`, where `MOUNT_PATH` is the +Parameter tokens injected from Vault records usually take the form +{samp}`$\{VAULT:\{MOUNT_PATH\}/\{PATH\}/\{SECRET\}\}`, where `MOUNT_PATH` is the mount path of the Vault secret engine used, `PATH` is the path to the secret record and `SECRET` determines what value is retrieved from that record. #### Mount path detection -Guacamole automatically detecting all of the available mount patch on the vault server +Guacamole automatically detecting all of the available mount paths on the vault server and detects their type. The selection of which secret engine that Guacamole used is therefore based the value of `MOUNT_PATH`. @@ -239,7 +460,6 @@ It is possible to have a value of the mount path like `subpath1` and a second mo `subpath1/subpath2`. Guacamole therefore chooses the secret engine with the longest match to the start of the token. - #### Secret Engines and paths supported The following Vault secret engines are supported @@ -260,13 +480,13 @@ ${vault://{MOUNT_PATH}/{static|dynamic|service}/{ROLE}/{username/password}} An example of a valid token might then be `${vault://ldap/static/myaccount/username}`, where the value of `ROLE` here is `myaccount`. - Please not that Guacamole does not touch the lease times associated with these accounts - in the vault and it does not check in the service accounts after use. + Please note that Guacamole does not touch the lease times associated with these accounts + in the vault and it does not check in the service accounts after use. `SSH` -: The Vault secret engine support both one-time password and signed certificate modes of +: The Vault secret engine support both one-time passwords and signed certificate modes of the secret engine. This secret engine required a helper command on the client machines - and is defined by its role. Token for one-time passwords are of the form + and is defined by its role. Tokens for one-time passwords are of the form ``` ${vault://{MOUNT_PATH}/otp/{ROLE}/{username/password}} @@ -284,9 +504,9 @@ ${vault://{MOUNT_PATH}/cert/{ROLE}/{public/private}} An example of a valid token might then be `${vault://ssh/cert/myaccount/public}`, where the value of `ROLE` here is `myaccount`, and the public certificate for use with - Guacamole is recovered. For SSH signed certificates le principal name used is determined + Guacamole is recovered. For SSH signed certificates the principal name used is determined by the `username` of the connection and the certificate will be limited for the use of - only this user. Also as Guacamole does not need port forwarding the certicate is created + only this user. Also as Guacamole does not need port forwarding the certificate is created in a manner that refuses all use of port forwarding. `DATABASE` @@ -298,12 +518,12 @@ ${vault://{MOUNT_PATH}/{ROLE}/{username/password}} ``` `Key-Values` -: The Vault key-values secret engine as a generic secret engine to store arbitrary +: The Vault key-values secret engine is a generic secret engine to store arbitrary secrets. This means that it is adapted to clients that are not managed by one of the other three secret engines supported by Guacamole. For example, this engine might be used for a password on an unmanaged account of a firewall. - Both the path and secret values of key-value tokens are completemy arbitrary and + Both the path and secret values of key-value tokens are completely arbitrary and a valid token might be of the form ``` @@ -318,7 +538,7 @@ ${vault://{MOUNT_PATH}/{PATH}/{SECRET} In addition, the following sub-tokens can be used with the vault token to modify its value when it is referenced. These sub-tokens are of the form `{SUB_TOKEN}` within the vault token itself. If `{SUB_TOKEN}` is valid literal with the vault -token, you can yse `$${SUB_TOKEN}` to ensure it is interpreted as `{SUB_TOKEN}`. +token, you can use `$${SUB_TOKEN}` to ensure it is interpreted as `{SUB_TOKEN}`. The allowed sub-token values are: `{USER}` @@ -391,5 +611,5 @@ mysql-username: vault://db/guacamole/username mysql-password: vault://db/guacamole/password ``` -The secrate engine in this case is mounted on the path `db/` and this secret +The secret engine in this case is mounted on the path `db/` and this secret engine defines a role `guacamole`. diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.sh b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.sh new file mode 100644 index 0000000000..f9381a1b95 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.sh @@ -0,0 +1,264 @@ +#! /bin/sh +# +# Test script to start openbao server for testing and print a test plan + +pwgen() { + _alpha=${2:-'_A-Z-a-z0-9'} + tr -dc "$_alpha" < /dev/random 2> /dev/null | head -c "$1" +} + +docker rm -f openbao > /dev/null 2>&1 +docker run --detach --name openbao --publish 8200:8200 openbao/openbao:2.5 > /dev/null 2>&1 +sleep 2 +VAULT_TOKEN=$(docker logs openbao 2> /dev/null | grep "Root Token" | cut -d: -f2 | xargs) +UNSEAL_KEY=$(docker logs openbao 2> /dev/null | grep "Unseal" | cut -d: -f2 | xargs) + +echo "# Enable KV_1 secret engine" +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --request POST --data '{"type": "kv", "options": {"version": "1"}}' \ + http://127.0.0.1:8200/v1/sys/mounts/kv1 > /dev/null 2>&1 +echo "# Write K-V secret: kv1/users/kali/{\"username\": \"kali\", \"password\": \"kali\"}" +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --request POST --data '{"username": "kali", "password": "kali"}' \ + http://127.0.0.1:8200/v1/kv1/users/kali + +echo "# Enable KV_2 secret engine" +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --request POST --data '{"type": "kv", "options": {"version": "2"}}' \ + http://127.0.0.1:8200/v1/sys/mounts/kv2 > /dev/null 2>&1 +echo "# Write K-V secret: kv2/users/kali/{\"username\": \"kali\", \"password\": \"kali\"}" +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --request POST --data '{"data": {"username": "kali", "password": "kali"}}' \ + http://127.0.0.1:8200/v1/kv2/data/users/kali > /dev/null 2>&1 + +echo "# Enable LDAP secret engine" +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --request POST --data '{"type": "ldap"}' \ + http://127.0.0.1:8200/v1/sys/mounts/ldap > /dev/null 2>&1 + +echo "# Enable Postgres database secret engine" +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --request POST --data '{"type": "database"}' \ + http://127.0.0.1:8200/v1/sys/mounts/db > /dev/null 2>&1 + +echo "# Enable SSH secret engine" +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --request POST --data '{"type": "ssh"}' \ + http://127.0.0.1:8200/v1/sys/mounts/ssh > /dev/null 2>&1 + +echo "# Generate SSH CA" +USER_CA=$(curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --request POST --data '{"generate_signing_key": true}' \ + http://127.0.0.1:8200/v1/ssh/config/ca | jq -rc ".data.public_key" | xargs) + +echo "# Create SSH certificate signing role 'signer'" +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --request POST --data '{"algorithm_signer": "rsa-sha2-256", "allow_user_certificates": true, "allowed_users": "*", "key_type": "ca", "max_ttl": "30m", "allowed_extensions": "permit-tty"}' \ + http://127.0.0.1:8200/v1/ssh/roles/signer + +echo "# Create SSH OTP for account 'testuser'" +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --request POST --data '{"key_type": "otp", "default_user": "testuser", "cidr_list": "0.0.0.0/0"}' \ + http://127.0.0.1:8200/v1/ssh/roles/generate_otp +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --request POST --data '{"roles": "generate_otp"}' \ + http://127.0.0.1:8200/v1/ssh/config/zeroaddress + + +echo "# Create Guacamole limited access policy" +cat << EOF > guacamole.hcl +path "sys/mounts" { + capabilities = ["read"] +} +path "kv1/*" { + capabilities = ["read"] +} +path "kv2/*" { + capabilities = ["read"] +} +path "ssh/*" { + capabilities = ["read"] +} +path "ldap/*" { + capabilities = ["read"] +} +path "db/*" { + capabilities = ["read"] +} +EOF +curl -s --header "X-Vault-Token: $VAULT_TOKEN" \ + --request POST --data-urlencode "policy@guacamole.hcl" \ + http://127.0.0.1:8200/v1/sys/policy/guacamole +/bin/rm guacamole.hcl + +echo "# Enable userpass authentication and create guacamole user in vault" +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --request POST --data '{"type": "userpass"}' \ + http://127.0.0.1:8200/v1/sys/auth/userpass +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --request POST --data '{"default_ttl_lease": "10m", "token_type": "service"}' \ + http://127.0.0.1:8200/v1/sys/auth/userpass/tune +USER_PASSWORD=$(pwgen 16) +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --request POST --data '{"password": "'$USER_PASSWORD'", "policies": "guacamole"}' \ + http://127.0.0.1:8200/v1/auth/userpass/users/guacamole | jq + + + +echo +echo "# OpenBao root token : $VAULT_TOKEN" +echo "# Openbao Unsealing key : $UNSEAL_KEY" +echo "# Username : guacamole" +echo "# password : $USER_PASSWORD" + +cat << EOF + +# TEST PLAN +# --------- +# +# 0. Setup test kali server with a test account with username and passwrd kali/kali +# Add an account 'testuser' to the kali machine that will be used with SSH OTP +# utility. Setup xrdp on the kali machine +# +# Setup a second machine with accounts managed by LDAP, and give LDAP credentials +# to OpenBao +# 1. Add following to guacamole.properties and restart guacamole, to test with root token +# vault-uri: http://localhost:8200 +# vault-token: $VAULT_TOKEN +# 2. Add the tokens to my test kali server with RDP +# \${vault://kv1/users/kali/password} and \${vault://kv1/users/kali/username} +# Test that connection kali server actually works +# 3. Add the tokens to my test kali server with SSH +# \${vault://kv1/users/kali/password} and \${vault://kv1/users/kali/username} +# Test that connection kali server actually works +# 4. Add the tokens to my test kali server with SSH +# \${vault://kv2/users/kali/password} and \${vault://kv2/users/kali/username} +# Test that connection kali server actually works +# 5. Add 'TrustedUserCAKeys' to test kali server, +# +# Do the following commands on test kali machine: + +cat << EOT > /etc/ssh/trusted_user_ca.pem +$USER_CA +EOT +chmod 700 /etc/ssh/trusted_user_ca.pem +chown sshd:sshd /etc/ssh/trusted_user_ca.pem +echo "TrustedUserCAKeys /etc/ssh/trusted_user_ca.pem" >> /etc/ssh/sshd_config +pkill -SIGHUP sshd + +# Use the username "kali" in SSH connection and add theses tokens to +# the private and public ssh keys +# +# \${vault://ssh/cert/signer/private} and \${vault://ssh/cert/signer/public} +# +# Test that the SSH connection to the kali machine works +# 6. Use RSA SSH certiciates. The previous tested used the default "ed25519" ssh +# certificates. Add the following to guacamole.properties +# vault-ssh-type: rsa +# Restart guacamole. Test that the SSH connection to the kali machine +# works +# +# Run the following command on the test kali server + +sed -i -e "/^TrustedUserCAKeys/d" /etc/ssh/sshd_config + +# 7. [TODO OTP SSH] Ensure the vault otp helper is on tehe test kali machine and configure +# Add the following tokens to the test kali connection +# \${vault://ssh/otp/generate_otp/username} and \${vault://ssh/otp/generate_otp/password} +# Use the username 'testuser' on the SSH connection, so and this user to the test machine +# if needed. Now setup the test machine with the code + +cat << EOF >> /usr/local/bin/vault_verify_otp.sh +#!/bin/sh +set -u + +IFS= read -r OTP + +USERNAME="\${PAM_USER}" + +[ -z "\${OTP}" ] && exit 1 +[ -z "\${USERNAME}" ] && exit 2 + +RESPONSE=\$(curl -s \ + --fail \ + --request POST \ + --header "Content-Type: application/json" \ + --data '{"otp": "'\${OTP}'"}' \ + http://127.0.0.1:8200/v1/ssh/verify) +[ "\$?" -eq 0 ] || exit 3 + +[ "\$(echo \$RESPONSE | jq -r .data.username)" = "\$USERNAME" ] || exit 4 +EOF +chmod 755 /usr/local/bin/vault_verify_otp.sh +sed -i '1s:^:auth sufficent pam_exec.so expose_authtok /usr/local/bin/vault_verify_otp.sh:' /etc/pam.d/sshd +pkill -SIGHUP sshd + +# Test that the connexion works. After remove the test code from the test machine like + +rm /usr/local/bin/vault_verify_otp.sh +sed -i '1d' /etc/pam.d/sshd + +# 8. Add the static ldap tokens to the SSH connection +# \${vault://ldap/static/users/kali/password} and \${vault://ldap/static/users/kali/username} +# Test that the SSH connection to the kali machine works +# 9. [TODO LDAP Dynamic] Add the dynamic ldaptokens to the SSH connection +# \${vault://ldap/dynamic/users/kali/password} and \${vault://ldap/dynamic/users/kali/username} +# Test that the SSH connection to the kali machine works +# 10. [TODO LDAP Service] Add the ldap service tokens to the SSH connection +# \${vault://ldap/static/users/kali/password} and \${vault://ldap/static/users/kali/username} +# Test that the SSH connection to the kali machine works +# 11. Test Guacamole with a token with limited rights and an infinite TTL +# First generate the token with the short TTL and limited right + +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --request POST --data '{"policies": ["guacamole"], ttl: "0m", "renewable": true}' \ + http://127.0.0.1:8200/v1/auth/token/create | jq + +# Add the printed token to the 'vault-token' in gaucamole.properties and restart +# Guacamole. +# +# Test that one of the previous test still works +# 12. A VaultAgent can be simulated by using a token sink file as follows. First create +# A short lived (10 minutes token) with the command + +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --request POST --data '{"policies": ["guacamole"], ttl: "10m", "renewable": true}' \ + http://127.0.0.1:8200/v1/auth/token/create | jq -r .auth.client_token > /etc/guacamole.token +chmod 700 /etc/guacamole.token +chown guacamole:guacamole /etc/guacamole.token + +# Change the vault of vault-token in guacamole-properties to +# vault-token: /etc/guacamole.token +# and restart Guacamole, and all of this within 10 minutes. Test access to kali +# machine still works. Now generate a new infinite token with the command + +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --request POST --data '{"policies": ["guacamole"], ttl: "0m", "renewable": true}' \ + http://127.0.0.1:8200/v1/auth/token/create | jq -r .auth.client_token > /etc/guacamole.token + +# Wait 10 minutes and see if the access to the kali machine still works as the +# access has beed renewed with the new token. +# 13. Test Guacamole with username and password. Remove 'token-uri' from +# guacamole.properties and replace with +# vault-username: guacamole +# vault-password: $USER_PASSWORD +# Restart guacamole rapidly and (less than 10 minutes) test that one of the +# previous connections still works +# +# Wait 10 minutes, so as to test the static token renewal of the openbao driver +# and retest that one of the connections above works +# 14. [TODO Database password test] Create a `guacamole.properties.vlt` file with +# entries for the database like +# mysql-username: vault://db/guacamole/username +# mysql-password: vault://db/guacamole/password +# or adapt for your database engine. Restart guacamole. If it functions at all the +# database is accessible +# 15. [TODO Manual tokens] Create a file `openbao-token-mapping.yml` with the tokens +# KALI_USERNAME: vault://kv1/users/kali/password +# KALI_PASSWORD: vault://kv1/users/kali/username +# Add to the kali ssh connection the tokens ${KALI_USERNAME} and ${KALI_PASSWORD} +# and see if the connection still works +# 16. In the kali machine leave the user as `kali` but the password should use the +# token ${vault://kv1/users/{USER}/password}. Test that the connection works + +EOF diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/pom.xml b/extensions/guacamole-vault/modules/guacamole-vault-openbao/pom.xml index 88365b729e..63dd539f39 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/pom.xml +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/pom.xml @@ -66,23 +66,47 @@ - org.bouncycastle - bcprov-jdk15to18 - 1.80 + org.apache.sshd + sshd-common + 2.12.1 + + + org.slf4j + jcl-over-slf4j + + + + + + + org.checkerframework + checker-qual + 3.37.0 - - org.bouncycastle - bcpkix-jdk15to18 - 1.80 + com.google.errorprone + error_prone_annotations + 2.21.1 com.github.ben-manes.caffeine caffeine - 3.1.8 - + 2.9.3 + + + org.checkerframework + checker-qual + + + com.google.errorprone + error_prone_annotations + + + diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProvider.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProvider.java index 075335812d..2909355a03 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProvider.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProvider.java @@ -25,7 +25,7 @@ import org.slf4j.LoggerFactory; /** - * OpenBao authentication provider that retrieves RDP passwords from OpenBao. + * OpenBao authentication provider that retrieves passwords from OpenBao. * This provider integrates with the Guacamole vault framework to automatically * fetch passwords from OpenBao based on the logged-in username. */ diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProviderModule.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProviderModule.java index 2092d13417..9a63e0ffcc 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProviderModule.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProviderModule.java @@ -26,12 +26,14 @@ import org.apache.guacamole.vault.openbao.conf.OpenBaoConfigurationService; import org.apache.guacamole.vault.openbao.secret.OpenBaoClient; import org.apache.guacamole.vault.openbao.secret.OpenBaoSecretService; +import org.apache.guacamole.vault.openbao.secret.TimeoutVaultTemplate; import org.apache.guacamole.vault.openbao.user.OpenBaoAttributeService; import org.apache.guacamole.vault.openbao.user.OpenBaoDirectoryService; import org.apache.guacamole.vault.secret.VaultSecretService; import org.apache.guacamole.vault.conf.VaultAttributeService; import org.apache.guacamole.vault.user.VaultDirectoryService; + /** * Guice module for configuring OpenBao vault integration. * Binds the OpenBao-specific implementations to the vault base interfaces. @@ -52,27 +54,19 @@ public OpenBaoAuthenticationProviderModule() throws GuacamoleException { protected void configureVault() { // Bind configuration service - bind(VaultConfigurationService.class) - .to(OpenBaoConfigurationService.class) - .in(Scopes.SINGLETON); + bind(OpenBaoConfigurationService.class); + bind(VaultConfigurationService.class).to(OpenBaoConfigurationService.class); // Bind secret service - bind(VaultSecretService.class) - .to(OpenBaoSecretService.class) - .in(Scopes.SINGLETON); + bind(VaultSecretService.class).to(OpenBaoSecretService.class); // Bind attribute service - bind(VaultAttributeService.class) - .to(OpenBaoAttributeService.class) - .in(Scopes.SINGLETON); + bind(VaultAttributeService.class).to(OpenBaoAttributeService.class); // Bind directory service - bind(VaultDirectoryService.class) - .to(OpenBaoDirectoryService.class) - .in(Scopes.SINGLETON); + bind(VaultDirectoryService.class).to(OpenBaoDirectoryService.class); - // Bind OpenBao client - bind(OpenBaoClient.class) - .in(Scopes.SINGLETON); + // bind OpenBao client + bind(OpenBaoClient.class); } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfigurationService.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfigurationService.java index 3743e277c3..155319134b 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfigurationService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfigurationService.java @@ -19,8 +19,9 @@ package org.apache.guacamole.vault.openbao.conf; -import java.net.URI; import com.google.inject.Inject; +import com.google.inject.Singleton; +import java.net.URI; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.environment.Environment; import org.apache.guacamole.properties.BooleanGuacamoleProperty; @@ -32,6 +33,7 @@ /** * Service for retrieving Hashicorp/OpenBao configuration from guacamole.properties. */ +@Singleton public class OpenBaoConfigurationService extends VaultConfigurationService { /** * The default cache lifetime in milliseconds. @@ -49,15 +51,16 @@ public class OpenBaoConfigurationService extends VaultConfigurationService { public static final int DEFAULT_CONNECTION_TIMEOUT = 10000; /** - * The default ssh connection tiemout in milliseconds. + * The default ssh connection tiemout in seconds. */ - public static final int DEFAULT_SSH_CONNECTION_TIMEOUT = 10000; + public static final int DEFAULT_SSH_CONNECTION_TIMEOUT = 1800; /** - * The default ssh certificate type + * The default ssh certificate type. + * FIXME : ed25519 not supportted before java 15. Waiting for upgrade */ public static final String DEFAULT_SSH_TYPE = "ed25519"; - + /** * The name of the file which contains the YAML mapping of connection * parameter token to secrets within Hashicorp/OpenBao Vault. @@ -149,7 +152,7 @@ public class OpenBaoConfigurationService extends VaultConfigurationService { @Override public String getName() { return "vault-ssh-connection-timeout"; } }; - + /** * The type of ssh certificates that will be generated */ @@ -299,7 +302,7 @@ public String getSshType() throws GuacamoleException { } return type; } - + /** * The maximum time that a signed SSH certificate is considered valid in * milliseconds. diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/FileTokenAuthentication.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/FileTokenAuthentication.java index ad89e7049e..f5ffa32dc9 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/FileTokenAuthentication.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/FileTokenAuthentication.java @@ -25,19 +25,21 @@ import java.time.Duration; import java.util.Map; import org.apache.guacamole.vault.openbao.conf.OpenBaoConfigurationService; +import org.springframework.http.ResponseEntity; import org.springframework.vault.authentication.AuthenticationSteps; import org.springframework.vault.authentication.AuthenticationStepsFactory; import org.springframework.vault.authentication.AuthenticationSteps.HttpRequest; -import static org.springframework.vault.authentication.AuthenticationSteps.HttpRequestBuilder.get; import org.springframework.vault.authentication.ClientAuthentication; import org.springframework.vault.authentication.LoginToken; -import org.springframework.vault.client.VaultHttpHeaders; +import org.springframework.vault.client.VaultEndpoint; import org.springframework.vault.support.VaultResponse; import org.springframework.vault.support.VaultToken; +import org.springframework.vault.VaultException; +import org.springframework.web.client.RestTemplate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public final class FileTokenAuthentication implements ClientAuthentication, AuthenticationStepsFactory { +public final class FileTokenAuthentication implements ClientAuthentication { /** * Logger for this class. */ @@ -48,6 +50,16 @@ public final class FileTokenAuthentication implements ClientAuthentication, Auth */ private final Path tokenPath; + /** + * The vault endpoint to lookup token metadata + */ + private final VaultEndpoint endpoint; + + /** + * A reusable restTemplate + */ + private final RestTemplate restTemplate; + /** * An instantiator for a Token Authentication class where the token * is reread from a file on renewal requests. This allows integration @@ -55,9 +67,14 @@ public final class FileTokenAuthentication implements ClientAuthentication, Auth * * @param String tokenPath * A path to a readable file containing the token + * + * @param VaultEndpoint endpoint + * The Vault endpoint used for token metadata lookup */ - public FileTokenAuthentication(String tokenPath) { + public FileTokenAuthentication(String tokenPath, VaultEndpoint endpoint, RestTemplate restTemplate) { this.tokenPath = Path.of(tokenPath); + this.endpoint = endpoint; + this.restTemplate = restTemplate; } /* @@ -68,101 +85,40 @@ public FileTokenAuthentication(String tokenPath) { */ @Override public VaultToken login() { + String token; try { - String token = Files.readString(tokenPath).trim(); - return VaultToken.of(token); + token = Files.readString(tokenPath).trim(); } catch (IOException e) { throw new IllegalStateException( "Cannot read Vault token sink: " + tokenPath, e); } - } - - /** - * Create 'AuthenticationSteps' for token authentication given a VaultToken - * - * @param VaultToken token - * token must not be null. - * - * @param boolean selfLookup - * Set true to perform a self-lookup using the given VaultToken. - * Self-lookup will create a LoginToken and provide renewability - * and TTL - * - * @return AuthenticationSteps - * The AuthenticationSteps for token authentication. - */ - public static AuthenticationSteps createAuthenticationSteps(VaultToken token, boolean selfLookup) { - if (token == null) { - throw new IllegalStateException("VaultToken must not be null"); - } - if (selfLookup) { - HttpRequest httpRequest = get("auth/token/lookup-self").with(VaultHttpHeaders.from(token)) - .as(VaultResponse.class); + String lookupPath = String.format( + "%s://%s:%d/v1/auth/token/lookup-self", + endpoint.getScheme(), + endpoint.getHost(), + endpoint.getPort()); - return AuthenticationSteps.fromHttpRequest(httpRequest) - .login(response -> LoginTokenFrom(token.toCharArray(), response.getRequiredData())); - } + ResponseEntity response = + restTemplate.postForEntity(lookupPath, null, VaultResponse.class); - return AuthenticationSteps.just(token); - } - - /** - * Reimplementation of LoginTokenUtil.from as it is private - * - * @param char[] token - * The token converted to a char[] - * - * @param Map auth - * A map of the authentication response. - * - * @return LoginToken - */ - private static LoginToken LoginTokenFrom(char[] token, Map auth ) { - if (auth == null) { - throw new IllegalStateException("VaultToken must not be null"); - } - - Boolean renewable = (Boolean) auth.get("renewable"); - Number leaseDuration = (Number) auth.get("lease_duration"); - String accessor = (String) auth.get("accessor"); - String type = (String) auth.get("type"); - - if (leaseDuration == null) { - leaseDuration = (Number) auth.get("ttl"); - } - - if (type == null) { - type = (String) auth.get("token_type"); - } + VaultResponse vaultResponse = response.getBody(); - LoginToken.LoginTokenBuilder builder = LoginToken.builder(); - builder.token(token); - - if (accessor != null && !accessor.trim().isEmpty()) { - builder.accessor(accessor); - } - - if (leaseDuration != null) { - builder.leaseDuration(Duration.ofSeconds(leaseDuration.longValue())); - } - - if (renewable != null) { - builder.renewable(renewable); - } + Boolean renewable = (Boolean) vaultResponse.getAuth().get("renewable"); + Duration leaseDuration = Duration.ofSeconds((long) vaultResponse.getAuth().get("lease_duration")); + String accessor = (String) vaultResponse.getAuth().get("accessor"); + String type = (String) vaultResponse.getAuth().get("type"); - if (type != null && !type.trim().isEmpty()) { - builder.type(type); + if (token == null) { + throw new VaultException("No client_token in Vault auth response"); } - return builder.build(); + return LoginToken.builder().token(token) + .leaseDuration(leaseDuration) + .renewable(renewable) + .accessor(accessor) + .type(type) + .build(); } - - - @Override - public AuthenticationSteps getAuthenticationSteps() { - VaultToken token = login(); - return createAuthenticationSteps(token, false); - } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java index 7ce8e8141d..abb0aa81c1 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java @@ -24,29 +24,38 @@ import com.github.benmanes.caffeine.cache.Cache; import com.github.benmanes.caffeine.cache.Caffeine; import com.github.benmanes.caffeine.cache.RemovalCause; -import javax.inject.Inject; -import javax.inject.Singleton; +import com.google.inject.Inject; +import com.google.inject.Singleton; import java.io.IOException; import java.net.URI; +import java.net.InetAddress; +import java.net.UnknownHostException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.Duration; import java.util.Collections; +import java.util.concurrent.Executors; import java.util.function.Supplier; import java.util.HashMap; import java.util.Map; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.GuacamoleServerException; +import org.apache.guacamole.protocol.GuacamoleConfiguration; import org.apache.guacamole.vault.openbao.conf.OpenBaoConfigurationService; import org.apache.guacamole.vault.openbao.secret.FileTokenAuthentication; import org.apache.guacamole.vault.openbao.secret.UsernamePasswordAuthentication; import org.apache.guacamole.vault.openbao.secret.UsernamePasswordAuthenticationOptions; import org.apache.guacamole.vault.openbao.secret.TimeoutVaultTemplate; import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; import org.springframework.vault.authentication.ClientAuthentication; +import org.springframework.vault.authentication.LifecycleAwareSessionManager; +import org.springframework.vault.authentication.SessionManager; import org.springframework.vault.authentication.SimpleSessionManager; import org.springframework.vault.authentication.TokenAuthentication; +import org.springframework.vault.client.VaultClients; import org.springframework.vault.client.VaultEndpoint; import org.springframework.vault.core.lease.event.SecretLeaseErrorEvent; import org.springframework.vault.core.lease.event.SecretLeaseExpiredEvent; @@ -54,11 +63,12 @@ import org.springframework.vault.core.VaultKeyValueOperations; import org.springframework.vault.VaultException; import org.springframework.vault.support.VaultResponse; +import org.springframework.web.client.RestOperations; import org.springframework.web.client.RestTemplate; import org.slf4j.Logger; import org.slf4j.LoggerFactory; - +@Singleton public class OpenBaoClient { /** * Logger for this class. @@ -70,13 +80,13 @@ public class OpenBaoClient { */ @Inject private OpenBaoConfigurationService configService; - + /** * A singleton ObjectMapper for converting a Map to a JSON string when * returning a complex token. */ private static final ObjectMapper objectMapper = new ObjectMapper(); - + /** * The prefix of the Guacamole token to resolve on the vault server. */ @@ -87,16 +97,11 @@ public class OpenBaoClient { */ private Cache> cache; - /** - * A cache of the valid mount paths and their type - */ - private Map mountpaths; - /** * Vault template that will be used with all of the mount paths */ private TimeoutVaultTemplate vaultTemplate; - + /** * Vault client authentication object */ @@ -109,17 +114,18 @@ public class OpenBaoClient { private SecretLeaseContainer leaseContainer; /** - * Complete the instantiation of the class after injection of confService + * Complete the instantiation of the class on first use */ - @Inject - public void init() { + public void init() throws GuacamoleException { try { - VaultEndpoint endpoint = VaultEndpoint.from(configService.getVaultUri()); - - + VaultEndpoint endpoint = VaultEndpoint.from(configService.getVaultUri().resolve("v1")); + + if (configService.getVaultToken() != null) { if (isTokenReadableFile(configService.getVaultToken())) { - this.authentication = new FileTokenAuthentication(configService.getVaultToken()); + this.authentication = + new FileTokenAuthentication(configService.getVaultToken(), + endpoint, new RestTemplate()); } else { this.authentication = new TokenAuthentication(configService.getVaultToken()); @@ -136,35 +142,44 @@ else if (configService.getVaultUsername() != null && configService.getVaultPassw new UsernamePasswordAuthentication(options, endpoint, new RestTemplate()); } else { - logger.error("Either a vault token or Username/Password must be supplied"); + throw new GuacamoleException("Either a vault token or Username/Password must be supplied"); } - this.vaultTemplate = new TimeoutVaultTemplate(endpoint, - new SimpleClientHttpRequestFactory(), - new SimpleSessionManager(this.authentication)); - } - catch (Exception e) { - logger.error("Error initiatizing Vault client: ", e); - } - - // The access token generated above might have a limited - // lifetime. Setup automatic renewal. - this.leaseContainer = - new SecretLeaseContainer(vaultTemplate); - this.leaseContainer.addLeaseListener(event -> { - if (event instanceof SecretLeaseErrorEvent) { - Throwable cause = ((SecretLeaseErrorEvent) event).getException(); - logger.error("Vault lease error", cause); - } - else if (event instanceof SecretLeaseExpiredEvent) { - SecretLeaseExpiredEvent expired = (SecretLeaseExpiredEvent) event; - logger.warn("Vault lease expired: {}", expired.getSource().getPath()); + if (configService.getVaultToken() != null && + ! isTokenReadableFile(configService.getVaultToken())) { + this.vaultTemplate = new TimeoutVaultTemplate(endpoint, + new SimpleClientHttpRequestFactory(), + new SimpleSessionManager(this.authentication)); } else { - logger.debug("Vault access token renewed"); + ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler(); + taskScheduler.setPoolSize(1); + taskScheduler.setThreadNamePrefix("vault-token-renewal-"); + taskScheduler.initialize(); + RestOperations restOperations = VaultClients.createRestTemplate(endpoint, + new SimpleClientHttpRequestFactory()); + LifecycleAwareSessionManager session = + new LifecycleAwareSessionManager(this.authentication, taskScheduler, restOperations); + + this.vaultTemplate = new TimeoutVaultTemplate(endpoint, + new SimpleClientHttpRequestFactory(), session); } - }); - this.leaseContainer.start(); + try { + this.vaultTemplate.setConnectionTimeout(configService.getConnectionTimeout()); + } + catch (GuacamoleException e) { + logger.debug("Using default vault endpoint connection timeout: " + e.getMessage()); + } + try { + this.vaultTemplate.setRequestTimeout(configService.getRequestTimeout()); + } + catch (GuacamoleException e) { + logger.debug("Using default vault endpoint request timeout: " + e.getMessage()); + } + } + catch (Exception e) { + throw new GuacamoleException("Error initializing Vault client: ", e); + } // Initialize the cache with maximum size of 1MB, and cache expiry with // forced cleanup @@ -173,28 +188,17 @@ else if (event instanceof SecretLeaseExpiredEvent) { // 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 eleswhere as String - // values in memory.. A VaultConverter function could deal with the - // spring-vault-core part of the problem, but not Gaucamole. For now just - // ensure that the values are really deleted and let the garbage collector - // do want it can for the passwords in memory. + // values in memory.. A VaultConverter function could deal with the + // spring-vault-core part of the problem, but not Gaucamole. try { cache = Caffeine.newBuilder() .expireAfterWrite(Duration.ofMillis(configService.getVaultCacheLifetime())) .maximumSize(1_000_000) - .removalListener((String k, Map v, RemovalCause cause) -> { - if (v != null) { - v.clear(); - }}) .build(); } catch (GuacamoleException e) { - logger.error("Can read the cache lifetime value"); + throw new GuacamoleException("Can't setup OpenBao cache"); } - - // Cache the valid mount paths on start up - // FIXME a change to the mount paths of the vault server will require a restart - // of Guacamole - mountpaths = listMountPaths(); } /* @@ -224,7 +228,13 @@ private static boolean isTokenReadableFile(String token) { * The map of the mount path and with the value being the type. * The type can be ssh, database, kv_1 or kv_2 */ - public Map listMountPaths() { + public Map listMountPaths() { + + Map cacheMountPaths = cache.getIfPresent("sys/mounts"); + if (cacheMountPaths != null) { + return cacheMountPaths; + } + VaultResponse response = vaultTemplate.read("sys/mounts"); if (response == null || response.getData() == null) { @@ -232,40 +242,42 @@ public Map listMountPaths() { } Map mounts = response.getData(); - Map result = new HashMap<>(); + Map mountPaths = new HashMap<>(); mounts.forEach((mountPath, mountInfoObj) -> { if (mountInfoObj instanceof Map) { Map mountInfo = (Map) mountInfoObj; Object type = mountInfo.get("type"); if (type instanceof String) { - if (type == "ssh" || type == "database") { - result.put(mountPath, (String) type); + if (type.equals("ssh") || type.equals("database")) { + mountPaths.put(mountPath, type); } - else if (type == "kv") { + else if (type.equals("kv")) { // Need to detect if type 1 or type 2 Key/Value engine - if (mountInfo.get("options") instanceof Map) { + if (mountInfo.get("options") instanceof Map) { Map options = (Map) mountInfo.get("options"); - Object version = options.get("vesion"); + Object version = options.get("version"); if (version instanceof String) { - if (version == "2") { - result.put(mountPath, "kv_2"); + if (version.equals("2")) { + mountPaths.put(mountPath, "kv_2"); } else { - result.put(mountPath, "kv_1"); + mountPaths.put(mountPath, "kv_1"); } } } else { // No options, assume kv_1 - result.put(mountPath, "kv_1"); + mountPaths.put(mountPath, "kv_1"); } } } } }); - return result; + cache.put("sys/mounts", mountPaths); + + return mountPaths; } /** @@ -277,8 +289,9 @@ else if (type == "kv") { * @param token * The Guacamole token to look up in OpenBao. * - * @param paramaters - * The connection parameters of the connection. + * @param config + * A GuacamoleConfiguration containing the connection configuration + * parameters. * * @return * The value associated with the key. @@ -286,9 +299,13 @@ else if (type == "kv") { * @throws GuacamoleException * If the secret cannot be retrieved from OpenBao. */ - public String getValue(String token, Map parameters) throws GuacamoleException { + public String getValue(String token, GuacamoleConfiguration config) throws GuacamoleException { try { - logger.info("Fetching secret from OpenBao: {}", token); + if (authentication == null) { + logger.debug("Initializing OpenBao"); + init(); + logger.debug("Initialized OpenBao"); + } if (! token.startsWith(VAULT_TOKEN_PREFIX)) { throw new GuacamoleException("Invalid token Vault token: " + token); @@ -300,25 +317,25 @@ public String getValue(String token, Map parameters) throws Guac // FIXME Could this be done with the TokenFilter in OpenBaoSecretService ? // 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 username = parameters.get("username"); + String username = config.getParameter("username"); if (username != null && !username.isEmpty() && token.contains("{USER}") && ! token.contains("$${USER}")) { token.replace("{USER}", username); } - String hostname = parameters.get("hostname"); + String hostname = config.getParameter("hostname"); if (hostname != null && !hostname.isEmpty() && token.contains("{SERVER}") && ! token.contains("$${SERVER}")) { token.replace("{SERVER}", hostname); } - String gatewayHostname = parameters.get("gateway-hostname"); + String gatewayHostname = config.getParameter("gateway-hostname"); if (gatewayHostname != null && !gatewayHostname.isEmpty() && token.contains("{GATEWAY}") && ! token.contains("$${GATEWAY}")) { token.replace("{GATEWAY}", gatewayHostname); } - String gatewayUsername = parameters.get("gateway-username"); + String gatewayUsername = config.getParameter("gateway-username"); if (gatewayUsername != null && !gatewayUsername.isEmpty() && token.contains("{GATEWAY_USER}") && ! token.contains("$${GATEWAY_USER}")) { token.replace("{GATEWAY_USER}", gatewayUsername); @@ -326,12 +343,14 @@ public String getValue(String token, Map parameters) throws Guac } // Detect and validate the mount path + Map mountpaths = listMountPaths(); String mountPath = null; String type = null; for (String _path : mountpaths.keySet()) { - if (token.startsWith(_path) && _path.length() > mountPath.length()) { + if (token.startsWith(VAULT_TOKEN_PREFIX + _path) && + (mountPath == null || _path.length() > mountPath.length())) { mountPath = _path; - type = mountpaths.get(_path); + type = (String) mountpaths.get(_path); } } if (mountPath == null || mountPath.isEmpty()) { @@ -346,9 +365,11 @@ public String getValue(String token, Map parameters) throws Guac String path = token.substring(VAULT_TOKEN_PREFIX.length() + mountPath.length(), lastSlashIndex); String secret = token.substring(lastSlashIndex + 1); + logger.info("MountPath {}, path {}, secret {}, type {}", mountPath, path, secret, type); + switch (type) { case "ssh": - return getValueSSH(mountPath, path, secret, parameters); + return getValueSSH(mountPath, path, secret, config); case "ldap": return getValueLDAP(mountPath, path, secret); case "database": @@ -361,7 +382,8 @@ public String getValue(String token, Map parameters) throws Guac } } catch (VaultException e) { - throw new GuacamoleServerException("Failed to retrieve secret from Vault Server", e); + logger.error("ERROR : {}", e.getMessage()); + throw new GuacamoleServerException("Failed to retrieve secret from Vault Server : " + e.getMessage()); } } @@ -392,13 +414,14 @@ private String getValueKV(String mountPath, String path, String secret, String t if (cacheResponse != null) { // The path is already cached?. Use it - if (cacheResponse.get(secret) instanceof String) { - return (String) cacheResponse.get(secret); + Object raw = cacheResponse.get(secret); + if (raw instanceof String || raw instanceof Number || raw instanceof Boolean) { + return String.valueOf(raw); } else { try { // Stored JSON value.. Probably not usable, but return as a string - return objectMapper.writeValueAsString(cacheResponse.get(secret)); + return objectMapper.writeValueAsString(raw); } catch (JsonProcessingException e) { throw new GuacamoleException("Error json parsing returned secret: ", e); @@ -420,20 +443,23 @@ private String getValueKV(String mountPath, String path, String secret, String t // Get the values on the path and cache them VaultResponse response = kvOperations.get(path); - cache.put(mountPath + "/" + path, response.getData()); - if (response == null) { + + if (response.getData() == null) + { throw new GuacamoleServerException( "Value not found in OpenBao for path: " + mountPath + "/" + path); } + cache.put(mountPath + "/" + path, response.getData()); - if (response.getData().get(secret) instanceof String) { - return (String) response.getData().get(secret); + Object raw = response.getData().get(secret); + if (raw instanceof String || raw instanceof Number || raw instanceof Boolean) { + return String.valueOf(raw); } else { try { // Stored JSON value.. Probably not usable, but return as a string - return objectMapper.writeValueAsString(response.getData().get(secret)); + return objectMapper.writeValueAsString(raw); } catch (JsonProcessingException e) { throw new GuacamoleException("Error json parsing returned secret: ", e); @@ -453,8 +479,9 @@ private String getValueKV(String mountPath, String path, String secret, String t * @param secret * The secret value to return * - * @param parameters - * The connection parameters of the connection + * @param config + * A GuacamoleConfiguration containing the connection configuration + * parameters. * * @return * The value associated with the secret. @@ -462,7 +489,7 @@ private String getValueKV(String mountPath, String path, String secret, String t * @throws GuacamoleException * If the secret cannot be retrieved from OpenBao. */ - private String getValueSSH(String mountPath, String path, String secret, Map parameters) throws GuacamoleException { + private String getValueSSH(String mountPath, String path, String secret, GuacamoleConfiguration config) throws GuacamoleException { // Is the key-value already in the cache Map cacheResponse = (Map) cache.getIfPresent(mountPath + "/" + path); @@ -482,31 +509,22 @@ private String getValueSSH(String mountPath, String path, String secret, Map request = Map.of( - "username", username, - "ip", hostname - ); - VaultResponse response = - vaultTemplate.write(mountPath + "/creds/" + path.substring(4), request); + vaultTemplate.write(mountPath + "/creds/" + path.substring(4), Map.of("ip", "0.0.0.0")); + try { + logger.info(" KV Response : {}", objectMapper.writeValueAsString(response.getData())); + } + catch (Exception e) { + logger.info(" KV Response Error : {}", e); + } if (response == null || response.getData() == null) { throw new GuacamoleException("No response from Vault SSH engine"); @@ -521,25 +539,27 @@ private String getValueSSH(String mountPath, String path, String secret, Map request = Map.of( "public_key", sshKeys.publicSsh, "valid_principals", username, - "extensions", Map.of( - "permit-port-forwarding", false, - "permit-agent-forwarding", false, - "permit-x11-forwarding", false - ), + "extensions", Map.of("permit-pty", ""), "ttl", configService.getSshConnectionTimeout() ); @@ -559,18 +579,19 @@ else if (path.startsWith("cert/")) { cache.put(mountPath + "/" + path, Map.of( "private", sshKeys.privateSshPem, "public", signedCert)); + logger.info("SSH CERT : " + sshKeys.privateSshPem + " : " + signedCert); - if (secret == "private") { + if (secret.equals("private")) { return sshKeys.privateSshPem; } - else if (secret == "public") { + else if (secret.equals("public")) { return signedCert; } } else { - throw new GuacamoleException("Unknown SSH type on path: " + mountPath +"/" + path); + throw new GuacamoleException("Unknown SSH type on path: " + mountPath + path); } - throw new GuacamoleException("SSH secret '" + secret + "' not found on the path : " + mountPath +"/" + path); + throw new GuacamoleException("SSH secret '" + secret + "' not found on the path : " + mountPath + path); } /** @@ -616,7 +637,7 @@ private String getValueLDAP(String mountPath, String path, String secret) throws } } - if (retval == null || retval == "") { + if (retval == null || retval.isEmpty()) { throw new GuacamoleException("SSH secret '" + secret + "' not found on the path : " + mountPath +"/" + path); } return retval; @@ -655,8 +676,9 @@ else if (path.startsWith("service/")) { // Cache the retrieved user and password cache.put(mountPath + "/" + path, Map.of("username", username, "password", password)); + logger.debug("LDAP : " + username + " : " + password); - if (secret == "username") { + if (secret.equals("username")) { return username; } return password; @@ -725,8 +747,9 @@ private String getValueDB(String mountPath, String path, String secret) throws G // Cache the retrieved user and password cache.put(mountPath + "/" + path, Map.of("username", username, "password", password)); + logger.debug("DB : " + username + " : " + password); - if (secret == "username") { + if (secret.equals("username")) { return username; } return password; diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java index c8a595f624..faa91c2a17 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java @@ -20,6 +20,7 @@ package org.apache.guacamole.vault.openbao.secret; import com.google.inject.Inject; +import com.google.inject.Singleton; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; import java.util.Map; @@ -42,6 +43,7 @@ * OpenBao implementation of VaultSecretService. * Retrieves secrets from OpenBao based on parameters of the logged-in user. */ +@Singleton public class OpenBaoSecretService implements VaultSecretService { /** @@ -83,7 +85,7 @@ public String canonicalize(String nameComponent) { * of the context of the particular connection being established, or any * associated user context. * - * @param name + * @param token * The name of the secret to retrieve. * * @return @@ -98,24 +100,14 @@ public String canonicalize(String nameComponent) { */ @Override public Future getValue(String token) throws GuacamoleException { - // Empty parameters in this context - Map parameters = Map.of("username", "", - "hostname", "", - "gateway-username", "", - "gateway-hostname", ""); - String value = openBaoClient.getValue(token, parameters); - return CompletableFuture.completedFuture(value); + String value = openBaoClient.getValue(token, new GuacamoleConfiguration()); + return CompletableFuture.completedFuture(value); } @Override public Future getValue(UserContext userContext, Connectable connectable, String token) throws GuacamoleException { - // Empty parameters in this context - Map parameters = Map.of("username", "", - "hostname", "", - "gateway-username", "", - "gateway-hostname", ""); - String value = openBaoClient.getValue(token, parameters); + String value = openBaoClient.getValue(token, new GuacamoleConfiguration()); return CompletableFuture.completedFuture(value); } @@ -166,11 +158,18 @@ public Map> getTokens(UserContext userContext, Matcher tokenMatcher = tokenPattern.matcher(entry.getValue()); while (tokenMatcher.find()) { String token = tokenMatcher.group(1); - String value = openBaoClient.getValue(token, parameters); + String value = openBaoClient.getValue(token, config); tokens.put(token, CompletableFuture.completedFuture(value)); } } + logger.info("Returning {} OpenBao tokens:", tokens.size()); + tokens.forEach((k, v) -> { + try { + logger.info(" {} : {}", k, v.get()); + } catch (Exception e) { + logger.info(" {} => ERROR: {}", k, e); + }}); return tokens; } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSshKeys.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSshKeys.java index fac5325748..9aaa726ae7 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSshKeys.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSshKeys.java @@ -19,67 +19,67 @@ package org.apache.guacamole.vault.openbao.secret; -import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; -import org.bouncycastle.crypto.AsymmetricCipherKeyPair; -import org.bouncycastle.crypto.generators.RSAKeyPairGenerator; -import org.bouncycastle.crypto.params.Ed25519PrivateKeyParameters; -import org.bouncycastle.crypto.params.Ed25519PublicKeyParameters; -import org.bouncycastle.crypto.params.RSAKeyGenerationParameters; -import org.bouncycastle.crypto.params.RSAPrivateCrtKeyParameters; -import org.bouncycastle.crypto.params.RSAKeyParameters; -import org.bouncycastle.crypto.util.PrivateKeyInfoFactory; -import org.bouncycastle.openssl.jcajce.JcaPEMWriter; -import org.apache.guacamole.vault.openbao.conf.OpenBaoConfigurationService; import java.io.ByteArrayOutputStream; -import java.io.StringWriter; -import java.math.BigInteger; import java.nio.charset.StandardCharsets; -import java.security.SecureRandom; -import java.util.Base64; -import java.util.Map; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import org.apache.guacamole.vault.openbao.conf.OpenBaoConfigurationService; +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; + + + + +import org.apache.sshd.common.config.keys.PublicKeyEntry; +import org.apache.sshd.common.config.keys.writer.openssh.OpenSSHKeyPairResourceWriter; +import org.apache.sshd.common.util.security.SecurityUtils; + public class OpenBaoSshKeys { + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(OpenBaoSshKeys.class); + /** * The PEM encoded private SSH key */ - public String privateSshPem; + public final String privateSshPem; /** * The OpenSSH encoded public SSH key */ - public String publicSsh; + public final String publicSsh; /** * Class instantiation to return generated SSH keys */ public OpenBaoSshKeys(String type) { - if (type == "rsa") { - RSAKeyPairGenerator generator = new RSAKeyPairGenerator(); - RSAKeyGenerationParameters params = - new RSAKeyGenerationParameters( - BigInteger.valueOf(0x10001), // public exponent (65537) - new SecureRandom(), - 4096, // key size - 80 // certainty - ); - generator.init(params); - AsymmetricCipherKeyPair keyPair = generator.generateKeyPair(); - publicSsh = toOpenSshPublicKey((RSAKeyParameters) keyPair.getPublic()); - privateSshPem = toOpenSshPrivateKey((RSAPrivateCrtKeyParameters) keyPair.getPrivate(), - (RSAKeyParameters) keyPair.getPublic(), null); + KeyPair keyPair; + + if ("rsa".equals(type)) { + keyPair = generateRsa(); } else { - SecureRandom random = new SecureRandom(); - - Ed25519PrivateKeyParameters privateKey = - new Ed25519PrivateKeyParameters(random); - - Ed25519PublicKeyParameters publicKey = - privateKey.generatePublicKey(); + keyPair = generateEd25519WithFallback(); + } - publicSsh = toOpenSshPublicKey(publicKey); - privateSshPem = toOpenSshPrivateKey(privateKey); + 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); } } @@ -91,173 +91,40 @@ public OpenBaoSshKeys() { } /** - * Generate OpenSSH encode ED25519 public key + * Generate a ed25519 key-pair * - * @param Ed25519PublicKeyParameters pubicKey - * The public key generated by BouncyCastle - * - * @return String - * The OpenSSH encoded ED25519 public key - */ - private static String toOpenSshPublicKey(Ed25519PublicKeyParameters publicKey) { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - writeBytes(out, "ssh-ed25519".getBytes(StandardCharsets.US_ASCII)); - writeBytes(out, publicKey.getEncoded()); - - String encoded = Base64.getEncoder().encodeToString(out.toByteArray()); - return "ssh-ed25519 " + encoded; - } - - /** - * Generate OpenSSH encode RSA public key - * - * @param RSAKeyParameters publicKey - * The public key generated by BouncyCastle - * - * @return String - * The OpenSSH encoded RSA public key + * @return + * A java.security.KeyPair containing the ed25519 key pair or RSA if failure */ - private static String toOpenSshPublicKey(RSAKeyParameters publicKey) { - ByteArrayOutputStream out = new ByteArrayOutputStream(); - writeBytes(out, "ssh-rsa".getBytes(StandardCharsets.US_ASCII)); - writeBytes(out, publicKey.getExponent().toByteArray()); - writeBytes(out, publicKey.getModulus().toByteArray()); - String encoded = Base64.getEncoder().encodeToString(out.toByteArray()); - - return "ssh-rsa " + encoded; - } - - /** - * Generate OpenSSH encode ed25519 private key - * - * @param Ed25519PrivateKeyParameters privateKey) - * The private key generated by BouncyCastle - * - * @return String - * The OpenSSH encoded ED25519 private key - */ - private static String toOpenSshPrivateKey(Ed25519PrivateKeyParameters privateKey) { - byte[] raw = privateKey.getEncoded(); - String base64 = Base64.getEncoder().encodeToString(raw); - - return String.format( - "-----BEGIN OPENSSH PRIVATE KEY-----%n%s%n" + - " -----END OPENSSH PRIVATE KEY-----%n", base64); - } - - /** - * Guacamole needs private key in OpenSSH format, which is officially undocumented, - * so this is an ugly function to write RSA private keys in OpenSSH format - * - * @param RSAPrivateCrtKeyParameter privateKey - * The RSA private key generated by BouncyCastle - * - * @param RSAKeyParameters publicKey - * The RSA public key generated by BouncyCastle - * - * @param String comment - * An arbitrary comment that can be added to the private key - * - * @return String - * The OpenSSH encoded RSA private key - */ - private static String toOpenSshPrivateKey( - RSAPrivateCrtKeyParameters privateKey, - RSAKeyParameters publicKey, - String comment) { - + private KeyPair generateEd25519WithFallback() { try { - SecureRandom rnd = new SecureRandom(); - ByteArrayOutputStream out = new ByteArrayOutputStream(); - - // header - writeBytes(out, "openssh-key-v1\0".getBytes(StandardCharsets.UTF_8)); - writeBytes(out, "none".getBytes(StandardCharsets.UTF_8)); // cipher - writeBytes(out, "none".getBytes(StandardCharsets.UTF_8)); // kdf - writeBytes(out, new byte[0]); // kdf options - writeInt(out, 1); // number of keys - - // write public key blob - ByteArrayOutputStream pubOut = new ByteArrayOutputStream(); - writeBytes(pubOut, "ssh-rsa".getBytes(StandardCharsets.UTF_8)); - writeBytes(pubOut, publicKey.getExponent().toByteArray()); - writeBytes(pubOut, publicKey.getModulus().toByteArray()); - writeBytes(out, pubOut.toByteArray()); - - // private key block - ByteArrayOutputStream privOut = new ByteArrayOutputStream(); - - int check = rnd.nextInt(); - writeInt(privOut, check); - writeInt(privOut, check); - - writeBytes(privOut, "ssh-rsa".getBytes(StandardCharsets.UTF_8)); - writeBytes(privOut, publicKey.getModulus().toByteArray()); - writeBytes(privOut, publicKey.getExponent().toByteArray()); - writeBytes(privOut, privateKey.getExponent().toByteArray()); - writeBytes(privOut, privateKey.getQInv().toByteArray()); - writeBytes(privOut, privateKey.getP().toByteArray()); - writeBytes(privOut, privateKey.getQ().toByteArray()); - writeBytes(privOut, - (comment == null ? "" : comment).getBytes(StandardCharsets.UTF_8)); - - pad(privOut); - - writeBytes(out, privOut.toByteArray()); - - String base64 = - Base64.getMimeEncoder(70, new byte[]{'\n'}) - .encodeToString(out.toByteArray()); - - return String.format( - "-----BEGIN OPENSSH PRIVATE KEY-----%n%s%n" + - " -----END OPENSSH PRIVATE KEY-----%n", base64); - - } catch (Exception e) { - throw new IllegalStateException(e); + // Use SSHD's EdDSA generator, not JDK "Ed25519" + KeyPairGenerator keyPairGenerator = + SecurityUtils.getKeyPairGenerator("EdDSA"); + keyPairGenerator.initialize(256); // Ed25519 + return keyPairGenerator.generateKeyPair(); } - } - - /** - * Pad output stream to 8 byte boundary - * - * @param ByteArrAyOutputStream - * The byte stream to be padded - */ - private static void pad(ByteArrayOutputStream out) { - int padLen = 8 - (out.size() % 8); - for (int i = 1; i <= padLen; i++) { - out.write(i); + catch (Exception e) { + logger.info("Ed25519 not available via SSHD EdDSA. Falling back to RSA : " + e.getMessage()); + return generateRsa(); } } /** - * Helper function toan int in OpenSSH key format - * - * @param ByteArrayOutputStream - * The stream on which to write the result + * Generate a 4096-bit RSA key-pair * - * @param int - * The int to convert and write to the stream + * @return + * A java.security.KeyPair containing the RSA key pair */ - private static void writeInt(ByteArrayOutputStream out, int v) { - out.write((v >>> 24) & 0xff); - out.write((v >>> 16) & 0xff); - out.write((v >>> 8) & 0xff); - out.write(v & 0xff); - } - - /** - * Helper function to write bytes to OpenSSH key format - * - * @param ByteArrayOutputStream - * The stream on which to write the result - * - * @param byte[] - * The byte array to convert and write to the stream - */ - private static void writeBytes(ByteArrayOutputStream out, byte[] b) { - writeInt(out, b.length); - out.write(b, 0, b.length); + 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-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/TimeoutVaultTemplate.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/TimeoutVaultTemplate.java index 534ed94e54..9f332ac112 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/TimeoutVaultTemplate.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/TimeoutVaultTemplate.java @@ -19,7 +19,7 @@ package org.apache.guacamole.vault.openbao.secret; -import javax.inject.Inject; +import com.google.inject.Inject; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.vault.openbao.conf.OpenBaoConfigurationService; import org.springframework.http.client.ClientHttpRequestFactory; @@ -34,10 +34,9 @@ /** * The point of this class is to override the doCreateRestTemplate method - * when creating a VaultTemplate and allow a request and connection - * timeout to be added to the HttpClient. + * when creating a VaultTemplate and allow a request and connection + * timeout to be added to the HttpClient. */ - public class TimeoutVaultTemplate extends VaultTemplate { /** * Logger for this class. @@ -50,6 +49,15 @@ public class TimeoutVaultTemplate extends VaultTemplate { @Inject private OpenBaoConfigurationService configService; + /** + * The connection timeout in milliseconds + */ + private int connectionTimeout = OpenBaoConfigurationService.DEFAULT_CONNECTION_TIMEOUT; + + /** + * The request timeout in milliseconds + */ + private int requestTimeout = OpenBaoConfigurationService.DEFAULT_REQUEST_TIMEOUT; public TimeoutVaultTemplate(VaultEndpoint endpoint, ClientHttpRequestFactory requestFactory, @@ -57,30 +65,20 @@ public TimeoutVaultTemplate(VaultEndpoint endpoint, super(endpoint, requestFactory, session); } + public void setRequestTimeout(int requestTimeout) { this.requestTimeout = requestTimeout; } + public void setConnectionTimeout(int requestTimeout) { this.connectionTimeout = connectionTimeout; } + @Override protected RestTemplate doCreateRestTemplate(VaultEndpointProvider endpointProvider, ClientHttpRequestFactory requestFactory) { - int connectionTimeout; - int requestTimeout; - try { - connectionTimeout = configService.getConnectionTimeout(); - } - catch (GuacamoleException e) { - connectionTimeout = OpenBaoConfigurationService.DEFAULT_CONNECTION_TIMEOUT; - } - try { - requestTimeout = configService.getRequestTimeout(); - } - catch (GuacamoleException e) { - requestTimeout = OpenBaoConfigurationService.DEFAULT_REQUEST_TIMEOUT; - } + if (requestFactory instanceof SimpleClientHttpRequestFactory) { logger.debug("Setting http request and connection timeouts"); ((SimpleClientHttpRequestFactory) requestFactory) .setConnectTimeout(connectionTimeout); ((SimpleClientHttpRequestFactory) requestFactory) .setReadTimeout(requestTimeout); - } + } return super.doCreateRestTemplate(endpointProvider, requestFactory); } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/UsernamePasswordAuthentication.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/UsernamePasswordAuthentication.java index 2bffad0022..d8170023bf 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/UsernamePasswordAuthentication.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/UsernamePasswordAuthentication.java @@ -16,21 +16,23 @@ * 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 SecretLeaseContainer package org.apache.guacamole.vault.openbao.secret; +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.VaultToken; import org.springframework.vault.support.VaultResponse; +import org.springframework.vault.support.VaultToken; import org.springframework.web.client.RestTemplate; public class UsernamePasswordAuthentication implements ClientAuthentication { @@ -77,11 +79,20 @@ public VaultToken login() throws VaultException { } String token = (String) vaultResponse.getAuth().get("client_token"); + Boolean renewable = (Boolean) vaultResponse.getAuth().get("renewable"); + Duration leaseDuration = Duration.ofSeconds((long) vaultResponse.getAuth().get("lease_duration")); + String accessor = (String) vaultResponse.getAuth().get("accessor"); + String type = (String) vaultResponse.getAuth().get("type"); if (token == null) { throw new VaultException("No client_token in Vault auth response"); } - return VaultToken.of(token); + return LoginToken.builder().token(token) + .leaseDuration(leaseDuration) + .renewable(renewable) + .accessor(accessor) + .type(type) + .build(); } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/UsernamePasswordAuthenticationOptions.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/UsernamePasswordAuthenticationOptions.java index 03fe267a7f..a543d99e5e 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/UsernamePasswordAuthenticationOptions.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/UsernamePasswordAuthenticationOptions.java @@ -16,7 +16,7 @@ * 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.openbao.secret; diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/user/OpenBaoAttributeService.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/user/OpenBaoAttributeService.java index 06ee6679a6..2836eca969 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/user/OpenBaoAttributeService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/user/OpenBaoAttributeService.java @@ -19,16 +19,17 @@ package org.apache.guacamole.vault.openbao.user; -import org.apache.guacamole.form.Form; -import org.apache.guacamole.vault.conf.VaultAttributeService; - +import com.google.inject.Singleton; import java.util.Collection; import java.util.Collections; +import org.apache.guacamole.form.Form; +import org.apache.guacamole.vault.conf.VaultAttributeService; /** * OpenBao implementation of VaultAttributeService. * Defines attributes that trigger OpenBao secret lookups. */ +@Singleton public class OpenBaoAttributeService implements VaultAttributeService { @Override diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/user/OpenBaoDirectoryService.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/user/OpenBaoDirectoryService.java index 31c24a9a84..72e21e4b05 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/user/OpenBaoDirectoryService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/user/OpenBaoDirectoryService.java @@ -19,6 +19,7 @@ package org.apache.guacamole.vault.openbao.user; +import com.google.inject.Singleton; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.net.auth.ActiveConnection; import org.apache.guacamole.net.auth.Connection; @@ -34,6 +35,7 @@ * Since OpenBao only provides secrets (not user/group/connection management), * all directory methods simply pass through the underlying directories unchanged. */ + @Singleton public class OpenBaoDirectoryService extends VaultDirectoryService { @Override 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..e776954e8c 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 @@ -133,6 +133,27 @@ public String getToken(String name) { return tokenValues.get(name); } + /** + * Returns the value of the token with the given name, or null if no such + * token has been set. + * + * @param name + * The name of the token to return. + * + * @param modifier + * A modifier that might or not be part of the token + * + * @return + * The value of the token with the given name, or null if no such + * token exists. + */ + public String getToken(String name, String modifier) { + if (modifier != null && tokenValues.containsKey(name + ":" + modifier)) + return tokenValues.get(name + ":" + modifier); + else + return tokenValues.get(name); + } + /** * Removes the value of the token with the given name. If no such token * exists, this function has no effect. @@ -226,7 +247,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 bbd057e7157d11223c74cceca9ce6650cbc87aa6 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Thu, 7 May 2026 21:26:44 +0200 Subject: [PATCH 06/16] GUACAMOLE-2196: WIP Openbao/Hashicorp Vault extension - Gets ed25519 ssh to actually work - Replace spring-vault sesion manager with our own implement as broken in spring-vault version 2.3 and doesn't treat non renewable tokens by re-authentication in later versions of spring-vault - Reorganisation vault specific code in to a seperate sub-directory - Fully test everything but the LDAP and database secret engines - Update the documentation --- .../apache-sshd-2.12.1/dep-coordinates.txt | 1 - .../README | 2 +- .../apache-sshd-2.17.1/dep-coordinates.txt | 3 + doc/licenses/net-i2p-crypto/README | 12 + .../net-i2p-crypto/dep-coordinates.txt | 1 + .../guacamole-vault-openbao/.ratignore | 1 - .../include/openbao-optional.properties.in | 17 +- .../docs/include/openbao.properties.in | 2 +- .../docs/openbao.md.j2 | 637 ++++++++++++++---- .../guacamole-vault-openbao/docs/openbao.sh | 141 ++-- .../modules/guacamole-vault-openbao/pom.xml | 14 +- .../OpenBaoAuthenticationProvider.java | 2 + .../OpenBaoAuthenticationProviderModule.java | 8 +- .../conf/OpenBaoConfigurationService.java | 34 +- .../vault/openbao/secret/OpenBaoClient.java | 173 +++-- .../openbao/secret/OpenBaoClientProvider.java | 51 ++ .../openbao/secret/OpenBaoSecretService.java | 29 +- .../vault/openbao/secret/OpenBaoSshKeys.java | 14 +- .../openbao/secret/TimeoutVaultTemplate.java | 85 --- .../FileTokenAuthentication.java | 42 +- .../openbao/vault/TtlAwareSessionManager.java | 493 ++++++++++++++ .../UsernamePasswordAuthentication.java | 43 +- ...UsernamePasswordAuthenticationOptions.java | 2 +- 23 files changed, 1349 insertions(+), 458 deletions(-) delete mode 100644 doc/licenses/apache-sshd-2.12.1/dep-coordinates.txt rename doc/licenses/{apache-sshd-2.12.1 => apache-sshd-2.17.1}/README (90%) create mode 100644 doc/licenses/apache-sshd-2.17.1/dep-coordinates.txt create mode 100644 doc/licenses/net-i2p-crypto/README create mode 100644 doc/licenses/net-i2p-crypto/dep-coordinates.txt create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClientProvider.java delete mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/TimeoutVaultTemplate.java rename extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/{secret => vault}/FileTokenAuthentication.java (68%) create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/TtlAwareSessionManager.java rename extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/{secret => vault}/UsernamePasswordAuthentication.java (74%) rename extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/{secret => vault}/UsernamePasswordAuthenticationOptions.java (98%) diff --git a/doc/licenses/apache-sshd-2.12.1/dep-coordinates.txt b/doc/licenses/apache-sshd-2.12.1/dep-coordinates.txt deleted file mode 100644 index 3f32e8a0f9..0000000000 --- a/doc/licenses/apache-sshd-2.12.1/dep-coordinates.txt +++ /dev/null @@ -1 +0,0 @@ -org.apache.sshd:sshd-common:jar:2.12.1 diff --git a/doc/licenses/apache-sshd-2.12.1/README b/doc/licenses/apache-sshd-2.17.1/README similarity index 90% rename from doc/licenses/apache-sshd-2.12.1/README rename to doc/licenses/apache-sshd-2.17.1/README index dff6a3c8b8..d35bad48f3 100644 --- a/doc/licenses/apache-sshd-2.12.1/README +++ b/doc/licenses/apache-sshd-2.17.1/README @@ -1,7 +1,7 @@ Apache Mina (https://mina.apache.org/sshd-project) -------------------------------------- - Version: 2.12.1 + Version: 2.17.1 From: 'Apache Software Foundation' (https://www.apache.org/) License(s): Apache v2.0 diff --git a/doc/licenses/apache-sshd-2.17.1/dep-coordinates.txt b/doc/licenses/apache-sshd-2.17.1/dep-coordinates.txt new file mode 100644 index 0000000000..549f6c89ab --- /dev/null +++ b/doc/licenses/apache-sshd-2.17.1/dep-coordinates.txt @@ -0,0 +1,3 @@ +org.apache.sshd:sshd-common:jar:2.17.1 +org.apache.sshd:sshd-core:jar:2.17.1 + diff --git a/doc/licenses/net-i2p-crypto/README b/doc/licenses/net-i2p-crypto/README new file mode 100644 index 0000000000..04a5600b96 --- /dev/null +++ b/doc/licenses/net-i2p-crypto/README @@ -0,0 +1,12 @@ +net.i2p.crypto:eddsa +-------------------- + + 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) + https://creativecommons.org/publicdomain/zero/1.0/ 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..de81025be8 --- /dev/null +++ b/doc/licenses/net-i2p-crypto/dep-coordinates.txt @@ -0,0 +1 @@ +net.i2p.crypto:eddsa:jar:0.3.0 diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/.ratignore b/extensions/guacamole-vault/modules/guacamole-vault-openbao/.ratignore index 3c95685f9b..57d24ee9c9 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/.ratignore +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/.ratignore @@ -1,3 +1,2 @@ docs/* docs/*/* - diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao-optional.properties.in b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao-optional.properties.in index db61667470..4efa2dbe9c 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao-optional.properties.in +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao-optional.properties.in @@ -6,16 +6,16 @@ vault-password: my-secret-password # The lifetime of the data in the Guacamole cache in milliseconds. # This is used to avoid multiple concurrent requests for the same -# Vault record with different secret values. After this time the +# Vault record with different secret values. After this time the # secret values are cleared from Guacamole vault-cache-lifetime: 5000 -# The maximum time that a request to the vault server can take in +# The maximum time that a request to the vault server can take in # milliseconds. After this time a null value is returned for the # secret requested vault-request-timeout: 5000 -# The maximum time allowed for a connection to the vault server in +# The maximum time allowed for a connection to the vault server in # milliseconds. After this time a null value is returned for the # secret requested vault-connection-timeout: 5000 @@ -30,8 +30,17 @@ vault-connection-timeout: 5000 # account for clock drift. vault-ssh-connection-timeout: 1800 +# The renewal delay for expiring Vault tokens in miliiseconds. Tokens +# will be renewed prior to expiration by this delay +vault-token-renewal-delay: 10000 + # The type of the SSH certificates generated by Guacamole for signed # SSH certificate access. Valid types are `ed25519` and `rsa`. Only -# 4096-bit RSA certificates are supported +# ed25519 and 4096-bit RSA certificates are supported. +# +# Please note that if your server is configured for strict FIPS-140 +# compliance, then ed25519 certificates will not be available, and +# Guacamole will fallback to using 4096-bit RSA certificates +# automatically vault-ssh-type: ed25519 diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao.properties.in b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao.properties.in index 23a9101b15..7b795bd5fe 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao.properties.in +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao.properties.in @@ -2,5 +2,5 @@ vault-uri: http://localhost:8200 # The authentication token to use to access the vault -vault-token: s.IPzVb3b5dT... +vault-token: s.IPzVb3b5dThhjIrBX245szisjTQcylwSjMEeyJqcidaH8Hf diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 index 58f26f7d8c..82fb9d2026 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 @@ -4,13 +4,15 @@ Retrieving secrets from a vault =============================== -Guacamole supports reading secrets such as connection-specific passwords from a -key vault, automatically injecting those secrets into connection configurations -using [parameter tokens](parameter-tokens) or Guacamole configuration -properties via an additional, vault-specific configuration file analogous to -`guacamole.properties`. The module supports both [OpenBao](https://openbao.org) -and [Hashicorp Vault](https://www.hashicorp.com/products/vault) as the vault -provider. +Guacamole supports retrieving secrets—such as connection-specific passwords, private keys, +certificates, and credentials—from a Vault. These secrets are injected automatically into +connection configurations using parameter tokens or into Guacamole configuration properties +via an additional Vault-specific configuration file analogous to `guacamole.properties`. + +This extension supports both [**OpenBao**](https://openbao.org) and +[**HashiCorp Vault**](https://www.hashicorp.com/products/vault). Throughout this document, +the generic term **Vault** is used to refer to either implementation unless a distinction +is required. ```{include} include/warn-config-changes.md ``` @@ -24,70 +26,78 @@ Installing/Enabling the vault extension (adding-guac-to-vlt)= -### Adding Guacamole to Hashicorp or OpenBao - -Allowing an application like Guacamole to access secrets via an OpenBao -or Hashicorp Vault involves creating appropriate mount paths for the -secret engines and the needed authentication parameters. It is not the -objective of this document to discuss the creation and maintenance of -Vault mount paths and the user is refered to the [OpenBao](https://openbao.org/docs) -or [Hashicorp Vault](https://developer.hashicorp.com/vault/docs) for this -information. +### Adding Guacamole to a Vault This document will discuss examples of setting up the supported -secret engines of the Vault for Guacamole with concrete examples. To remain -independant of both OpenBao and Hashicorp Vault, the configuration below -will be done exclusively with `curl`. +secret engines of the Vault for Guacamole with concrete examples. + +Allowing an application such as Guacamole to access secrets stored in a Vault requires: + +- Enabling the required secret engines +- Creating appropriate mount paths +- Defining access policies +- Configuring authentication credentials + +This document does **not** cover general Vault administration. For details on creating and +managing mount paths, authentication backends, and policies, refer to the official +documentation: + +- https://openbao.org/docs +- https://developer.hashicorp.com/vault/docs + +All configuration examples below use **`curl`** exclusively to remain agnostic to the +specific Vault implementation. #### Vault connection information -The minimum information needed by Guacamole to be setup are in address of the -Vault server, including its associated port, and the means of authentication to the -server. The Vault server uses a standard HTTP REST API and a typical URI might -look like `https://vault.example.com:8200`. +Guacamole requires the network address of the Vault server and valid authentication +credentials. Vault exposes a standard HTTP REST API, and a typical Vault URI looks like: + +``` +https://vault.example.com:8200 +``` #### Setup up a Guacamole Access Policy -Guacamole only needs read access to the secret engine mount path and only to the -paths that are actually used in Guacamole. It therefore makes sense to limit -Guacamole's access to the Vault secrets by a specific policy. It should also be -noted that Guacamole uses the special mount path `sys/mounts` to list the available -mount paths and detect the type of the secret engine used on this mount path. +Guacamole requires only **read access** to the secrets it consumes, plus limited write +access for certain secret engines (such as SSH). For security and auditability, it is +strongly recommended to create a **dedicated Vault policy** for Guacamole. -To allow proper auditing of the access to the Vault, the created policy should be -dedicated to Guacamole. In case of a breach of Guacamole's Vault authentication, this -means that the Vault secrets accessed by the attacker might be known and the number -of remedial actions needed, controlled. +Guacamole also requires read access to the special mount path `sys/mounts` to detect +available secret engines and their types. -So, imagine that Guacamole needs access to the 4 mount paths representing the 4 -secret engines supported by Gaucamole, but only to a subset of the roles on the -mount paths of these secret engines respresented by the path `guacamole` +Below is an example policy that grants Guacamole access to supported secret engines, +restricted to `guacamole` roles in each secret engine used: ``` $ cat << EOT >> gaucamole.hcl -# Read system mount table +// Read system mount table path "sys/mounts" { capabilities = ["read"] } -# Read from guacamole team of ldap mount path for static accounts -path "ldap/static/guacamole/*" { +// Read from key-value secrets dedicated to Guacamole +path "kv/guacamole/*" { capabilities = ["read"] } -# Read from mount path of the SSH engine allowing generation of signed -# certificates for the accounts managed by guacamole -path "ssh/cert/guacamole/*" { - capabilities = ["read"] +// Allow SSH certificate signing for Guacamole +path "ssh/sign/guacamole_cert" { + capabilities = ["create", "update"] } -# Read from mount path of the database mount path dediciated to Guacamole -path "db/guacamole/*" { +// Allow SSH one-time password generation +path "ssh/creds/guacamole_otp" { + capabilities = ["create"] +} + +// Read static LDAP credentials +path "ldap/static/guacamole/*" { capabilities = ["read"] } -# Read from mount path of the key-values store dediciated to Guacamole -path "kv/guacamole/*" { +// Read database credentials +path "db/guacamole/*" { capabilities = ["read"] } EOT @@ -105,26 +115,26 @@ $ curl -sS \ ``` where `VAULT_TOKEN` is a valid Vault token allowing administrative access. It -also assumes that the vault server is running locally and on http. This +also assumes that the vault server is running locally and on http. This creates a policy called `guacamole` in the vault for our use. -The mount paths above are examples and should be adapted the paths you actually +The mount paths above are examples and should be adapted to the paths you actually use. #### Creating a key-values store for guacamole The simplest means of storing and accessing secrets in a Vault is using the key-values secret engine. This secret engine can store arbitrary values associated with a key. -Appropriate uses of this secret engine are for unmanaged accounted of the client machines, +Appropriate uses of this secret engine are for unmanaged accounted on client machines, certificates, etc. The secret rotation features of the Vault can not be used with this secret engine, so the use of this secret engine for password storage means that the user is entirely responsable for the adherance to any corporate password policies. -The key-values secret engine, comes in two varieties. The older version 1, where only +The key-values secret engine, comes in two varieties. The older version 1, where only the latest values are stored, or the newer version 2 that also stores metadata about the -stored values ,including the last time the values were changed. +stored values, including the last time the values were changed. -The first step is to enable to secret engine. To enable the version 1 secret engine of a +The first step is to enable the secret engine. To enable the version 1 secret engine on a mount path `kv1`, the command might be ``` @@ -147,10 +157,10 @@ to the version 1 secret engine the command is ``` curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ --request POST --data '{"username": "bruce", "password": "wayne"}' \ - http://127.0.0.1:8200/v1/kv1/users/batman + http://127.0.0.1:8200/v1/kv1/guacamole/batman ``` -Here the path of the key is `users/batman`. Complex paths are allowed to seperate groups +Here the path of the key is `guacamole/batman`. Complex paths are allowed to seperate groups of users. The stored secrets are `username` and `password`. However, any string could be used for these. @@ -160,13 +170,17 @@ command to the above being ``` curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ --request POST --data '{"data": {"username": "bruce", "password": "wayne"}}' \ - http://127.0.0.1:8200/v1/kv2/data/users/batman + http://127.0.0.1:8200/v1/kv2/data/guacamole/batman ``` #### Creating an SSH secret engine for Guacamole -The SSH secret engine allows two means of operation: signed SSH certificates or one-time -passwords. The better more modern of these is signed SSH certificates, though Guacamole +The SSH secret engine allows two means of operation: + +- signed SSH certificates (Recommanded), or +- one-time passwords. + +The better more modern of these is signed SSH certificates, though Guacamole supports both SSH methods. We first need to enable the ssh secret engine with a command like @@ -178,7 +192,7 @@ curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: applicati which mounts the secret engine on the mount path `ssh`. For certificate signing, the Vault needs a valid CA certificate and its private key. These might be uploaded to the Vault, but -for this example we let the Vault create these value itself. The command for this is +for this example we let the Vault create these values itself. The command for this is ``` curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ @@ -191,14 +205,24 @@ file `/etc/ssh/trusted_user_ca.pem`. SSH of these target machines must then be c certificates signed with this public key. For OpenSSH this could then be achieved by adding ``` -TrustedUserCAKeys /etc/ssh/trusted_user_ca.pem +TrustedUserCAKeys /etc/ssh/trusted_user_ca ``` to the file `/etc/ssh/sshd_config`. +and save the generated `public_key` above in `/etc/ssh/trusted_user_ca` on the target +machine and force openssh to reread it configuration with a command like + +``` +pkill -SIGHUP /usr/sbin/sshd +``` + +Send a HUP signal to the master sshd process in openssh, forces the configuration to +be reloaded without interupting existing ssh connections. + The next step to allow signed SSH certificates is setting up a role in the SSH secret engine for Guacamole. Guacamole needs the `permit-pty` option of ssh, and so the creation -of a signing role `signer` might be done with the command +of a signing role `guacamole_cert` might be done with the command ``` $ curl -sS \ @@ -206,45 +230,47 @@ $ curl -sS \ --header "Content-Type: application/json" \ --request POST \ --data '{ - "algorithm_signer": "rsa-sha2-256", - "allow_user_certificates": true, - "allowed_users": "*", - "key_type": "ca", - "max_ttl": "30m", - "allowed_extensions": "permit-tty"}' \ - http://127.0.0.1:8200/v1/ssh/roles/signer + "algorithm_signer": "rsa-sha2-256", + "allow_user_certificates": true, + "allowed_users": "*", + "key_type": "ca", + "max_ttl": "30m", + "allowed_extensions": "permit-pty"}' \ + http://127.0.0.1:8200/v1/ssh/roles/guacamole_cert ``` -The `max_ttl` value limits the life of the certificates signed by Guacamole to +The `max_ttl` value limits the life of the certificates signed by Guacamole to 30 minutes. Small values here ensure that the risk of abuse of the signed certificates -is limited, but the value must taken into account both the latence of the ssh conection +is limited, but the value must taken into account both the latence of the ssh conection times and the clock drift between the Vault and the SSH client machines. So there is a limit to how small this value might be without causing problems. The Vault is now ready for Guacamole to use to signed SSH certificates. To allow the use of one-time passwords, a specific role needs to be created. The command to create a role -`generate_otp` for a default user `testuser` is +`guacamole_otp` for a default user `testuser` is ``` curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ --request POST --data '{"key_type": "otp", "default_user": "testuser"}' \ - http://127.0.0.1:8200/v1/ssh/roles/generate_otp + http://127.0.0.1:8200/v1/ssh/roles/guacamole_otp ``` The created role can only be used to create one-time passwords for the account `testuser` -as Guacamole will only request the default user. +and the Vault's one-time password secret engine doesn't not support the user of the same +role for different username. If multiple users are required, then multiple roles with +different default users should be created. Note that the `cidr_list` option is ommitted here. The problem is that the SSH connections from Guacamole will appear to come from the `guacd` deamon, and within Guacamole's Vault -secret driver we do not have access to the IP address of `guacd`. So Guacamole requests +secret driver we do not have access to the IP address of `guacd`. So Guacamole requests one-time passwords without specifying and IP address. This needs an additional change in the Vault. To allow the role `generate_otp` to be used without IP addresses, the additional command is ``` curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ - --request POST --data '{"roles": "generate_otp"}' \ - http://127.0.0.1:8200/v1/ssh/config/zeroaddress + --request POST --data '{"roles": "guacamole"}' \ + http://127.0.0.1:8200/v1/ssh/config/zeroaddress ``` The target machine needs to be setup for the Vault one-time password. Firstly SSH must be @@ -255,14 +281,13 @@ passwords the following line might be added to the top of `/etc/pam.d/sshd`. auth sufficent pam_exec.so expose_authtok /usr/local/bin/verify_otp.sh ``` -This is only an example that is guarenteed to work the one-time passwords without -preventing the existing methods from failing. The user will surely need to adapt +This is only an example that is guarenteed to work the one-time passwords without +preventing the existing methods from failing. The user will surely need to adapt this to their installation. The final piece needed on the client machine is the script `/usr/local/sbin/verify_otp.sh`. A possible script that tests that the user corresponds to the user allowed by the one-time -password and that the one-time password is correct in - +password and that the one-time password is correct in ``` #!/bin/sh @@ -281,12 +306,12 @@ RESPONSE=$(curl -s \ --header "Content-Type: application/json" \ --data '{"otp": "'${OTP}'"}' \ http://127.0.0.1:8200/v1/ssh/verify) -[ "$?" -eq 0 ] || exit 4 +[ "$?" -eq 0 ] || exit 3 -[ "$(echo $RESPONSE | jq -r .data.username)" = "$USERNAME" ] || exit 5 +[ "$(echo $RESPONSE | jq -r .data.username)" = "$USERNAME" ] || exit 4 ``` -that should be saved at `/usr/local/sbin/verify_otp.sh` and made executable with the +that should be saved at `/usr/local/sbin/verify_otp.sh` and made executable with the command ``` @@ -300,19 +325,360 @@ encouraged to look at the [Hashicorp Vault SSH Helper](https://github.com/hashic #### Creating an LDAP secret engine for Guacamole - #TODO - +The LDAP secret engine allows Vault to supply credentials backed by an LDAP directory, +such as OpenLDAP or Microsoft Active Directory. Guacamole can use these credentials for +connections that authenticate against directory services. + +As Vault will rotate its own LDAP password you are highly recommanded to use an LDAP account +dedicated to Vault. This should first be created on the LDAP server. For the example below +we assume that all user account are in the LDAP DN "ou=users,dc=example,dc=com". In real +deployment it would probably be better to use dedicated DN of hashicorp managed accounts and +limit the write permissions of hashicorp to these specific DN. + +We first need to generate an LDAP password hash for the initial password for Vault. The +exact command for this will depend on the hash used. For example, to use a modern password +hash like Argon2, if server uses openldap and supports it, a command to generate the password +hash might be + +``` +$ slappasswd -o module-load=argon2.so -h '{ARGON2}' -s your_secret_password_here +{ARGON2}$argon2id$v=19$m=7168,t=5,p=1$4JbzrJjMiH8x6Qfe5WsNtA$n5tzU8dGW2lFE+KDJRCLImKK+feE+mZaBmDP7nE9fyI +``` + +This password hash must then be used in an LDIF file and added to the ldap server with +ldapadd like + +``` +$ cat << EOLDIF > adduser.ldif +dn: uid=vault,ou=users,dc=example,dc=com +objectClass: top +objectClass: person +objectClass: organizationalPerson +objectClass: inetOrgPerson +uid: vault +cn: Vault User +sn: Vault +displayName: Vault +description: Account used by Vault Server +userPassword: {{ARGON2}$argon2id$v=19$m=7168,t=5,p=1$4JbzrJjMiH8x6Qfe5WsNtA$n5tzU8dGW2lFE+KDJRCLImKK+feE+mZaBmDP7nE9fyI +EOLDIF +$ ldapadd -x -D "cn=admin,dc=example,dc=com" -W -f adduser.ldif +``` + +The exact LDIF might vary depending on the configuration of your ldap server. +For static, or existing accounts, the Vault only needs read permission and +the permission of modify the passwords of accounts Managed DN. Most LDAP +are configured to allow read access, except to the passwords. So only the +permission for access to the attribute `userPassword` will need to be changed. + +If an `olcAcess` for the attribute `userPassword` does not exist an example +of a change allowing access to `userPassword` of all users in the LDAP +for Vault might be + +``` +$ cat << EOLDIF > userpassword.ldif +dn: olcDatabase={1}mdb,cn=config +changetype: modify +add: olcAccess +olcAccess: {0}to attrs=userPassword + by dn.exact="uid=vault,ou=users,dc=example,dc=com" write + by self write + by anonymous auth + by dn.base="cn=admin,dc=example,dc=com" manage + by * none +EOLDIF +$ ldapmodify x -D "cn=admin,cn=config" -W -f userpassword.ldif +``` + +if the block already exists then if will need to be modified instead, adding +the line for the Vault server, like + +``` +$ cat << EOLDIF > userpassword.ldif +dn: olcDatabase={2}mdb,cn=config +changetype: modify +replace: olcAccess +olcAccess: {0}to attrs=userPassword + by dn.exact="uid=vault,ou=users,dc=example,dc=com" write + by self write + by anonymous auth + by dn.base="cn=admin,dc=example,dc=com" manage + by * none +EOLDIF +$ ldapmodify x -D "cn=admin,cn=config" -W -f userpassword.ldif +``` + +Be careful to keep any existing rules. + +:::warning +The above change gives the Vault server the permission to change all passwords +on the LDAP server in the DN "ou=users,dc=example,dc=com". For this reason the +best pracitices is to isolate the Vault managed account in a seperate DN and +only give write permission on this DN. Then the change might instead be + +``` +$ cat << EOLDIF > userpassword.ldif +dn: olcDatabase={2}mdb,cn=config +changetype: modify +replace: olcAccess +olcAccess: {0}to dn.subtree="ou=vault,dc=example,dc=com" attrs=userPassword + by dn.exact="uid=vault,ou=users,dc=example,dc=com" write + by anonymous auth + by dn.base="cn=admin,dc=example,dc=com" manage + by * none +EOLDIF +$ ldapmodify x -D "cn=admin,cn=config" -W -f userpassword.ldif +``` + +This rule needs to be before the generique `userPassword` rule to be effective +::: + +If you wish to use LDAP dynamic roles with Vault, the Vault server needs to be able +to add accounts to the LDAP server, and so permission to write to all attributes of +the managed DN. This might be done like + +``` +$ cat << EOLDIF > acl.ldif +dn: olcDatabase={1}mdb,cn=config +changetype: modify +add: olcAccess +olcAccess: {3}to dn.subtree="dc=example,dc=com" + by dn.exact="uid=vault,ou=users,dc=example,dc=com" write +EOLDIF +$ ldapmodify -x -D "cn=admin,cn=config" -W -f acl.ldif +``` + +where the values might vary with your LDAP installation. The next step is to +setup a password policy for the LDAP server, giving the rules for the passwords +in will generate when rotating passwords. An example policy might be created +in the file `ldap-policy.hcl` and uploaded to the vault server like + +``` +$ cat << EOHCL > ldap-policy.hcl +length = 20 + +rule "charset" { + charset = "abcdefghijklmnopqrstuvwxyz" + min_chars = 1 +} + +rule "charset" { + charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + min_chars = 1 +} + +rule "charset" { + charset = "0123456789" + min_chars = 1 +} + +rule "charset" { + charset = "!@#%^&*" + min_chars = 1 +} +EOHCL +$ curl -s --header "X-Vault-Token: $VAULT_TOKEN" \ + --request POST --data-urlencode "policy@ldap-policy.hcl" \ + http://127.0.0.1:8200/v1/sys/policies/password/ldap-policy +``` + +At this point you are now ready to connect your Vault server to your LDAP +with the previously created credentials. + +``` +$ curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + --request PUT \ + --data '{ + "schema": "openldap", + "url": "ldaps://easyctf", + "binddn": "uid=vault,ou=users,dc=easyctf", + "bindpass": "your_secret_password_here", + "password_policy": "ldap-policy", + "insecure_tls": true + }' \ + http://127.0.0.1:8200/v1/ldap/config +``` + +The `insecure_tls` option is used here only for testing. In production you should +ensure that the LDAP certificates ares signed by a certificate authority and the +CA certificate is installed in the above command with either `tls_ca_cert` or +`tls_ca_cert_file` options. + +Before we go any further it is important at this point to ensure that the Vault +server can actually communicate with the LDAP server, and there are no configuration +issues either in the previous commands or the network. These can be done by forcing +the Vault server to rotate its password with the command + +``` +$ curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --request POST \ + http://127.0.0.1:8200/v1/ldap/rotate-root +``` + +If the command executes correctly, the Vault password has beed changed and the Vault +server can communicate with the LDAP server + +To create a static role for Guacamole, the command is slightly different for OpenBao +and Hashicorp Vault. + +**Hashicorp Vault static ldap example:** + +``` +$ curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + --request POST \ + --data '{ + "role_name": "guacamole", + "username": "guacamole", + "dn": "cn=guacamole,ou=users,dc=example,dc=com", + "rotation_period": "1h" + }' \ + http://127.0.0.1:8200/v1/ldap/static-role +``` + +**OpenBao Vault static ldap example:** + +``` +$ curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + --request POST \ + --data '{ + "username": "guacamole", + "dn": "cn=guacamole,ou=users,dc=example,dc=com", + "rotation_period": "1h" + }' \ + http://127.0.0.1:8200/v1/ldap/static-role/guacamole +``` + +This will manage an existing account with the username `guacamole` on the LDAP +server, rotating its password as required. + + #TODO Dynamic Accounts !! + +Guacamole does not manage LDAP lease revocation and does not attempt to check in +credentials after use. + #### Creating a Database secret engine for Guacamole - #TODO +The first step in allowing the Vault server to manage the database is to create +a role for the vault within the database used by Guacamole. As the vault server +can and does modify its own password in the database, it is strong recommanded +that this created account is used only by the Vault server itself. + +In this below we assume thay Postgresql is used, and the commands might need to be +slightly modified for other databases. A command to create a role for the Vault +server in our database, with th enecessary permission will be something like + +``` +$ cat << EOSQL > psql --dbname "guacamole_db" +CREATE ROLE vault_admin WITH + LOGIN + PASSWORD 'secure_password_here' + CREATEROLE + NOINHERIT; +GRANT CREATE ON DATABASE guacamole_db TO vault_admin; +GRANT USAGE ON SCHEMA public to vault_admin; +EOSQL +``` + +The next step is to activate the database secret engine of the Vault + +``` +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --request POST --data '{"type": "database"}' http://127.0.0.1:8200/v1/sys/mounts/db +``` + +and then create a role for Guacamole needs to be created with the desired permissions -#### Authentication using a Token + +``` +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --request POST --data '{ + "db-name": "guacamole_db", + "default_ttl": "1h", + "max_ttl": "24h", + "create_statements": "CREATE ROLE \"{{name}}\" + WITH LOGIN PASSWORD \"{{password}}\" + VALID UNTIL \"{{expiration}}\"; + GRANT SELECT,INSERT,UPDATE,DELETE + ON ALL TABLES IN SCHEMA public + TO \"{{name}}\"; + GRANT SELECT,USAGE ON ALL SEQUENCES + IN SCHEMA public + TO \"{{name}}\"; + GRANT ALL ON SCHEMA public TO \"{{name}}\";", + "rotation_statements": "ALTER ROLE \"{{name}}\" + WITH PASSWORD \"{{password}}\";"}' \ + http://127.0.0.1:8200/v1/sys/mounts/db +``` + +This role assumes that Guacamole has already created all of its databases and the permissions +needed afterwards are more limited. With this role the Vault will supply Guacamole with a new +username and password every 24 hours, and both will be random. If you prefer to use a static +username the `{{name}}` field should be replaced with the desired username and flags as static +as follows + +``` +curl -s --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + --request POST --data '{ + "db-name": "guacamole_db", + "default_ttl": "1h", + "max_ttl": "24h", + "create_statements": "CREATE ROLE \"guacamome_user\" + WITH LOGIN PASSWORD \"{{password}}\" + VALID UNTIL \"{{expiration}}\"; + GRANT SELECT,INSERT,UPDATE,DELETE + ON ALL TABLES IN SCHEMA public + TO \"guacamole_user\"; + GRANT SELECT,USAGE ON ALL SEQUENCES + IN SCHEMA public + TO \"guacamole_user\"; + GRANT ALL ON SCHEMA public TO \"guacamole_user\";", + "rotation_statements": "ALTER ROLE \"guacamole_user\" + WITH PASSWORD \"{{password}}\";", + "static_username": true}' \ + http://127.0.0.1:8200/v1/db/roles/guacamole +``` + +at this point we can now configure the database plugin with the desired role as follows + +``` +curl -s --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + --request POST --data '{"plugin_name": "postgresql-database-plugin", + "connection_url": "postgresql://{{username}}:{{password}}@127.0.0.1:5432/guacamole_db", + "allowed_roles": "guacamole", + "username": "vault_admin"; + "password": "secure_password_here"}' + http://127.0.0.1:8200/v1/db/config/guacamole_db +``` + +The username and password here are for the account created for the Vault in our +database. + +### Authenticating Guacamole to the Vault + +#### Authentication using a Token String Guacamole can authenticate to OpenBao using a token. This token must use a role -as previously discussed above and it must not expire. Guacamole can not be guarenteed -to be able to renew a static expiring token, especially after a reboot. +as previously discussed above and in most cases it should not expire. Guacamole +will attempt to renew tokens if they are marked as renewable. However, the vault +server imposes a maximum lifetime of renewable tokens that is by default is +32 days, and after that time Guacamole will not be able to renew the token. + +Also if Guacamole does not suceed in contacting the vault for a period corresponding +to the token's TTL, the token will have expired and not be renewable. THis might +cause problems in acse of network problems or server reboots. So if you choose to +supply a token as a string to Guacamole you are encouraged to use a non expiring +token (with a TTL of zero). -To create a static token for use with Guacamole, a command like +To create a static token for use with Guacamole, a command like ``` @@ -329,10 +695,11 @@ $ curl -sS \ }' ``` -where the `ttl` or zero ensures that the token will not expire. The token to use -is stored in the value of `.auth.client_token`. +where the `ttl` or zero ensures that the token will not expire. The previously created +`guacamole` policy is used for the created token. The token to use is stored in the value +of `.auth.client_token`. -#### Authentication use a Token sink file +#### Authentication using a Token sink file Guacamole does not implement complex authentication methods, such as AppRole. But these can still be used with the help of a [Vault agent](https://openbao.org/https://openbao.org/docs/agent-and-proxy/agent). @@ -345,14 +712,14 @@ encouraged to read the [OpenBao Vault Agent](https://openbao.org/docs/agent-and- or [Hashicorp Vault Agent](https://developer.hashicorp.com/vault/docs/agent-and-proxy/agent) documentation. These vault agents can be used with AppRole authentication as discussed for [OpenBao AppRole](https://openbao.org/docs/auth/approle) or -[Hashicorp AppRole](https://developr.hashicorp/vault/docs/auth/approle) +[Hashicorp AppRole](https://developer.hashicorp/vault/docs/auth/approle) secrets. #### Authentication using Username and Password If the authentication to Vault is via a username and password, we first need to have the `userpass` authentication method mounted which it isn't be default. Assuming -a auth mount path of `auth/userpass` this can be done with the command +an authentication mount path of `auth/userpass` this can be done with the command ``` curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ @@ -370,18 +737,22 @@ curl \ http://127.0.0.1:8200/v1/auth/userpass/users/guacamole \ -d '{ "password": "strong-password-here", - "policies": ["guacamole"] + "policies": ["guacamole"], + "token-ttl": "10m", + "token-max-ttl": "60m" }' ``` This creates a user `guacmaole` using the same policy as previously and sets the password assuming the userpass engine is mounted on the default path `auth/userpass`. +In the above example the TTL of the token is 10 minutes. Guacamole with renew the +token recovered on the login upto 60 minutes or the maximum TTL for the token. After +Guacamole will reauthenticate and recover a new token. It should be noted that the Vault can not rotate the passwords of these user accounts. So the maintenance and update of the password needs to be done manually, updating the password configured in Guacamole at the same time. - (guac-vault-config)= Required configuration @@ -442,7 +813,7 @@ based on connection parameters: available. If automatic injection of secrets cannot work for your use case, consider using -[manually-specified secrets via `openbao-token-mapping.yml`](vault-static-secrets). +[manually-specified secrets via `vault-token-mapping.yml`](vault-static-secrets). ::: Parameter tokens injected from Vault records usually take the form @@ -457,12 +828,12 @@ and detects their type. The selection of which secret engine that Guacamole used therefore based the value of `MOUNT_PATH`. It is possible to have a value of the mount path like `subpath1` and a second mount path -`subpath1/subpath2`. Guacamole therefore chooses the secret engine with the longest +`subpath1/subpath2`. Guacamole therefore chooses the secret engine with the longest match to the start of the token. #### Secret Engines and paths supported -The following Vault secret engines are supported +The following Vault secret engines are supported `LDAP` : The Vault secret engine supports both open source ldap servers and Microsoft Active @@ -470,31 +841,31 @@ The following Vault secret engines are supported a fixed usename, a `dynamic` role is an account created and deleted by the vault for the demanded operation, and a `service` role is a pool of accounts managed by the vault that can be used. - + Guacamole simplifies the tokens for the LDAP paths in the following manner - + ``` ${vault://{MOUNT_PATH}/{static|dynamic|service}/{ROLE}/{username/password}} ``` - - An example of a valid token might then be `${vault://ldap/static/myaccount/username}`, - where the value of `ROLE` here is `myaccount`. - + + An example of a valid token might then be `${vault://ldap/static/guacamole/username}`, + where the value of `ROLE` here is `guacamole`. + Please note that Guacamole does not touch the lease times associated with these accounts in the vault and it does not check in the service accounts after use. `SSH` -: The Vault secret engine support both one-time passwords and signed certificate modes of +: The Vault secret engine support both one-time passwords and signed certificate modes of the secret engine. This secret engine required a helper command on the client machines and is defined by its role. Tokens for one-time passwords are of the form - + ``` ${vault://{MOUNT_PATH}/otp/{ROLE}/{username/password}} ``` An example of a valid token might then be `${vault://ssh/otp/myaccount/username}`, where the value of `ROLE` here is `myaccount`. - + Vault does not support the creation of certificates itself, so the role for SSH certificate creation is left to Guacamole. A valid SSH token for the creation of a certificate is @@ -508,24 +879,27 @@ ${vault://{MOUNT_PATH}/cert/{ROLE}/{public/private}} by the `username` of the connection and the certificate will be limited for the use of only this user. Also as Guacamole does not need port forwarding the certificate is created in a manner that refuses all use of port forwarding. - + `DATABASE` : Guacamole supports all databases supported by the Vault database secret engine. The form -of the database tokens are + of the database tokens are ``` ${vault://{MOUNT_PATH}/{ROLE}/{username/password}} ``` + So that Guacamole can use thes values, they must be placed the the + `guacamole.properties.vlt` file. + `Key-Values` : The Vault key-values secret engine is a generic secret engine to store arbitrary - secrets. This means that it is adapted to clients that are not managed by one of + secrets. This means that it is adapted to clients that are not managed by one of the other three secret engines supported by Guacamole. For example, this engine might be used for a password on an unmanaged account of a firewall. - + Both the path and secret values of key-value tokens are completely arbitrary and a valid token might be of the form - + ``` ${vault://{MOUNT_PATH}/{PATH}/{SECRET} ``` @@ -533,35 +907,34 @@ ${vault://{MOUNT_PATH}/{PATH}/{SECRET} So a token like `vault://kv/org/example/fw1/adminuser` where `kv` is the mount path, `org/example/fw1` the path the the record and `adminuser` the username of associated with a generic account. - + In addition, the following sub-tokens can be used with the vault token to modify -its value when it is referenced. These sub-tokens are of the form `{SUB_TOKEN}` +its value when it is referenced. These sub-tokens are of the form `{SUB_TOKEN}` within the vault token itself. If `{SUB_TOKEN}` is valid literal with the vault token, you can use `$${SUB_TOKEN}` to ensure it is interpreted as `{SUB_TOKEN}`. The allowed sub-token values are: `{USER}` -: The record whose "login" field contains a username that matches the value of - the `username` parameter of the connection. If the record has no "login" field, - a "text" or "password" custom field will be used if the label of that field - contains the word "username" (case-insensitive). +: The value of the token ${GUAC_USERNAME}, the current logged in user, is + substituted into the vault token before it is looked up. `{SERVER}` -: The record whose "login" field contains a hostname that matches the value of - the `hostname` parameter of the connection. If the record has no "login" field, - a "text" or "password" custom field will be used if the label of that field - contains the word "hostname", "address", or "IP address" (case-insensitive, - ignoring any spaces between "IP" and "address"). If the record is a KeeperPAM - resource with linked credentials, it will use the linked administrative credentials. +: The hostname field of the connection is substituted into the vault token + before it is looked up. The hostname of the connection must not be empty + or itself include a token. `{GATEWAY}` : Identical to `SERVER`, except that the value of the `gateway-hostname` - parameter is used. This is only applicable to RDP connections. + parameter is used. This is only applicable to RDP connections. The + `gateway-hostname` of the connection must not be empty or itself include a + token. `{GATEWAY_USER}` : Identical to `USER`, except that the value of the `gateway-username` - parameter is used. This is only applicable to RDP connections. + parameter is used. This is only applicable to RDP connections. The + `gateway-username` of the connection must not be empty or itself include a + token. For example `vault://ldap/org/example/{SERVER}/{USER}/password` is a valid vault token. @@ -570,11 +943,11 @@ For example `vault://ldap/org/example/{SERVER}/{USER}/password` is a valid vault ### Manual definition of secrets Parameter tokens can be manually defined by placing a YAML file within -`GUACAMOLE_HOME` called `openbao-token-mapping.yml`. This file must contain a set +`GUACAMOLE_HOME` called `vault-token-mapping.yml`. This file must contain a set of name/value pairs where each name is the name of a token to define and each value is a token in the same form as the above. -For example, the following `openbao-token-mapping.yml` defines two parameter +For example, the following `vault-token-mapping.yml` defines two parameter tokens, `${WINDOWS_ADMIN_PASSWORD}` and `${LINUX_SERVER_KEY}`, each pulling their values from different parts of different records in KSM: @@ -603,12 +976,12 @@ Secrets can be used for any Guacamole configuration property that isn't required to configure the Vault support. For example, the following `guacamole.properties.vlt` defines both the -`mysql-username` and `mysql-password` properties using values from a +`postgresql-username` and `postgresql-password` properties using values from a role in the Vault database secret engine. ``` -mysql-username: vault://db/guacamole/username -mysql-password: vault://db/guacamole/password +postgresql-username: vault://db/guacamole_db/username +postgresql-password: vault://db/guacamole_db/password ``` The secret engine in this case is mounted on the path `db/` and this secret diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.sh b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.sh index f9381a1b95..e5e8a165cf 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.sh +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.sh @@ -12,6 +12,35 @@ docker run --detach --name openbao --publish 8200:8200 openbao/openbao:2.5 > /de sleep 2 VAULT_TOKEN=$(docker logs openbao 2> /dev/null | grep "Root Token" | cut -d: -f2 | xargs) UNSEAL_KEY=$(docker logs openbao 2> /dev/null | grep "Unseal" | cut -d: -f2 | xargs) +echo "# Create Guacamole limited access policy" +cat << EOF > guacamole.hcl +path "sys/mounts" { + capabilities = ["read"] +} +path "kv1/*" { + capabilities = ["read"] +} +path "kv2/*" { + capabilities = ["read"] +} +path "ssh/sign/guacamole_cert" { + capabilities = ["update", "create"] +} +path "ssh/creds/guacamole_otp" { + capabilities = ["update"] +} +path "ldap/*" { + capabilities = ["read"] +} +path "db/*" { + capabilities = ["read"] +} +EOF + +curl -s --header "X-Vault-Token: $VAULT_TOKEN" \ + --request POST --data-urlencode "policy@guacamole.hcl" \ + http://127.0.0.1:8200/v1/sys/policy/guacamole +/bin/rm guacamole.hcl echo "# Enable KV_1 secret engine" curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ @@ -53,43 +82,16 @@ USER_CA=$(curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: echo "# Create SSH certificate signing role 'signer'" curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ - --request POST --data '{"algorithm_signer": "rsa-sha2-256", "allow_user_certificates": true, "allowed_users": "*", "key_type": "ca", "max_ttl": "30m", "allowed_extensions": "permit-tty"}' \ - http://127.0.0.1:8200/v1/ssh/roles/signer + --request POST --data '{"algorithm_signer": "rsa-sha2-256", "allow_user_certificates": true, "allowed_users": "*", "key_type": "ca", "max_ttl": "30m", "allowed_extensions": "permit-pty"}' \ + http://127.0.0.1:8200/v1/ssh/roles/guacamole_cert echo "# Create SSH OTP for account 'testuser'" curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ --request POST --data '{"key_type": "otp", "default_user": "testuser", "cidr_list": "0.0.0.0/0"}' \ - http://127.0.0.1:8200/v1/ssh/roles/generate_otp + http://127.0.0.1:8200/v1/ssh/roles/guacamole_otp curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ - --request POST --data '{"roles": "generate_otp"}' \ - http://127.0.0.1:8200/v1/ssh/config/zeroaddress - - -echo "# Create Guacamole limited access policy" -cat << EOF > guacamole.hcl -path "sys/mounts" { - capabilities = ["read"] -} -path "kv1/*" { - capabilities = ["read"] -} -path "kv2/*" { - capabilities = ["read"] -} -path "ssh/*" { - capabilities = ["read"] -} -path "ldap/*" { - capabilities = ["read"] -} -path "db/*" { - capabilities = ["read"] -} -EOF -curl -s --header "X-Vault-Token: $VAULT_TOKEN" \ - --request POST --data-urlencode "policy@guacamole.hcl" \ - http://127.0.0.1:8200/v1/sys/policy/guacamole -/bin/rm guacamole.hcl + --request POST --data '{"roles": "guacamole"}' \ + http://127.0.0.1:8200/v1/ssh/config/zeroaddress echo "# Enable userpass authentication and create guacamole user in vault" curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ @@ -103,8 +105,6 @@ curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: applicati --request POST --data '{"password": "'$USER_PASSWORD'", "policies": "guacamole"}' \ http://127.0.0.1:8200/v1/auth/userpass/users/guacamole | jq - - echo echo "# OpenBao root token : $VAULT_TOKEN" echo "# Openbao Unsealing key : $UNSEAL_KEY" @@ -118,23 +118,36 @@ cat << EOF # # 0. Setup test kali server with a test account with username and passwrd kali/kali # Add an account 'testuser' to the kali machine that will be used with SSH OTP -# utility. Setup xrdp on the kali machine +# utility. Setup xrdp on the kali machine # # Setup a second machine with accounts managed by LDAP, and give LDAP credentials -# to OpenBao +# to OpenBao # 1. Add following to guacamole.properties and restart guacamole, to test with root token # vault-uri: http://localhost:8200 # vault-token: $VAULT_TOKEN -# 2. Add the tokens to my test kali server with RDP +# 2. Add the tokens to my test kali server with RDP # \${vault://kv1/users/kali/password} and \${vault://kv1/users/kali/username} # Test that connection kali server actually works -# 3. Add the tokens to my test kali server with SSH + +# 3. Test Guacamole with a token with limited rights and an infinite TTL +# First generate the token with the short TTL and limited right + +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --request POST --data '{"policies": ["guacamole"], ttl: "0m", "renewable": true}' \ + http://127.0.0.1:8200/v1/auth/token/create | jq + +# Add the printed token to the 'vault-token' in gaucamole.properties and restart +# Guacamole. +# +# Test that the previous test still works. This token will be used from on out +# so that the limited permissions are tested +# 4. Add the tokens to my test kali server with SSH # \${vault://kv1/users/kali/password} and \${vault://kv1/users/kali/username} # Test that connection kali server actually works -# 4. Add the tokens to my test kali server with SSH +# 5. Add the tokens to my test kali server with SSH # \${vault://kv2/users/kali/password} and \${vault://kv2/users/kali/username} # Test that connection kali server actually works -# 5. Add 'TrustedUserCAKeys' to test kali server, +# 6. Add 'TrustedUserCAKeys' to test kali server, # # Do the following commands on test kali machine: @@ -143,32 +156,32 @@ $USER_CA EOT chmod 700 /etc/ssh/trusted_user_ca.pem chown sshd:sshd /etc/ssh/trusted_user_ca.pem -echo "TrustedUserCAKeys /etc/ssh/trusted_user_ca.pem" >> /etc/ssh/sshd_config +echo "TrustedUserCAKeys /etc/ssh/trusted_user_ca.pem" >> /etc/ssh/sshd_config pkill -SIGHUP sshd -# Use the username "kali" in SSH connection and add theses tokens to +# Use the username "kali" in SSH connection and add theses tokens to # the private and public ssh keys # -# \${vault://ssh/cert/signer/private} and \${vault://ssh/cert/signer/public} +# \${vault://ssh/cert/guacamole_sign/private} and \${vault://ssh/cert/guacamole_sign/public} # # Test that the SSH connection to the kali machine works -# 6. Use RSA SSH certiciates. The previous tested used the default "ed25519" ssh +# 7. Use RSA SSH certiciates. The previous tested used the default "ed25519" ssh # certificates. Add the following to guacamole.properties # vault-ssh-type: rsa # Restart guacamole. Test that the SSH connection to the kali machine -# works +# works # # Run the following command on the test kali server sed -i -e "/^TrustedUserCAKeys/d" /etc/ssh/sshd_config -# 7. [TODO OTP SSH] Ensure the vault otp helper is on tehe test kali machine and configure +# 8. Ensure the vault otp helper is on the test kali machine and configure # Add the following tokens to the test kali connection -# \${vault://ssh/otp/generate_otp/username} and \${vault://ssh/otp/generate_otp/password} +# \${vault://ssh/otp/guacamole_otp/username} and \${vault://ssh/otp/guacamole_otp/password} # Use the username 'testuser' on the SSH connection, so and this user to the test machine # if needed. Now setup the test machine with the code -cat << EOF >> /usr/local/bin/vault_verify_otp.sh +cat << EOT >> /usr/local/sbin/verify_otp.sh #!/bin/sh set -u @@ -188,36 +201,25 @@ RESPONSE=\$(curl -s \ [ "\$?" -eq 0 ] || exit 3 [ "\$(echo \$RESPONSE | jq -r .data.username)" = "\$USERNAME" ] || exit 4 -EOF +EOT chmod 755 /usr/local/bin/vault_verify_otp.sh -sed -i '1s:^:auth sufficent pam_exec.so expose_authtok /usr/local/bin/vault_verify_otp.sh:' /etc/pam.d/sshd +sed -i '1s:^:auth sufficent pam_exec.so expose_authtok /usr/local/sbin/verify_otp.sh:' /etc/pam.d/sshd pkill -SIGHUP sshd # Test that the connexion works. After remove the test code from the test machine like -rm /usr/local/bin/vault_verify_otp.sh +rm /usr/local/sbin/verify_otp.sh sed -i '1d' /etc/pam.d/sshd -# 8. Add the static ldap tokens to the SSH connection +# 9. [TODO LDAP Static] Add the static ldap tokens to the SSH connection # \${vault://ldap/static/users/kali/password} and \${vault://ldap/static/users/kali/username} # Test that the SSH connection to the kali machine works -# 9. [TODO LDAP Dynamic] Add the dynamic ldaptokens to the SSH connection +# 10. [TODO LDAP Dynamic] Add the dynamic ldaptokens to the SSH connection # \${vault://ldap/dynamic/users/kali/password} and \${vault://ldap/dynamic/users/kali/username} # Test that the SSH connection to the kali machine works -# 10. [TODO LDAP Service] Add the ldap service tokens to the SSH connection +# 11. [TODO LDAP Service] Add the ldap service tokens to the SSH connection # \${vault://ldap/static/users/kali/password} and \${vault://ldap/static/users/kali/username} # Test that the SSH connection to the kali machine works -# 11. Test Guacamole with a token with limited rights and an infinite TTL -# First generate the token with the short TTL and limited right - -curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ - --request POST --data '{"policies": ["guacamole"], ttl: "0m", "renewable": true}' \ - http://127.0.0.1:8200/v1/auth/token/create | jq - -# Add the printed token to the 'vault-token' in gaucamole.properties and restart -# Guacamole. -# -# Test that one of the previous test still works # 12. A VaultAgent can be simulated by using a token sink file as follows. First create # A short lived (10 minutes token) with the command @@ -236,14 +238,13 @@ curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: applicati --request POST --data '{"policies": ["guacamole"], ttl: "0m", "renewable": true}' \ http://127.0.0.1:8200/v1/auth/token/create | jq -r .auth.client_token > /etc/guacamole.token -# Wait 10 minutes and see if the access to the kali machine still works as the +# Wait 10 minutes and see if the access to the kali machine still works as the # access has beed renewed with the new token. -# 13. Test Guacamole with username and password. Remove 'token-uri' from +# 13. Test Guacamole with username and password. Remove 'token-uri' from # guacamole.properties and replace with # vault-username: guacamole # vault-password: $USER_PASSWORD -# Restart guacamole rapidly and (less than 10 minutes) test that one of the -# previous connections still works +# Restart guacamole and test that one of the previous connections still works # # Wait 10 minutes, so as to test the static token renewal of the openbao driver # and retest that one of the connections above works @@ -253,7 +254,7 @@ curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: applicati # mysql-password: vault://db/guacamole/password # or adapt for your database engine. Restart guacamole. If it functions at all the # database is accessible -# 15. [TODO Manual tokens] Create a file `openbao-token-mapping.yml` with the tokens +# 15. Create a file `vault-token-mapping.yml` with the tokens # KALI_USERNAME: vault://kv1/users/kali/password # KALI_PASSWORD: vault://kv1/users/kali/username # Add to the kali ssh connection the tokens ${KALI_USERNAME} and ${KALI_PASSWORD} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/pom.xml b/extensions/guacamole-vault/modules/guacamole-vault-openbao/pom.xml index 63dd539f39..da96920d81 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/pom.xml +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/pom.xml @@ -51,7 +51,7 @@ ${revision} - + com.fasterxml.jackson.core jackson-databind @@ -68,7 +68,7 @@ org.apache.sshd sshd-common - 2.12.1 + 2.17.1 org.slf4j @@ -77,6 +77,13 @@ + + + net.i2p.crypto + eddsa + 0.3.0 + + @@ -107,6 +114,7 @@ error_prone_annotations - + + diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProvider.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProvider.java index 2909355a03..fb1a3ecd7b 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProvider.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProvider.java @@ -19,6 +19,7 @@ package org.apache.guacamole.vault.openbao; +import com.google.inject.Inject; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.vault.VaultAuthenticationProvider; import org.slf4j.Logger; @@ -44,6 +45,7 @@ public class OpenBaoAuthenticationProvider extends VaultAuthenticationProvider { */ public OpenBaoAuthenticationProvider() throws GuacamoleException { super(new OpenBaoAuthenticationProviderModule()); + logger.info("OpenBaoAuthenticationProvider initialized"); } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProviderModule.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProviderModule.java index 9a63e0ffcc..81d50808d1 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProviderModule.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProviderModule.java @@ -19,14 +19,16 @@ package org.apache.guacamole.vault.openbao; +import com.google.inject.Singleton; +import com.google.inject.Provides; import com.google.inject.Scopes; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.vault.VaultAuthenticationProviderModule; import org.apache.guacamole.vault.conf.VaultConfigurationService; import org.apache.guacamole.vault.openbao.conf.OpenBaoConfigurationService; import org.apache.guacamole.vault.openbao.secret.OpenBaoClient; +import org.apache.guacamole.vault.openbao.secret.OpenBaoClientProvider; import org.apache.guacamole.vault.openbao.secret.OpenBaoSecretService; -import org.apache.guacamole.vault.openbao.secret.TimeoutVaultTemplate; import org.apache.guacamole.vault.openbao.user.OpenBaoAttributeService; import org.apache.guacamole.vault.openbao.user.OpenBaoDirectoryService; import org.apache.guacamole.vault.secret.VaultSecretService; @@ -66,7 +68,7 @@ protected void configureVault() { // Bind directory service bind(VaultDirectoryService.class).to(OpenBaoDirectoryService.class); - // bind OpenBao client - bind(OpenBaoClient.class); + // Bind client + bind(OpenBaoClient.class).toProvider(OpenBaoClientProvider.class).asEagerSingleton(); } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfigurationService.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfigurationService.java index 155319134b..02d86e6edb 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfigurationService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfigurationService.java @@ -46,7 +46,7 @@ public class OpenBaoConfigurationService extends VaultConfigurationService { public static final int DEFAULT_REQUEST_TIMEOUT = 5000; /** - * The default connection tiemout in milliseconds. + * The default connection timeout in milliseconds. */ public static final int DEFAULT_CONNECTION_TIMEOUT = 10000; @@ -55,6 +55,12 @@ public class OpenBaoConfigurationService extends VaultConfigurationService { */ 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. * FIXME : ed25519 not supportted before java 15. Waiting for upgrade @@ -153,6 +159,17 @@ public class OpenBaoConfigurationService extends VaultConfigurationService { 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 */ @@ -275,7 +292,7 @@ public int getRequestTimeout() throws GuacamoleException { * milliseconds. * * @return int - * The conenction timeout in milliseconds. + * The connection timeout in milliseconds. * * @throws GuacamoleException * If guacamole.properties can not be parsed. @@ -284,6 +301,19 @@ 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 int + * The renewl delay in milliseconds. + * + * @throws GuacamoleException + * If guacamole.properties can not be parsed. + */ + public int getTokenRenewalDelay() throws GuacamoleException { + return environment.getProperty(VAULT_TOKEN_RENEWAL_DELAY, DEFAULT_TOKEN_RENEWAL_DELAY); + } /** * The type of SSH certificates are will be generated. Must be either diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java index abb0aa81c1..747fe9d677 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java @@ -26,45 +26,34 @@ import com.github.benmanes.caffeine.cache.RemovalCause; import com.google.inject.Inject; import com.google.inject.Singleton; -import java.io.IOException; import java.net.URI; -import java.net.InetAddress; -import java.net.UnknownHostException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.Duration; import java.util.Collections; -import java.util.concurrent.Executors; -import java.util.function.Supplier; import java.util.HashMap; import java.util.Map; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.GuacamoleServerException; +import org.apache.guacamole.net.auth.UserContext; import org.apache.guacamole.protocol.GuacamoleConfiguration; import org.apache.guacamole.vault.openbao.conf.OpenBaoConfigurationService; -import org.apache.guacamole.vault.openbao.secret.FileTokenAuthentication; -import org.apache.guacamole.vault.openbao.secret.UsernamePasswordAuthentication; -import org.apache.guacamole.vault.openbao.secret.UsernamePasswordAuthenticationOptions; -import org.apache.guacamole.vault.openbao.secret.TimeoutVaultTemplate; +import org.apache.guacamole.vault.openbao.vault.FileTokenAuthentication; +import org.apache.guacamole.vault.openbao.vault.UsernamePasswordAuthentication; +import org.apache.guacamole.vault.openbao.vault.UsernamePasswordAuthenticationOptions; +import org.apache.guacamole.vault.openbao.vault.TtlAwareSessionManager; import org.springframework.http.client.SimpleClientHttpRequestFactory; -import org.springframework.scheduling.TaskScheduler; import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; import org.springframework.vault.authentication.ClientAuthentication; -import org.springframework.vault.authentication.LifecycleAwareSessionManager; -import org.springframework.vault.authentication.SessionManager; -import org.springframework.vault.authentication.SimpleSessionManager; import org.springframework.vault.authentication.TokenAuthentication; -import org.springframework.vault.client.VaultClients; import org.springframework.vault.client.VaultEndpoint; -import org.springframework.vault.core.lease.event.SecretLeaseErrorEvent; -import org.springframework.vault.core.lease.event.SecretLeaseExpiredEvent; -import org.springframework.vault.core.lease.SecretLeaseContainer; 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.RestOperations; import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.DefaultUriBuilderFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -78,7 +67,6 @@ public class OpenBaoClient { /** * Service for retrieving OpenBao configuration. */ - @Inject private OpenBaoConfigurationService configService; /** @@ -100,7 +88,12 @@ public class OpenBaoClient { /** * Vault template that will be used with all of the mount paths */ - private TimeoutVaultTemplate vaultTemplate; + private VaultTemplate vaultTemplate; + + /* + * Ttl aware token manager for automatic token renewal + */ + private TtlAwareSessionManager sessionManager; /** * Vault client authentication object @@ -108,10 +101,12 @@ public class OpenBaoClient { private ClientAuthentication authentication; /** - * A Vault lease container used to automatically renew tokens - * or username/password access. + * Constructor allowing early injection f configuartion and initialization + * to start thetoken renewal process as early as possible */ - private SecretLeaseContainer leaseContainer; + public OpenBaoClient(OpenBaoConfigurationService configService) { + this.configService = configService; + } /** * Complete the instantiation of the class on first use @@ -120,14 +115,15 @@ public void init() throws GuacamoleException { try { VaultEndpoint endpoint = VaultEndpoint.from(configService.getVaultUri().resolve("v1")); - if (configService.getVaultToken() != null) { if (isTokenReadableFile(configService.getVaultToken())) { + logger.info("File Token : {}", configService.getVaultToken()); this.authentication = new FileTokenAuthentication(configService.getVaultToken(), endpoint, new RestTemplate()); } else { + logger.info("Token : {}", configService.getVaultToken()); this.authentication = new TokenAuthentication(configService.getVaultToken()); } } @@ -145,40 +141,36 @@ else if (configService.getVaultUsername() != null && configService.getVaultPassw throw new GuacamoleException("Either a vault token or Username/Password must be supplied"); } - if (configService.getVaultToken() != null && - ! isTokenReadableFile(configService.getVaultToken())) { - this.vaultTemplate = new TimeoutVaultTemplate(endpoint, - new SimpleClientHttpRequestFactory(), - new SimpleSessionManager(this.authentication)); - } - else { - ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler(); - taskScheduler.setPoolSize(1); - taskScheduler.setThreadNamePrefix("vault-token-renewal-"); - taskScheduler.initialize(); - RestOperations restOperations = VaultClients.createRestTemplate(endpoint, - new SimpleClientHttpRequestFactory()); - LifecycleAwareSessionManager session = - new LifecycleAwareSessionManager(this.authentication, taskScheduler, restOperations); - - this.vaultTemplate = new TimeoutVaultTemplate(endpoint, - new SimpleClientHttpRequestFactory(), session); - } + // Create a task scheduler for our token renewal + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(1); + scheduler.setThreadNamePrefix("vault-renewal-"); + scheduler.initialize(); + + // Automatically renew tokens before expiration + RestTemplate restTemplate = new RestTemplate(); + restTemplate.setUriTemplateHandler(new DefaultUriBuilderFactory( + configService.getVaultUri().resolve("v1").toString())); + this.sessionManager = new TtlAwareSessionManager(this.authentication, + restTemplate, scheduler, configService.getTokenRenewalDelay()); + + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); try { - this.vaultTemplate.setConnectionTimeout(configService.getConnectionTimeout()); + requestFactory.setConnectTimeout(configService.getConnectionTimeout()); } catch (GuacamoleException e) { logger.debug("Using default vault endpoint connection timeout: " + e.getMessage()); } try { - this.vaultTemplate.setRequestTimeout(configService.getRequestTimeout()); + requestFactory.setReadTimeout(configService.getRequestTimeout()); } catch (GuacamoleException e) { logger.debug("Using default vault endpoint request timeout: " + e.getMessage()); } + this.vaultTemplate = new VaultTemplate(endpoint, requestFactory, this.sessionManager); } catch (Exception e) { - throw new GuacamoleException("Error initializing Vault client: ", e); + throw new GuacamoleException("Error initializing Vault client: " + e.getMessage()); } // Initialize the cache with maximum size of 1MB, and cache expiry with @@ -299,7 +291,7 @@ else if (type.equals("kv")) { * @throws GuacamoleException * If the secret cannot be retrieved from OpenBao. */ - public String getValue(String token, GuacamoleConfiguration config) throws GuacamoleException { + public String getValue(String token, UserContext userContext, GuacamoleConfiguration config) throws GuacamoleException { try { if (authentication == null) { logger.debug("Initializing OpenBao"); @@ -313,32 +305,33 @@ public String getValue(String token, GuacamoleConfiguration config) throws Guaca // Before going further replace the arguments "{USERNAME}", "{SERVER}", // "{GATEWAY_USERNAME}" and "{GATEWAY_HOSTNAME}" in the token with their - // with values supplied in the parameters + // with values supplied in the parameters. The value of USER here corresponds + // to GUAC_USERNAME // FIXME Could this be done with the TokenFilter in OpenBaoSecretService ? // 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 username = config.getParameter("username"); + String username = userContext == null ? "" : userContext.self().getIdentifier(); if (username != null && !username.isEmpty() && token.contains("{USER}") && ! token.contains("$${USER}")) { - token.replace("{USER}", username); + token = token.replace("{USER}", username); } String hostname = config.getParameter("hostname"); - if (hostname != null && !hostname.isEmpty() + if (hostname != null && !hostname.isEmpty() && !hostname.contains("${") && token.contains("{SERVER}") && ! token.contains("$${SERVER}")) { - token.replace("{SERVER}", hostname); + token = token.replace("{SERVER}", hostname); } String gatewayHostname = config.getParameter("gateway-hostname"); - if (gatewayHostname != null && !gatewayHostname.isEmpty() + if (gatewayHostname != null && !gatewayHostname.isEmpty() && !gatewayHostname.contains("${") && token.contains("{GATEWAY}") && ! token.contains("$${GATEWAY}")) { - token.replace("{GATEWAY}", gatewayHostname); + token = token.replace("{GATEWAY}", gatewayHostname); } String gatewayUsername = config.getParameter("gateway-username"); - if (gatewayUsername != null && !gatewayUsername.isEmpty() + if (gatewayUsername != null && !gatewayUsername.isEmpty() && !gatewayUsername.contains("${") && token.contains("{GATEWAY_USER}") && ! token.contains("$${GATEWAY_USER}")) { - token.replace("{GATEWAY_USER}", gatewayUsername); + token = token.replace("{GATEWAY_USER}", gatewayUsername); } @@ -444,8 +437,7 @@ private String getValueKV(String mountPath, String path, String secret, String t // Get the values on the path and cache them VaultResponse response = kvOperations.get(path); - - if (response.getData() == null) + if (response == null || response.getData() == null) { throw new GuacamoleServerException( "Value not found in OpenBao for path: " + mountPath + "/" + path); @@ -490,10 +482,26 @@ private String getValueKV(String mountPath, String path, String secret, String t * If the secret cannot be retrieved from OpenBao. */ private String getValueSSH(String mountPath, String path, String secret, GuacamoleConfiguration config) throws GuacamoleException { - // Is the key-value already in the cache - Map cacheResponse = (Map) cache.getIfPresent(mountPath + "/" + path); + + // Is the key-value already in the cache. The SSH connection is for a specific + // user, so add username in the cache path + Map cacheResponse; + String username = config.getParameter("username"); + if (path.startsWith("sign/")) { + if (username == null || username.isEmpty()) { + throw new GuacamoleException("The username can not be empty for SSH signed certificates"); + } + cacheResponse = (Map) cache.getIfPresent(username + "-" + mountPath + path); + } + else if (path.startsWith("creds/")) { + cacheResponse = (Map) cache.getIfPresent(mountPath + path); + } + else { + throw new GuacamoleException("Unknown SSH type on path: " + mountPath + path); + } if (cacheResponse != null) { + logger.info("Cached Response"); String retval; // The path is already cached?. Use it if (cacheResponse.get(secret) instanceof String) { @@ -502,7 +510,7 @@ private String getValueSSH(String mountPath, String path, String secret, Guacamo else { try { // Stored JSON value.. Probably not usable, but return as a string - return objectMapper.writeValueAsString(cacheResponse.get(secret)); + retval = objectMapper.writeValueAsString(cacheResponse.get(secret)); } catch (JsonProcessingException e) { throw new GuacamoleException("Error json parsing returned secret: ", e); @@ -512,19 +520,18 @@ private String getValueSSH(String mountPath, String path, String secret, Guacamo if (retval == null || retval.isEmpty()) { throw new GuacamoleException("SSH secret '" + secret + "' not found on the path : " + mountPath +"/" + path); } + // One-time password so needs to be cleared after first use + if (path.startsWith("creds/")) { + cache.invalidate(mountPath + path); + } + return retval; } // Detect if wanting one-time password or signed certificate - if (path.startsWith("otp/")) { + if (path.startsWith("creds/")) { VaultResponse response = - vaultTemplate.write(mountPath + "/creds/" + path.substring(4), Map.of("ip", "0.0.0.0")); - try { - logger.info(" KV Response : {}", objectMapper.writeValueAsString(response.getData())); - } - catch (Exception e) { - logger.info(" KV Response Error : {}", e); - } + vaultTemplate.write(mountPath + path, Map.of("ip", "0.0.0.0")); if (response == null || response.getData() == null) { throw new GuacamoleException("No response from Vault SSH engine"); @@ -538,8 +545,7 @@ private String getValueSSH(String mountPath, String path, String secret, Guacamo } // Cache the retrieved user and otp - cache.put(mountPath + "/" + path, Map.of("username", user, "password", password)); - logger.debug("SSH OTP : " + user + " : " + password); + cache.put(mountPath + path, Map.of("username", user, "password", password)); if (secret.equals("username")) { return user; @@ -548,20 +554,14 @@ else if (secret.equals("password")) { return password; } } - else if (path.startsWith("cert/")) { - String username = config.getParameter("username"); - if (username == null || username.isEmpty()) { - throw new GuacamoleException("The username can not be empty for SSH signed certificates"); - } + else if (path.startsWith("sign/")) { OpenBaoSshKeys sshKeys = new OpenBaoSshKeys(configService.getSshType()); - Map request = Map.of( "public_key", sshKeys.publicSsh, "valid_principals", username, "extensions", Map.of("permit-pty", ""), - "ttl", configService.getSshConnectionTimeout() - ); + "ttl", configService.getSshConnectionTimeout()); VaultResponse vaultResponse = vaultTemplate.write(mountPath + "/sign/" + path.substring(5), request); @@ -571,26 +571,23 @@ else if (path.startsWith("cert/")) { } String signedCert = (String) vaultResponse.getData().get("signed_key"); + logger.info("SSH Cert response keys: {}", vaultResponse.getData().keySet()); if (signedCert == null) { throw new GuacamoleException("Vault did not return a signed SSH certificate"); } - cache.put(mountPath + "/" + path, Map.of( + cache.put(username + "-" + mountPath + path, Map.of( "private", sshKeys.privateSshPem, "public", signedCert)); - logger.info("SSH CERT : " + sshKeys.privateSshPem + " : " + signedCert); if (secret.equals("private")) { return sshKeys.privateSshPem; } else if (secret.equals("public")) { - return signedCert; - } - } - else { - throw new GuacamoleException("Unknown SSH type on path: " + mountPath + path); + return signedCert; } } + throw new GuacamoleException("SSH secret '" + secret + "' not found on the path : " + mountPath + path); } @@ -756,9 +753,9 @@ private String getValueDB(String mountPath, String path, String secret) throws G } /** - * Release the automatic renewal of the AppRole token on shutown + * Release the automatic renewal of the tokens on shutdown */ public void shutdown() { - leaseContainer.stop(); + this.sessionManager.close(); } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClientProvider.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClientProvider.java new file mode 100644 index 0000000000..7ee761f6eb --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClientProvider.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.openbao.secret; + +import com.google.inject.Inject; +import com.google.inject.Provider; +import com.google.inject.ProvisionException; +import com.google.inject.Singleton; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.vault.openbao.conf.OpenBaoConfigurationService; +import org.apache.guacamole.vault.openbao.secret.OpenBaoClient; + +@Singleton +public class OpenBaoClientProvider implements Provider { + + private final OpenBaoConfigurationService configService; + + @Inject + public OpenBaoClientProvider(OpenBaoConfigurationService configService) { + this.configService = configService; + } + + @Override + public OpenBaoClient get() { + OpenBaoClient client = new OpenBaoClient(configService); + try { + client.init(); + } + catch (GuacamoleException e) { + throw new ProvisionException("Failed to initialize OpenBaoClient : " + e.getMessage()); + } + return client; + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java index faa91c2a17..e2f3f534ae 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java @@ -20,6 +20,7 @@ package org.apache.guacamole.vault.openbao.secret; import com.google.inject.Inject; +import com.google.inject.Provider; import com.google.inject.Singleton; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; @@ -35,6 +36,7 @@ import org.apache.guacamole.protocol.GuacamoleConfiguration; import org.apache.guacamole.token.TokenFilter; import org.apache.guacamole.vault.openbao.secret.OpenBaoClient; +import org.apache.guacamole.vault.openbao.secret.OpenBaoClientProvider; import org.apache.guacamole.vault.secret.VaultSecretService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -54,16 +56,25 @@ public class OpenBaoSecretService implements VaultSecretService { /** * Client for communicating with OpenBao. */ - @Inject - private OpenBaoClient openBaoClient; + private final Provider openBaoClientProvider; /** - * Constructor that logs when the service is created. + * Constructor that logs when the service is created. Inject OpenBaoClient via + * Provider to avoid circular Guice dependency */ - public OpenBaoSecretService() { + @Inject + public OpenBaoSecretService(Provider openBaoClientProvider) { + this.openBaoClientProvider = openBaoClientProvider; logger.info("OpenBaoSecretService initialized"); } + /** + * Get a Guice cahced copy of the OpenBaoClient Singleton + */ + private OpenBaoClient client() { + return openBaoClientProvider.get(); + } + @Override public String canonicalize(String nameComponent) { try { @@ -100,14 +111,14 @@ public String canonicalize(String nameComponent) { */ @Override public Future getValue(String token) throws GuacamoleException { - String value = openBaoClient.getValue(token, new GuacamoleConfiguration()); + String value = client().getValue(token, null, new GuacamoleConfiguration()); return CompletableFuture.completedFuture(value); } @Override public Future getValue(UserContext userContext, Connectable connectable, String token) throws GuacamoleException { - String value = openBaoClient.getValue(token, new GuacamoleConfiguration()); + String value = client().getValue(token, null, new GuacamoleConfiguration()); return CompletableFuture.completedFuture(value); } @@ -116,7 +127,7 @@ public Future getValue(UserContext userContext, Connectable connectable, * 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 + * function should be implementedopenBaoClient to provide automatic tokens for those * secrets and remove the need for manual mapping via YAML. * * @param userContext @@ -152,13 +163,13 @@ public Map> getTokens(UserContext userContext, Map> tokens = new HashMap<>(); Map parameters = config.getParameters(); - Pattern tokenPattern = Pattern.compile("\\$\\{(" + OpenBaoClient.VAULT_TOKEN_PREFIX + ".+)\\}"); + Pattern tokenPattern = Pattern.compile("\\$\\{(" + client().VAULT_TOKEN_PREFIX + ".+)\\}"); for (Map.Entry entry : parameters.entrySet()) { Matcher tokenMatcher = tokenPattern.matcher(entry.getValue()); while (tokenMatcher.find()) { String token = tokenMatcher.group(1); - String value = openBaoClient.getValue(token, config); + String value = client().getValue(token, userContext, config); tokens.put(token, CompletableFuture.completedFuture(value)); } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSshKeys.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSshKeys.java index 9aaa726ae7..dd4db77e86 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSshKeys.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSshKeys.java @@ -31,14 +31,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; - - - -import org.apache.sshd.common.config.keys.PublicKeyEntry; -import org.apache.sshd.common.config.keys.writer.openssh.OpenSSHKeyPairResourceWriter; -import org.apache.sshd.common.util.security.SecurityUtils; - - public class OpenBaoSshKeys { /** @@ -79,7 +71,7 @@ public OpenBaoSshKeys(String type) { baos.toString(StandardCharsets.UTF_8); } catch (Exception e) { - throw new IllegalStateException("Failed to serialize SSH keypair", e); + throw new IllegalStateException("Failed to serialize SSH keypair: " + e.getMessage(), e); } } @@ -98,14 +90,12 @@ public OpenBaoSshKeys() { */ private KeyPair generateEd25519WithFallback() { try { - // Use SSHD's EdDSA generator, not JDK "Ed25519" KeyPairGenerator keyPairGenerator = SecurityUtils.getKeyPairGenerator("EdDSA"); - keyPairGenerator.initialize(256); // Ed25519 return keyPairGenerator.generateKeyPair(); } catch (Exception e) { - logger.info("Ed25519 not available via SSHD EdDSA. Falling back to RSA : " + e.getMessage()); + logger.info("Ed25519 not available via SSHD EdDSA. Falling back to RSA : {}", e.getMessage()); return generateRsa(); } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/TimeoutVaultTemplate.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/TimeoutVaultTemplate.java deleted file mode 100644 index 9f332ac112..0000000000 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/TimeoutVaultTemplate.java +++ /dev/null @@ -1,85 +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.openbao.secret; - -import com.google.inject.Inject; -import org.apache.guacamole.GuacamoleException; -import org.apache.guacamole.vault.openbao.conf.OpenBaoConfigurationService; -import org.springframework.http.client.ClientHttpRequestFactory; -import org.springframework.http.client.SimpleClientHttpRequestFactory; -import org.springframework.vault.client.VaultEndpoint; -import org.springframework.vault.client.VaultEndpointProvider; -import org.springframework.vault.core.VaultTemplate; -import org.springframework.vault.authentication.SessionManager; -import org.springframework.web.client.RestTemplate; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * The point of this class is to override the doCreateRestTemplate method - * when creating a VaultTemplate and allow a request and connection - * timeout to be added to the HttpClient. - */ -public class TimeoutVaultTemplate extends VaultTemplate { - /** - * Logger for this class. - */ - private static final Logger logger = LoggerFactory.getLogger(TimeoutVaultTemplate.class); - - /** - * Service for retrieving OpenBao configuration. - */ - @Inject - private OpenBaoConfigurationService configService; - - /** - * The connection timeout in milliseconds - */ - private int connectionTimeout = OpenBaoConfigurationService.DEFAULT_CONNECTION_TIMEOUT; - - /** - * The request timeout in milliseconds - */ - private int requestTimeout = OpenBaoConfigurationService.DEFAULT_REQUEST_TIMEOUT; - - public TimeoutVaultTemplate(VaultEndpoint endpoint, - ClientHttpRequestFactory requestFactory, - SessionManager session) { - super(endpoint, requestFactory, session); - } - - public void setRequestTimeout(int requestTimeout) { this.requestTimeout = requestTimeout; } - public void setConnectionTimeout(int requestTimeout) { this.connectionTimeout = connectionTimeout; } - - @Override - protected RestTemplate doCreateRestTemplate(VaultEndpointProvider endpointProvider, - ClientHttpRequestFactory requestFactory) { - - if (requestFactory instanceof SimpleClientHttpRequestFactory) { - logger.debug("Setting http request and connection timeouts"); - ((SimpleClientHttpRequestFactory) requestFactory) - .setConnectTimeout(connectionTimeout); - ((SimpleClientHttpRequestFactory) requestFactory) - .setReadTimeout(requestTimeout); - } - - return super.doCreateRestTemplate(endpointProvider, requestFactory); - } -} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/FileTokenAuthentication.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/FileTokenAuthentication.java similarity index 68% rename from extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/FileTokenAuthentication.java rename to extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/FileTokenAuthentication.java index f5ffa32dc9..529ac65ac4 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/FileTokenAuthentication.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/FileTokenAuthentication.java @@ -17,7 +17,7 @@ * under the License. */ -package org.apache.guacamole.vault.openbao.secret; +package org.apache.guacamole.vault.openbao.vault; import java.io.IOException; import java.nio.file.Files; @@ -25,25 +25,21 @@ import java.time.Duration; import java.util.Map; import org.apache.guacamole.vault.openbao.conf.OpenBaoConfigurationService; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; import org.springframework.http.ResponseEntity; import org.springframework.vault.authentication.AuthenticationSteps; import org.springframework.vault.authentication.AuthenticationStepsFactory; import org.springframework.vault.authentication.AuthenticationSteps.HttpRequest; import org.springframework.vault.authentication.ClientAuthentication; -import org.springframework.vault.authentication.LoginToken; import org.springframework.vault.client.VaultEndpoint; import org.springframework.vault.support.VaultResponse; import org.springframework.vault.support.VaultToken; import org.springframework.vault.VaultException; import org.springframework.web.client.RestTemplate; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; public final class FileTokenAuthentication implements ClientAuthentication { - /** - * Logger for this class. - */ - private static final Logger logger = LoggerFactory.getLogger(FileTokenAuthentication.class); /** * The path to the file containing the token @@ -90,35 +86,11 @@ public VaultToken login() { token = Files.readString(tokenPath).trim(); } catch (IOException e) { - throw new IllegalStateException( + // This might be recoverable. So throw a VautException + throw new VaultException( "Cannot read Vault token sink: " + tokenPath, e); } - String lookupPath = String.format( - "%s://%s:%d/v1/auth/token/lookup-self", - endpoint.getScheme(), - endpoint.getHost(), - endpoint.getPort()); - - ResponseEntity response = - restTemplate.postForEntity(lookupPath, null, VaultResponse.class); - - VaultResponse vaultResponse = response.getBody(); - - Boolean renewable = (Boolean) vaultResponse.getAuth().get("renewable"); - Duration leaseDuration = Duration.ofSeconds((long) vaultResponse.getAuth().get("lease_duration")); - String accessor = (String) vaultResponse.getAuth().get("accessor"); - String type = (String) vaultResponse.getAuth().get("type"); - - if (token == null) { - throw new VaultException("No client_token in Vault auth response"); - } - - return LoginToken.builder().token(token) - .leaseDuration(leaseDuration) - .renewable(renewable) - .accessor(accessor) - .type(type) - .build(); + return VaultToken.of(token); } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/TtlAwareSessionManager.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/TtlAwareSessionManager.java new file mode 100644 index 0000000000..8376107995 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/TtlAwareSessionManager.java @@ -0,0 +1,493 @@ +/* + * 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.openbao.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 LifesycleAwareSessionMnagerr 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. + */ + private LoginToken renewToken(VaultToken token) { + + 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) { + logger.info("Setting new token : {}", token.getToken()); + if (token != null && !token.getToken().isEmpty()) { + this.token.set(token); + } + scheduleRenewal(token); + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/UsernamePasswordAuthentication.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/UsernamePasswordAuthentication.java similarity index 74% rename from extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/UsernamePasswordAuthentication.java rename to extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/UsernamePasswordAuthentication.java index d8170023bf..113de2fb06 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/UsernamePasswordAuthentication.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/UsernamePasswordAuthentication.java @@ -18,9 +18,9 @@ */ // This is a minimal backport of this class to version 2.3.4 of spring-vault-core -// It is compatible with lease renewal using SecretLeaseContainer +// It is compatible with lease renewal using a life cycle aware session manager -package org.apache.guacamole.vault.openbao.secret; +package org.apache.guacamole.vault.openbao.vault; import java.time.Duration; import java.util.Collections; @@ -36,11 +36,24 @@ 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 + */ public UsernamePasswordAuthentication( UsernamePasswordAuthenticationOptions options, VaultEndpoint endpoint, @@ -55,6 +68,17 @@ public UsernamePasswordAuthentication( 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 + * 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 { @@ -79,20 +103,19 @@ public VaultToken login() throws VaultException { } String token = (String) vaultResponse.getAuth().get("client_token"); + Number lease = (Number) vaultResponse.getAuth().get("lease_duration"); Boolean renewable = (Boolean) vaultResponse.getAuth().get("renewable"); - Duration leaseDuration = Duration.ofSeconds((long) vaultResponse.getAuth().get("lease_duration")); - String accessor = (String) vaultResponse.getAuth().get("accessor"); - String type = (String) vaultResponse.getAuth().get("type"); + + 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(leaseDuration) - .renewable(renewable) - .accessor(accessor) - .type(type) + .leaseDuration(Duration.ofSeconds(leaseDuration)) + .renewable(isRenewable) .build(); } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/UsernamePasswordAuthenticationOptions.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/UsernamePasswordAuthenticationOptions.java similarity index 98% rename from extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/UsernamePasswordAuthenticationOptions.java rename to extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/UsernamePasswordAuthenticationOptions.java index a543d99e5e..a23672791d 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/UsernamePasswordAuthenticationOptions.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/UsernamePasswordAuthenticationOptions.java @@ -19,7 +19,7 @@ // This is a minimal backport of this class to version 2.3.4 of spring-vault-core -package org.apache.guacamole.vault.openbao.secret; +package org.apache.guacamole.vault.openbao.vault; import org.springframework.util.Assert; From 7226d816e28ca35ffd5e1cb344d9ce984907bb59 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Wed, 13 May 2026 13:32:36 +0200 Subject: [PATCH 07/16] GUACAMOLE-2196: Get static and dynamic LDAP accounts to work, update documentation and test script. --- .../include/openbao-optional.properties.in | 2 +- .../docs/openbao.md.j2 | 1528 +++++++++++------ .../guacamole-vault-openbao/docs/openbao.sh | 140 +- .../vault/openbao/secret/OpenBaoClient.java | 145 +- .../openbao/secret/OpenBaoSecretService.java | 2 +- 5 files changed, 1198 insertions(+), 619 deletions(-) diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao-optional.properties.in b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao-optional.properties.in index 4efa2dbe9c..04032072ad 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao-optional.properties.in +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao-optional.properties.in @@ -30,7 +30,7 @@ vault-connection-timeout: 5000 # account for clock drift. vault-ssh-connection-timeout: 1800 -# The renewal delay for expiring Vault tokens in miliiseconds. Tokens +# The renewal delay for expiring Vault tokens in milliseconds. Tokens # will be renewed prior to expiration by this delay vault-token-renewal-delay: 10000 diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 index 82fb9d2026..cf26a36772 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 @@ -1,355 +1,431 @@ {# vim: set filetype=markdown.jinja : #} {%- import 'include/ext-macros.md.j2' as ext with context -%} -Retrieving secrets from a vault -=============================== +# Retrieving Secrets from a Vault -Guacamole supports retrieving secrets—such as connection-specific passwords, private keys, -certificates, and credentials—from a Vault. These secrets are injected automatically into -connection configurations using parameter tokens or into Guacamole configuration properties -via an additional Vault-specific configuration file analogous to `guacamole.properties`. +Guacamole supports retrieving secrets—such as connection-specific passwords, +private keys, certificates, and credentials—from a **Vault**. These secrets are +injected automatically into connection configurations using **parameter tokens** +or into Guacamole configuration properties via an additional Vault-specific +configuration file analogous to `guacamole.properties`. -This extension supports both [**OpenBao**](https://openbao.org) and -[**HashiCorp Vault**](https://www.hashicorp.com/products/vault). Throughout this document, -the generic term **Vault** is used to refer to either implementation unless a distinction -is required. +This extension supports both **[OpenBao](https://openbao.org)** and +**[HashiCorp Vault](https://www.hashicorp.com/products/vault)**. Throughout this +document, the generic term **Vault** is used to refer to either implementation +unless a distinction is required. ```{include} include/warn-config-changes.md ``` (vault-downloading)= - -Installing/Enabling the vault extension +Installing/Enabling the Vault Extension --------------------------------------- {{ ext.install('openbao', 'guacamole-vault', 'openbao/guacamole-vault-openbao') }} (adding-guac-to-vlt)= -### Adding Guacamole to a Vault +## Adding Guacamole to a Vault + +This section provides concrete examples for setting up the supported secret +engines in Vault for use with Guacamole. To allow an application like Guacamole +to access secrets stored in a Vault, the following steps are required: -This document will discuss examples of setting up the supported -secret engines of the Vault for Guacamole with concrete examples. +1. **Enable the required secret engines** (e.g., KV, SSH, LDAP, Database). +2. **Create appropriate mount paths** for each secret engine. +3. **Define access policies** to restrict Guacamole’s permissions. +4. **Configure authentication credentials** for Guacamole to authenticate with Vault. -Allowing an application such as Guacamole to access secrets stored in a Vault requires: +:::{note} +This document **does not** cover general Vault administration. For details on +creating and managing mount paths, authentication backends, and policies, refer +to the official documentation: -- Enabling the required secret engines -- Creating appropriate mount paths -- Defining access policies -- Configuring authentication credentials +- [OpenBao Documentation](https://openbao.org/docs) +- [HashiCorp Vault Documentation](https://developer.hashicorp.com/vault/docs) +::: -This document does **not** cover general Vault administration. For details on creating and -managing mount paths, authentication backends, and policies, refer to the official -documentation: +All configuration examples below use `**curl**` exclusively to remain agnostic to +the specific Vault implementation (OpenBao or HashiCorp). -- https://openbao.org/docs -- https://developer.hashicorp.com/vault/docs +Unless otherwise stated, the supplied commands should work for both OpenBao and +Hashicorp Vault. -All configuration examples below use **`curl`** exclusively to remain agnostic to the -specific Vault implementation. +### Vault Connection Information -#### Vault connection information +Guacamole requires the following to connect to a Vault server: -Guacamole requires the network address of the Vault server and valid authentication -credentials. Vault exposes a standard HTTP REST API, and a typical Vault URI looks like: +- The **network address** of the Vault server (e.g., `https://vault.example.com:8200`). +- **Valid authentication credentials** (e.g., token, username/password, or file-based). + +Vault exposes a standard **HTTP REST API**, and a typical Vault URI looks like: ``` https://vault.example.com:8200 ``` -#### Setup up a Guacamole Access Policy +### Setting Up a Guacamole Access Policy -Guacamole requires only **read access** to the secrets it consumes, plus limited write -access for certain secret engines (such as SSH). For security and auditability, it is -strongly recommended to create a **dedicated Vault policy** for Guacamole. +Guacamole requires the following permissions in Vault: -Guacamole also requires read access to the special mount path `sys/mounts` to detect -available secret engines and their types. +- **Read access** to the secrets it consumes. +- **Limited write access** for certain secret engines (e.g., SSH certificate signing). +- **Read access to the `sys/mounts` path** to detect available secret engines and their types. -Below is an example policy that grants Guacamole access to supported secret engines, -restricted to `guacamole` roles in each secret engine used: +For **security and auditability**, it is **strongly recommended** to create a +**dedicated Vault policy** for Guacamole. Below is an example policy that grants +Guacamole access to supported secret engines, restricted to `guacamole` roles +in each engine. -``` -$ cat << EOT >> gaucamole.hcl -// Read system mount table +#### Example Policy (`guacamole.hcl`) + +```hcl +# Read system mount table path "sys/mounts" { capabilities = ["read"] } -// Read from key-value secrets dedicated to Guacamole +# Read from key-value secrets dedicated to Guacamole path "kv/guacamole/*" { capabilities = ["read"] } -// Allow SSH certificate signing for Guacamole +# Allow SSH certificate signing for Guacamole path "ssh/sign/guacamole_cert" { capabilities = ["create", "update"] } -// Allow SSH one-time password generation +# Allow SSH one-time password generation path "ssh/creds/guacamole_otp" { capabilities = ["create"] } -// Read static LDAP credentials +# Read static LDAP credentials path "ldap/static/guacamole/*" { capabilities = ["read"] } -// Read database credentials +# Read database credentials path "db/guacamole/*" { capabilities = ["read"] } -EOT ``` -We then need to upload this policy to the vault. +#### Uploading the Policy to Vault -``` -$ export VAULT_TOKEN=... -$ curl -sS \ +To upload the policy to Vault, use the following command: + +```bash +export VAULT_TOKEN="your_admin_token" +curl -sS \ --header "X-Vault-Token: $VAULT_TOKEN" \ --request PUT \ - --data @gaucamole.hcl \ + --data @guacamole.hcl \ http://127.0.0.1:8200/v1/sys/policies/acl/guacamole ``` -where `VAULT_TOKEN` is a valid Vault token allowing administrative access. It -also assumes that the vault server is running locally and on http. This -creates a policy called `guacamole` in the vault for our use. +:::{note} +- The variable `VAULT_TOKEN`, here and below, contains a valid Vault token withhaving **administrative access**. +- Replace `127.0.0.1:8200` with the address of your Vault server. +- The mount paths in the policy (e.g., `kv/guacamole/*`) are **examples** and should be adapted to match your actual paths. +::: -The mount paths above are examples and should be adapted to the paths you actually -use. -#### Creating a key-values store for guacamole +## Configuring Secret Engines for Guacamole -The simplest means of storing and accessing secrets in a Vault is using the key-values -secret engine. This secret engine can store arbitrary values associated with a key. -Appropriate uses of this secret engine are for unmanaged accounted on client machines, -certificates, etc. The secret rotation features of the Vault can not be used with this -secret engine, so the use of this secret engine for password storage means that the user -is entirely responsable for the adherance to any corporate password policies. +Guacamole supports the following Vault secret engines: -The key-values secret engine, comes in two varieties. The older version 1, where only -the latest values are stored, or the newer version 2 that also stores metadata about the -stored values, including the last time the values were changed. +- **[Key-Value (KV)](https://openbao.org/docs/secrets/kv/kv-v2)**: For static secrets like passwords, certificates, and unmanaged account credentials. +- **[SSH](https://openbao.org/docs/secrets/ssh)**: For signed SSH certificates or one-time passwords (OTP). +- **[LDAP](https://openbao.org/docs/secrets/ldap)**: For dynamic or static LDAP credentials. +- **[Database](https://openbao.org/docs/secrets/databases)**: For dynamic database credentials. -The first step is to enable the secret engine. To enable the version 1 secret engine on a -mount path `kv1`, the command might be +Below are detailed instructions for configuring each engine. -``` -curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ - --request POST --data '{"type": "kv", "options": {"version": "1"}}' \ - http://127.0.0.1:8200/v1/sys/mounts/kv1 -``` -and for a version 2 secret engine mounted on the path `kv2` the command might be +### Creating a Key-Value Store for Guacamole -``` -curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ - --request POST --data '{"type": "kv", "options": {"version": "2"}}' \ - http://127.0.0.1:8200/v1/sys/mounts/kv2 -``` +The **Key-Value (KV) secret engine** is the simplest way to store and retrieve +secrets in Vault. It can store arbitrary key-value pairs, such as usernames, +passwords, or certificates. The KV engine comes in two versions: -At this point the secret engines are mounted, but they contain no secrets. To add a secret -to the version 1 secret engine the command is +- **KV v1**: Stores only the latest values. +- **KV v2**: Stores metadata (e.g., version history, last modified time) alongside the values. -``` -curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ - --request POST --data '{"username": "bruce", "password": "wayne"}' \ - http://127.0.0.1:8200/v1/kv1/guacamole/batman -``` +:::{warning} +The KV secret engine **does not support secret rotation**. If you use it to store passwords, +you are **entirely responsible** for adhering to corporate password policies (e.g., manual rotation). +::: -Here the path of the key is `guacamole/batman`. Complex paths are allowed to seperate groups -of users. The stored secrets are `username` and `password`. However, any string could be -used for these. +#### Enabling the KV v1 Secret Engine -The command for a version 2 key-value store is slightly different. with the equivalent -command to the above being +To enable the **KV v1** secret engine on a mount path named `kv1`, use the +following command: -``` -curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ - --request POST --data '{"data": {"username": "bruce", "password": "wayne"}}' \ - http://127.0.0.1:8200/v1/kv2/data/guacamole/batman +```bash +curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + --request POST \ + --data '{"type": "kv", "options": {"version": "1"}}' \ + http://127.0.0.1:8200/v1/sys/mounts/kv1 ``` -#### Creating an SSH secret engine for Guacamole +#### Enabling the KV v2 Secret Engine -The SSH secret engine allows two means of operation: +To enable the **KV v2** secret engine on a mount path named `kv2`, use the +following command: -- signed SSH certificates (Recommanded), or -- one-time passwords. +```bash +curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + --request POST \ + --data '{"type": "kv", "options": {"version": "2"}}' \ + http://127.0.0.1:8200/v1/sys/mounts/kv2 +``` -The better more modern of these is signed SSH certificates, though Guacamole -supports both SSH methods. +#### Adding a Secret to KV v1 -We first need to enable the ssh secret engine with a command like +To add a secret (e.g., username and password for a connection named `batman`) +to the **KV v1** engine: -``` -curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ - --request POST --data '{"type": "ssh"}' http://127.0.0.1:8200/v1/sys/mounts/ssh +```bash +curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + --request POST \ + --data '{"username": "bruce", "password": "wayne"}' \ + http://127.0.0.1:8200/v1/kv1/guacamole/batman ``` -which mounts the secret engine on the mount path `ssh`. For certificate signing, the Vault -needs a valid CA certificate and its private key. These might be uploaded to the Vault, but -for this example we let the Vault create these values itself. The command for this is +Here: -``` -curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ - --request POST --data '{"generate_signing_key": true}' \ - http://127.0.0.1:8200/v1/ssh/config/ca -``` +- The **path** of the secret is `guacamole/batman`. +- The stored secrets are `username` and `password`. You can use any keys you like. -The returned value of `public_key` must be stored on the target machines, for example in the -file `/etc/ssh/trusted_user_ca.pem`. SSH of these target machines must then be changed to allow -certificates signed with this public key. For OpenSSH this could then be achieved by adding +#### Adding a Secret to KV v2 -``` -TrustedUserCAKeys /etc/ssh/trusted_user_ca +To add the same secret to the **KV v2** engine, use the following command +(note the `/data/` prefix): + +```bash +curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + --request POST \ + --data '{"data": {"username": "bruce", "password": "wayne"}}' \ + http://127.0.0.1:8200/v1/kv2/data/guacamole/batman ``` -to the file `/etc/ssh/sshd_config`. +:::{note} +Although the KV v2 requires the `/data/` prefix in API paths (e.g. +`kv2/data/guacamole/batman`). Guacamole handles this internally, so you **do +not** need to include `/data/` in your token paths. +::: -and save the generated `public_key` above in `/etc/ssh/trusted_user_ca` on the target -machine and force openssh to reread it configuration with a command like +:::{tip} +Use **complex paths** (e.g., `guacamole/production/batman`) to organize secrets +by environment, team, or application. +::: -``` -pkill -SIGHUP /usr/sbin/sshd -``` +### Creating an SSH Secret Engine for Guacamole -Send a HUP signal to the master sshd process in openssh, forces the configuration to -be reloaded without interupting existing ssh connections. +The **SSH secret engine** allows Vault to generate: -The next step to allow signed SSH certificates is setting up a role in the SSH secret -engine for Guacamole. Guacamole needs the `permit-pty` option of ssh, and so the creation -of a signing role `guacamole_cert` might be done with the command +1. **Signed SSH certificates** (recommended). +2. **One-time passwords (OTP)**. -``` -$ curl -sS \ +Guacamole supports both methods, but **signed certificates** are the more modern and secure approach. + +#### Enabling the SSH Secret Engine + +To enable the SSH secret engine on a mount path named `ssh`: + +```bash +curl -s \ --header "X-Vault-Token: $VAULT_TOKEN" \ --header "Content-Type: application/json" \ --request POST \ - --data '{ - "algorithm_signer": "rsa-sha2-256", - "allow_user_certificates": true, - "allowed_users": "*", - "key_type": "ca", - "max_ttl": "30m", - "allowed_extensions": "permit-pty"}' \ - http://127.0.0.1:8200/v1/ssh/roles/guacamole_cert + --data '{"type": "ssh"}' \ + http://127.0.0.1:8200/v1/sys/mounts/ssh ``` -The `max_ttl` value limits the life of the certificates signed by Guacamole to -30 minutes. Small values here ensure that the risk of abuse of the signed certificates -is limited, but the value must taken into account both the latence of the ssh conection -times and the clock drift between the Vault and the SSH client machines. So there is -a limit to how small this value might be without causing problems. - -The Vault is now ready for Guacamole to use to signed SSH certificates. To allow the use of -one-time passwords, a specific role needs to be created. The command to create a role -`guacamole_otp` for a default user `testuser` is +#### Generating a CA for Certificate Signing -``` -curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ - --request POST --data '{"key_type": "otp", "default_user": "testuser"}' \ - http://127.0.0.1:8200/v1/ssh/roles/guacamole_otp -``` +For **signed SSH certificates**, Vault needs a **Certificate Authority (CA)**. +You can either: -The created role can only be used to create one-time passwords for the account `testuser` -and the Vault's one-time password secret engine doesn't not support the user of the same -role for different username. If multiple users are required, then multiple roles with -different default users should be created. +- Upload an existing CA certificate and private key. +- Let Vault generate a new CA. -Note that the `cidr_list` option is ommitted here. The problem is that the SSH connections -from Guacamole will appear to come from the `guacd` deamon, and within Guacamole's Vault -secret driver we do not have access to the IP address of `guacd`. So Guacamole requests -one-time passwords without specifying and IP address. This needs an additional change in -the Vault. To allow the role `generate_otp` to be used without IP addresses, the additional -command is +To generate a new CA in Vault: +```bash +curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + --request POST \ + --data '{"generate_signing_key": true}' \ + http://127.0.0.1:8200/v1/ssh/config/ca ``` -curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ - --request POST --data '{"roles": "guacamole"}' \ - http://127.0.0.1:8200/v1/ssh/config/zeroaddress -``` -The target machine needs to be setup for the Vault one-time password. Firstly SSH must be -configured to use PAM. As an example of the modification needed to test the one-time -passwords the following line might be added to the top of `/etc/pam.d/sshd`. +The response will include a `public_key`. **Save this key** to +`/etc/ssh/trusted_user_ca.pem` on all target machines where Guacamole will +connect via SSH. + +#### Configuring SSH to Trust the CA +1. On each target machine, add the following line to `/etc/ssh/sshd_config`: ``` -auth sufficent pam_exec.so expose_authtok /usr/local/bin/verify_otp.sh + TrustedUserCAKeys /etc/ssh/trusted_user_ca.pem ``` +2. Reload the SSH daemon to apply the changes: +```bash + pkill -SIGHUP /usr/sbin/sshd +``` + +:::{note} +This sends a `SIGHUP` signal to the SSH daemon, forcing it to reload its +configuration **without interrupting existing connections**. +::: -This is only an example that is guarenteed to work the one-time passwords without -preventing the existing methods from failing. The user will surely need to adapt -this to their installation. +#### Creating a Signing Role for Guacamole -The final piece needed on the client machine is the script `/usr/local/sbin/verify_otp.sh`. -A possible script that tests that the user corresponds to the user allowed by the one-time -password and that the one-time password is correct in +Guacamole requires the `permit-pty` option for SSH sessions. To create a +signing role named `guacamole_cert`: +```bash +curl -sS \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + --request POST \ + --data '{ + "algorithm_signer": "rsa-sha2-256", + "allow_user_certificates": true, + "allowed_users": "*", + "key_type": "ca", + "max_ttl": "30m", + "allowed_extensions": "permit-pty" + }' \ + http://127.0.0.1:8200/v1/ssh/roles/guacamole_cert ``` -#!/bin/sh -set -u -IFS= read -r OTP +:::{note} +- `max_ttl`: Limits the lifetime of signed certificates to **30 minutes**. Adjust this value based on: + - Network latency between Guacamole and the target machines. + - Clock drift between the Vault server and SSH clients. +- `permit-pty`: Required for Guacamole to allocate a pseudo-terminal (PTY) for SSH sessions. +::: -USERNAME="${PAM_USER}" +#### Creating an OTP Role for Guacamole -[ -z "${OTP}" ] && exit 1 -[ -z "${USERNAME}" ] && exit 2 +For **one-time passwords (OTP)**, create a role named `guacamole_otp` for a +default user (e.g., `testuser`): -RESPONSE=$(curl -s \ - --fail \ - --request POST \ +```bash +curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ --header "Content-Type: application/json" \ - --data '{"otp": "'${OTP}'"}' \ - http://127.0.0.1:8200/v1/ssh/verify) -[ "$?" -eq 0 ] || exit 3 + --request POST \ + --data '{"key_type": "otp", "default_user": "testuser"}' \ + http://127.0.0.1:8200/v1/ssh/roles/guacamole_otp +``` + +:::{warning} +- Each OTP role is **tied to a single username**. If you need OTPs for multiple + users, create **separate roles** for each user. +- Vault’s OTP engine **does not support** specifying the client IP address. + Since Guacamole’s SSH connections originate from the `guacd` daemon (and + Guacamole’s Vault extension cannot access the IP of `guacd`), you must + disable IP restrictions for the OTP role, by not specifying a `cidr_list`. +::: -[ "$(echo $RESPONSE | jq -r .data.username)" = "$USERNAME" ] || exit 4 +To allow the `guacamole_otp` role to be used **without IP restrictions**, run: + +```bash +curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + --request POST \ + --data '{"roles": "guacamole"}' \ + http://127.0.0.1:8200/v1/ssh/config/zeroaddress ``` -that should be saved at `/usr/local/sbin/verify_otp.sh` and made executable with the -command +#### Configuring Target Machines for OTP +To use **OTP authentication**, the target machines must be configured to +validate OTPs via **PAM (Pluggable Authentication Modules)**. + +1. **Modify `/etc/pam.d/sshd**` to include the following line at the **top** of the file: ``` -chmod 755 /usr/local/sbin/verify_otp.sh + auth sufficient pam_exec.so expose_authtok /usr/local/bin/verify_otp.sh ``` +:::{note} +This line tells PAM to use the `verify_otp.sh` script to validate OTPs. The +`expose_authtok` option passes the OTP to the script. +::: -:::warning -Using a shell script for a login is not the best security practice, and the user is -encouraged to look at the [Hashicorp Vault SSH Helper](https://github.com/hashicorp/vault-ssh-helper) +2. **Create the OTP Verification Script** (`/usr/local/bin/verify_otp.sh`): +```bash + #!/bin/sh + set -u + IFS= read -r OTP + USERNAME="${PAM_USER}" + [ -z "${OTP}" ] && exit 1 + [ -z "${USERNAME}" ] && exit 2 + RESPONSE=$(curl -s \ + --fail \ + --request POST \ + --header "Content-Type: application/json" \ + --data "{\"otp\": \"${OTP}\"}" \ + http://127.0.0.1:8200/v1/ssh/verify) + [ "$?" -eq 0 ] || exit 3 + [ "$(echo $RESPONSE | jq -r .data.username)" = "$USERNAME" ] || exit 4 +``` + +3. **Make the Script Executable**: +```bash + chmod 755 /usr/local/bin/verify_otp.sh +``` + +:::{warning} +Using a **shell script for authentication is not a best practice** for +production environments. Instead, use the **[Hashicorp Vault SSH Helper](https://github.com/hashicorp/vault-ssh-helper)**, +which is designed for this purpose and integrates directly with OpenSSH. ::: -#### Creating an LDAP secret engine for Guacamole +### Creating an LDAP Secret Engine for Guacamole -The LDAP secret engine allows Vault to supply credentials backed by an LDAP directory, -such as OpenLDAP or Microsoft Active Directory. Guacamole can use these credentials for -connections that authenticate against directory services. +The **LDAP secret engine** allows Vault to manage credentials for LDAP +directories, such as **OpenLDAP** or **Microsoft Active Directory**. +Guacamole can use these credentials for connections that authenticate against +LDAP. -As Vault will rotate its own LDAP password you are highly recommanded to use an LDAP account -dedicated to Vault. This should first be created on the LDAP server. For the example below -we assume that all user account are in the LDAP DN "ou=users,dc=example,dc=com". In real -deployment it would probably be better to use dedicated DN of hashicorp managed accounts and -limit the write permissions of hashicorp to these specific DN. +:::{note} +Vault **rotates its own LDAP password** automatically. For this reason, it is +**highly recommended** to use a **dedicated LDAP account** for Vault (e.g. +`uid=vault,ou=users,dc=example,dc=com`). +::: -We first need to generate an LDAP password hash for the initial password for Vault. The -exact command for this will depend on the hash used. For example, to use a modern password -hash like Argon2, if server uses openldap and supports it, a command to generate the password -hash might be +#### Prerequisites for LDAP + +1. **Generate a Password Hash**: +For modern LDAP servers (e.g., OpenLDAP with Argon2 support), generate a password hash like this: ``` $ slappasswd -o module-load=argon2.so -h '{ARGON2}' -s your_secret_password_here {ARGON2}$argon2id$v=19$m=7168,t=5,p=1$4JbzrJjMiH8x6Qfe5WsNtA$n5tzU8dGW2lFE+KDJRCLImKK+feE+mZaBmDP7nE9fyI ``` -This password hash must then be used in an LDIF file and added to the ldap server with -ldapadd like +Replace `your_secret_password_here` with the actual password for the Vault LDAP account. -``` -$ cat << EOLDIF > adduser.ldif +2. **Create a Dedicated LDAP Account for Vault (file: adduser.ldif) **: + - This account should have permissions to: + - **Read** user attributes (for static roles). + - **Modify passwords** (for dynamic roles or static roles with rotation). + - Example LDIF for creating a Vault LDAP account (using the above Argon2 password hash): +```ldif dn: uid=vault,ou=users,dc=example,dc=com objectClass: top objectClass: person @@ -360,23 +436,18 @@ cn: Vault User sn: Vault displayName: Vault description: Account used by Vault Server -userPassword: {{ARGON2}$argon2id$v=19$m=7168,t=5,p=1$4JbzrJjMiH8x6Qfe5WsNtA$n5tzU8dGW2lFE+KDJRCLImKK+feE+mZaBmDP7nE9fyI -EOLDIF -$ ldapadd -x -D "cn=admin,dc=example,dc=com" -W -f adduser.ldif +userPassword: {ARGON2}$argon2id$v=19$m=7168,t=5,p=1$4JbzrJjMiH8x6Qfe5WsNtA$n5tzU8dGW2lFE+KDJRCLImKK+feE+mZaBmDP7nE9fyI ``` -The exact LDIF might vary depending on the configuration of your ldap server. -For static, or existing accounts, the Vault only needs read permission and -the permission of modify the passwords of accounts Managed DN. Most LDAP -are configured to allow read access, except to the passwords. So only the -permission for access to the attribute `userPassword` will need to be changed. - -If an `olcAcess` for the attribute `userPassword` does not exist an example -of a change allowing access to `userPassword` of all users in the LDAP -for Vault might be - +3. **Add the LDAP Account to the Directory**: +```bash +ldapadd -x -D "cn=admin,dc=example,dc=com" -W -f adduser.ldif ``` -$ cat << EOLDIF > userpassword.ldif +4. **Grant Vault Permissions to Modify Passwords**: +Modify the LDAP ACLs to allow the Vault account to read and modify the +`userPassword` attribute. Example LDIF for OpenLDAP: + +```ldif dn: olcDatabase={1}mdb,cn=config changetype: modify add: olcAccess @@ -386,75 +457,58 @@ olcAccess: {0}to attrs=userPassword by anonymous auth by dn.base="cn=admin,dc=example,dc=com" manage by * none -EOLDIF -$ ldapmodify x -D "cn=admin,cn=config" -W -f userpassword.ldif ``` -if the block already exists then if will need to be modified instead, adding -the line for the Vault server, like +:::{warning} +Extreme care must be taken when modifying the permissions to access the LDAP. +The risk of removing all administrative access the the LDAP server exists. +Please respect any existing olcAccess attributes and double check any changes +you make. +::: +```bash +ldapmodify x -D "cn=admin,cn=config" -W -f userpassword.ldif ``` -$ cat << EOLDIF > userpassword.ldif -dn: olcDatabase={2}mdb,cn=config -changetype: modify -replace: olcAccess -olcAccess: {0}to attrs=userPassword - by dn.exact="uid=vault,ou=users,dc=example,dc=com" write - by self write - by anonymous auth - by dn.base="cn=admin,dc=example,dc=com" manage - by * none -EOLDIF -$ ldapmodify x -D "cn=admin,cn=config" -W -f userpassword.ldif -``` - -Be careful to keep any existing rules. +:::{warning} +The above rule grants the Vault server **write access to all passwords** in +the `ou=users,dc=example,dc=com` subtree. **This is not secure for production!** +**Best Practice**: Isolate Vault-managed accounts in a **dedicated DN** +(e.g., `ou=vault,dc=example,dc=com`) and restrict write permissions to this DN +only. Example: -:::warning -The above change gives the Vault server the permission to change all passwords -on the LDAP server in the DN "ou=users,dc=example,dc=com". For this reason the -best pracitices is to isolate the Vault managed account in a seperate DN and -only give write permission on this DN. Then the change might instead be - -``` -$ cat << EOLDIF > userpassword.ldif +```ldif dn: olcDatabase={2}mdb,cn=config changetype: modify -replace: olcAccess +add: olcAccess olcAccess: {0}to dn.subtree="ou=vault,dc=example,dc=com" attrs=userPassword - by dn.exact="uid=vault,ou=users,dc=example,dc=com" write + by dn.exact="uid=vault,ou=users,dc=example,dc=com" manage by anonymous auth by dn.base="cn=admin,dc=example,dc=com" manage by * none -EOLDIF -$ ldapmodify x -D "cn=admin,cn=config" -W -f userpassword.ldif ``` -This rule needs to be before the generique `userPassword` rule to be effective +This rule **must appear before** any generic `userPassword` rules to take effect. ::: -If you wish to use LDAP dynamic roles with Vault, the Vault server needs to be able -to add accounts to the LDAP server, and so permission to write to all attributes of -the managed DN. This might be done like +5. **For Dynamic LDAP Roles**: +If you plan to use **dynamic LDAP roles**, Vault needs permissions to **create +and delete accounts** in the LDAP directory. Example LDIF: -``` -$ cat << EOLDIF > acl.ldif +```ldif dn: olcDatabase={1}mdb,cn=config changetype: modify add: olcAccess olcAccess: {3}to dn.subtree="dc=example,dc=com" by dn.exact="uid=vault,ou=users,dc=example,dc=com" write -EOLDIF -$ ldapmodify -x -D "cn=admin,cn=config" -W -f acl.ldif ``` -where the values might vary with your LDAP installation. The next step is to -setup a password policy for the LDAP server, giving the rules for the passwords -in will generate when rotating passwords. An example policy might be created -in the file `ldap-policy.hcl` and uploaded to the vault server like +#### Configuring the LDAP Secret Engine in Vault -``` -$ cat << EOHCL > ldap-policy.hcl +1. **Create a Password Policy for LDAP**: +Vault can generate random passwords for LDAP accounts based on a **password +policy**. Example policy (`ldap-policy.hcl`): + +```hcl length = 20 rule "charset" { @@ -475,59 +529,63 @@ rule "charset" { rule "charset" { charset = "!@#%^&*" min_chars = 1 -} -EOHCL -$ curl -s --header "X-Vault-Token: $VAULT_TOKEN" \ - --request POST --data-urlencode "policy@ldap-policy.hcl" \ - http://127.0.0.1:8200/v1/sys/policies/password/ldap-policy ``` -At this point you are now ready to connect your Vault server to your LDAP -with the previously created credentials. +2. **Upload the policy to Vault:** -``` -$ curl -s \ - --header "X-Vault-Token: $VAULT_TOKEN" \ - --header "Content-Type: application/json" \ - --request PUT \ - --data '{ - "schema": "openldap", - "url": "ldaps://easyctf", - "binddn": "uid=vault,ou=users,dc=easyctf", - "bindpass": "your_secret_password_here", - "password_policy": "ldap-policy", - "insecure_tls": true - }' \ - http://127.0.0.1:8200/v1/ldap/config +``bash +curl -s --header "X-Vault-Token: $VAULT_TOKEN" \ + --request POST --data-urlencode "policy@ldap-policy.hcl" \ + http://127.0.0.1:8200/v1/sys/policies/password/ldap-policy ``` -The `insecure_tls` option is used here only for testing. In production you should -ensure that the LDAP certificates ares signed by a certificate authority and the -CA certificate is installed in the above command with either `tls_ca_cert` or -`tls_ca_cert_file` options. +3. **Configure the LDAP Connection in Vault**: + + ```bash + curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + --request PUT \ + --data '{ + "schema": "openldap", + "url": "ldaps://ldap.example.com", + "binddn": "uid=vault,ou=users,dc=example,dc=com", + "bindpass": "your_secret_password_here", + "password_policy": "ldap-policy", + "insecure_tls": false + }' \ + http://127.0.0.1:8200/v1/ldap/config + ``` +:::{warning} +The `insecure_tls: false` option is **not secure** for production. Instead: +- Ensure your LDAP server uses **TLS with a valid certificate**. +- Specify the CA certificate using the `tls_ca_cert` or `tls_ca_cert_file` options. +::: -Before we go any further it is important at this point to ensure that the Vault -server can actually communicate with the LDAP server, and there are no configuration -issues either in the previous commands or the network. These can be done by forcing -the Vault server to rotate its password with the command +4. **Test the LDAP Connection**: +Before proceeding, verify that Vault can communicate with the LDAP server by +forcing a password rotation: -``` -$ curl -s \ +```bash +curl -s \ --header "X-Vault-Token: $VAULT_TOKEN" \ --request POST \ http://127.0.0.1:8200/v1/ldap/rotate-root ``` -If the command executes correctly, the Vault password has beed changed and the Vault -server can communicate with the LDAP server +If the command succeeds, Vault has successfully rotated its LDAP password and +can communicate with the LDAP server. -To create a static role for Guacamole, the command is slightly different for OpenBao -and Hashicorp Vault. +#### Creating a Static LDAP Role for Guacamole -**Hashicorp Vault static ldap example:** +Static roles use **existing LDAP accounts** and rotate their passwords +automatically. The syntax is different here depending on whether you are +using OpenBao or Hashicorp Vault. -``` -$ curl -s \ +**For HashiCorp Vault**: + +```bash +curl -s \ --header "X-Vault-Token: $VAULT_TOKEN" \ --header "Content-Type: application/json" \ --request POST \ @@ -540,10 +598,10 @@ $ curl -s \ http://127.0.0.1:8200/v1/ldap/static-role ``` -**OpenBao Vault static ldap example:** +**For OpenBao**: -``` -$ curl -s \ +```bash +curl -s \ --header "X-Vault-Token: $VAULT_TOKEN" \ --header "Content-Type: application/json" \ --request POST \ @@ -555,203 +613,425 @@ $ curl -s \ http://127.0.0.1:8200/v1/ldap/static-role/guacamole ``` -This will manage an existing account with the username `guacamole` on the LDAP -server, rotating its password as required. +:::{note} +- Guacamole **does not** manage LDAP lease revocation. Rotated credentials + remain valid until manually revoked or rotated again by Vault. +- The `rotation_period` specifies how often Vault rotates the password for + the LDAP account (e.g., every 1 hour). +::: + +#### Creating a Dynamic LDAP Role for Guacamole + +To enable **dynamic LDAP account creation** in Vault for Guacamole, you need +to configure Vault to **automatically create, delete, and roll back LDAP +accounts** on demand. This is useful for scenarios where you want Vault to +**temporarily provision LDAP accounts** for Guacamole connections and +**automatically clean them up** after use. + +Dynamic roles require **three LDIF files** to define how Vault interacts with +your LDAP directory: - #TODO Dynamic Accounts !! +`**creation.ldif**`: Defines how new accounts are created. +`**deletion.ldif**`: Defines how accounts are deleted. +`**rollback.ldif**`: Defines how to revert changes if account creation fails (often identical to `deletion.ldif`). -Guacamole does not manage LDAP lease revocation and does not attempt to check in -credentials after use. +:::{note} +The exact content of these files **depends on your LDAP schema and directory +structure**. The examples below are for **Unix-like accounts** (e.g. +`inetOrgPerson`, `posixAccount`) and will need to be adapted to match your +environment. +::: -#### Creating a Database secret engine for Guacamole +1. **Create the `creation.ldif` File** -The first step in allowing the Vault server to manage the database is to create -a role for the vault within the database used by Guacamole. As the vault server -can and does modify its own password in the database, it is strong recommanded -that this created account is used only by the Vault server itself. +The `creation.ldif` file defines the **attributes of new LDAP accounts** +created by Vault. Below is an example for creating **Unix user accounts** in an +OpenLDAP directory: -In this below we assume thay Postgresql is used, and the commands might need to be -slightly modified for other databases. A command to create a role for the Vault -server in our database, with th enecessary permission will be something like +```ldif +dn: uid={{.Username}},ou=users,dc=example,dc=com +objectClass: inetOrgPerson +objectClass: posixAccount +objectClass: shadowAccount +uid: {{.Username}} +cn: {{.Username}} +sn: {{.Username}} +gidNumber: 50 +homeDirectory: /home/{{.Username}} +loginShell: /bin/sh +userPassword: {{.Password}} +``` + +:::{note} +- `**{{.Username}}**` and `**{{.Password}}**` are **placeholders** that Vault + replaces with dynamically generated values. +- The `**uidNumber**` attribute is **intentionally omitted** in this example. + Vault **does not** automatically increment `uidNumber` values. If you need unique + `uidNumber` values, consider: + - Using the `**autonumber` overlay** in OpenLDAP to auto-assign `uidNumber`. + - Pre-defining a `uidNumber` value knowing all dynamic accounts will reuse it. +::: +:::{warning} +- The `**userPassword**` field is provided in **plaintext** in this LDIF file. To + ensure the password is **hashed before storage**, configure your LDAP server with: + +```ldif +olcPPolicyHashCleartext: TRUE ``` -$ cat << EOSQL > psql --dbname "guacamole_db" -CREATE ROLE vault_admin WITH - LOGIN - PASSWORD 'secure_password_here' - CREATEROLE - NOINHERIT; -GRANT CREATE ON DATABASE guacamole_db TO vault_admin; -GRANT USAGE ON SCHEMA public to vault_admin; -EOSQL + + This ensures the LDAP server **hashes the password** before storing it in the + directory. +::: + +2. **Create the `deletion.ldif` and `rollback.ldif` Files** + +The `deletion.ldif` and `rollback.ldif` files define how Vault **removes +accounts** from the LDAP directory. In most cases, these files are +**identical**, as rolling back a failed creation typically involves deleting +the account. + +Example `deletion.ldif` (and `rollback.ldif`): + +```ldif +dn: uid={{.Username}},ou=users,dc=example,dc=com +changetype: delete +``` + +:::{note} +- The `**changetype: delete**` directive tells LDAP to **remove the entire + entry** matching the `dn`. +- The `**{{.Username}}**` placeholder is replaced with the username of the + account to delete. +::: + +3. **Configure LDAP Permissions for Dynamic Roles** + +For Vault to **create and delete accounts**, it needs **sufficient +permissions** in your LDAP directory. Specifically: + +- **Write access** to the **DN subtree** where accounts will be created (e.g. + `ou=users,dc=example,dc=com`). +- **Write access** for the `userPassword` attribute + +To grant Vault **full write access** to a subtree (e.g., `ou=dynamic,dc=example,dc=com`), use the following LDIF: + +```ldif +dn: olcDatabase={1}mdb,cn=config +changetype: modify +add: olcAccess +olcAccess: {2}to dn.subtree="ou=dynamic,dc=example,dc=com" + by dn.exact="uid=vault,ou=users,dc=example,dc=com" write + by * none +``` + +:::{warning} +Granting **write access** to a broad subtree (e.g., `ou=users,dc=example,dc=com`) +can be a **security risk**. **Best practice** is to: + +- **Isolate dynamic accounts** in a dedicated subtree (e.g., `ou=dynamic,dc=example,dc=com`). +- **Restrict Vault’s permissions** to only this subtree. +::: + +4. **Create the Dynamic Role in Vault** + +Once the LDIF files are ready, create a **dynamic role** in Vault using the +`curl` command below. This role defines how Vault generates and manages dynamic +LDAP accounts for Guacamole. + +```bash +curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + --request POST \ + --data '{ + "creation_ldif": "dn: uid={{.Username}},ou=users,dc=example,dc=com\nobjectClass: inetOrgPerson\nobjectClass: posixAccount\nobjectClass: shadowAccount\nuid: {{.Username}}\ncn: {{.Username}}\nsn: {{.Username}}\ngidNumber: 50\nhomeDirectory: /home/{{.Username}}\nloginShell: /bin/sh\nuserPassword: {{.Password}}", + "deletion_ldif": "dn: uid={{.Username}},ou=users,dc=example,dc=com\nchangetype: delete", + "rollback_ldif": "dn: uid={{.Username}},ou=users,dc=example,dc=com\nchangetype: delete", + "username_template": "v_{{.RoleName}}_{{random 6}}", + "dn": "uid=vault,ou=users,dc=example,dc=com", + "default_ttl": "1h", + "max_ttl": "24h" + }' \ + http://127.0.0.1:8200/v1/ldap/roles/guacamole ``` -The next step is to activate the database secret engine of the Vault +5. Test the Dynamic Role +After creating the role, test it by **requesting a dynamic LDAP credential** +from Vault: + +```bash +curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + http://127.0.0.1:8200/v1/ldap/creds/guacamole ``` -curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ - --request POST --data '{"type": "database"}' http://127.0.0.1:8200/v1/sys/mounts/db + +The response will include: + +- A **dynamically generated username** (e.g., `v_guacamole_abc123`). +- A **password** for the account. +- The **TTL** (time-to-live) of the credential. + +:::{note} +- The account will be **automatically deleted** from LDAP after the `default_ttl` + (1 hour in the example). +- You can **manually revoke** the credential by deleting its lease in Vault. +::: + +#### Creating a LDAP Service Accounts for Guacamole + + #TODO + +### Creating a Database Secret Engine for Guacamole + +The **Database secret engine** allows Vault to generate **dynamic database +credentials** for Guacamole. This is useful for connections that require +database access (e.g., PostgreSQL, MySQL). + +#### Prerequisites for Database Engine + +1. **Create a Dedicated Database Role for Vault**: + - This role should have permissions to: + - **Create new roles** (for dynamic credentials). + - **Modify passwords** (for rotation). + - Example for PostgreSQL: +```sql +CREATE ROLE vault_admin WITH LOGIN PASSWORD 'secure_password_here' CREATEROLE NOINHERIT; +GRANT CREATE ON DATABASE guacamole_db TO vault_admin; +GRANT USAGE ON SCHEMA public TO vault_admin; + ``` +2. **Apply the SQL Commands**: + Save the above SQL to a file (e.g., `create_vault_role.sql`) and run it: + +```bash +cat create_vault_role.sql | psql --dbname "guacamole_db" ``` -and then create a role for Guacamole needs to be created with the desired permissions +#### Enabling the Database Secret Engine +To enable the database secret engine on a mount path named `db`: +```bash +curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + --request POST \ + --data '{"type": "database"}' \ + http://127.0.0.1:8200/v1/sys/mounts/db ``` -curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ - --request POST --data '{ - "db-name": "guacamole_db", + +#### Creating a Dynamic Database Role for Guacamole + +To create a role that generates **dynamic credentials** for Guacamole: + +```bash +curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + --request POST \ + --data '{ + "db_name": "guacamole_db", "default_ttl": "1h", "max_ttl": "24h", - "create_statements": "CREATE ROLE \"{{name}}\" - WITH LOGIN PASSWORD \"{{password}}\" - VALID UNTIL \"{{expiration}}\"; - GRANT SELECT,INSERT,UPDATE,DELETE - ON ALL TABLES IN SCHEMA public - TO \"{{name}}\"; - GRANT SELECT,USAGE ON ALL SEQUENCES - IN SCHEMA public - TO \"{{name}}\"; - GRANT ALL ON SCHEMA public TO \"{{name}}\";", - "rotation_statements": "ALTER ROLE \"{{name}}\" - WITH PASSWORD \"{{password}}\";"}' \ - http://127.0.0.1:8200/v1/sys/mounts/db -``` - -This role assumes that Guacamole has already created all of its databases and the permissions -needed afterwards are more limited. With this role the Vault will supply Guacamole with a new -username and password every 24 hours, and both will be random. If you prefer to use a static -username the `{{name}}` field should be replaced with the desired username and flags as static -as follows - -``` -curl -s --header "X-Vault-Token: $VAULT_TOKEN" \ - --header "Content-Type: application/json" \ - --request POST --data '{ - "db-name": "guacamole_db", + "create_statements": [ + "CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD \"{{password}}\" VALID UNTIL \"{{expiration}}\";", + "GRANT SELECT,INSERT,UPDATE,DELETE ON ALL TABLES IN SCHEMA public TO \"{{name}}\";", + "GRANT SELECT,USAGE ON ALL SEQUENCES IN SCHEMA public TO \"{{name}}\";", + "GRANT ALL ON SCHEMA public TO \"{{name}}\";" + ], + "rotation_statements": [ + "ALTER ROLE \"{{name}}\" WITH PASSWORD \"{{password}}\";" + ] + }' \ + http://127.0.0.1:8200/v1/db/roles/guacamole +``` + +:::{note} +- `default_ttl`: Default time-to-live for generated credentials (e.g., 1 hour). +- `max_ttl`: Maximum time-to-live for generated credentials (e.g., 24 hours). +- `create_statements`: SQL commands to create a new database role with the + generated credentials. +- `rotation_statements`: SQL commands to rotate the password for an existing + role. +- `{{name}}` and `{{password}}`: Placeholders for the dynamically generated + username and password. +::: + +#### Creating a Static Database Role for Guacamole + +If you prefer to use a **static username** (e.g., `guacamole_user`) instead of +dynamic usernames, modify the role as follows: + +```bash +curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + --request POST \ + --data '{ + "db_name": "guacamole_db", "default_ttl": "1h", "max_ttl": "24h", - "create_statements": "CREATE ROLE \"guacamome_user\" - WITH LOGIN PASSWORD \"{{password}}\" - VALID UNTIL \"{{expiration}}\"; - GRANT SELECT,INSERT,UPDATE,DELETE - ON ALL TABLES IN SCHEMA public - TO \"guacamole_user\"; - GRANT SELECT,USAGE ON ALL SEQUENCES - IN SCHEMA public - TO \"guacamole_user\"; - GRANT ALL ON SCHEMA public TO \"guacamole_user\";", - "rotation_statements": "ALTER ROLE \"guacamole_user\" - WITH PASSWORD \"{{password}}\";", - "static_username": true}' \ - http://127.0.0.1:8200/v1/db/roles/guacamole + "create_statements": [ + "CREATE ROLE \"guacamole_user\" WITH LOGIN PASSWORD \"{{password}}\" VALID UNTIL \"{{expiration}}\";", + "GRANT SELECT,INSERT,UPDATE,DELETE ON ALL TABLES IN SCHEMA public TO \"guacamole_user\";", + "GRANT SELECT,USAGE ON ALL SEQUENCES IN SCHEMA public TO \"guacamole_user\";", + "GRANT ALL ON SCHEMA public TO \"guacamole_user\";" + ], + "rotation_statements": [ + "ALTER ROLE \"guacamole_user\" WITH PASSWORD \"{{password}}\";" + ], + "static_username": true + }' \ + http://127.0.0.1:8200/v1/db/roles/guacamole ``` -at this point we can now configure the database plugin with the desired role as follows +#### Configuring the Database Connection -``` -curl -s --header "X-Vault-Token: $VAULT_TOKEN" \ - --header "Content-Type: application/json" \ - --request POST --data '{"plugin_name": "postgresql-database-plugin", - "connection_url": "postgresql://{{username}}:{{password}}@127.0.0.1:5432/guacamole_db", - "allowed_roles": "guacamole", - "username": "vault_admin"; - "password": "secure_password_here"}' - http://127.0.0.1:8200/v1/db/config/guacamole_db +Configure Vault to connect to your database (e.g., PostgreSQL): + +```bash +curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + --request POST \ + --data '{ + "plugin_name": "postgresql-database-plugin", + "connection_url": "postgresql://{{.Username}}:{{.Password}}@127.0.0.1:5432/guacamole_db", + "allowed_roles": "guacamole", + "username": "vault_admin", + "password": "secure_password_here" + }' \ + http://127.0.0.1:8200/v1/db/config/guacamole_db ``` -The username and password here are for the account created for the Vault in our -database. +:::{note} +- `connection_url`: The URL template for connecting to the database. + `{{.Username}}` and `{{.Password}}` are placeholders that Vault replaces + with dynamic credentials. +- `allowed_roles`: Comma-separated list of roles that can use this connection. +- `username` and `password`: Credentials for the Vault database account (e.g. + `vault_admin`). +::: -### Authenticating Guacamole to the Vault +## Authenticating Guacamole to Vault -#### Authentication using a Token String +Guacamole supports the following authentication methods to Vault: -Guacamole can authenticate to OpenBao using a token. This token must use a role -as previously discussed above and in most cases it should not expire. Guacamole -will attempt to renew tokens if they are marked as renewable. However, the vault -server imposes a maximum lifetime of renewable tokens that is by default is -32 days, and after that time Guacamole will not be able to renew the token. +| Method | Description | +| --------------------- | ------------------------------------------------------------------------------------------ | +| **Token String** | Static or renewable token provided directly in Guacamole’s configuration. | +| **Token Sink File** | Vault Agent writes a token to a file; Guacamole reads it. Supports complex roles. | +| **Username/Password** | Uses Vault’s `userpass` auth method. Tokens are short-lived and auto-renewed by Guacamole. | -Also if Guacamole does not suceed in contacting the vault for a period corresponding -to the token's TTL, the token will have expired and not be renewable. THis might -cause problems in acse of network problems or server reboots. So if you choose to -supply a token as a string to Guacamole you are encouraged to use a non expiring -token (with a TTL of zero). +### Authentication Using a Token String -To create a static token for use with Guacamole, a command like +Guacamole can authenticate to Vault using a **static token**. This token must: +- Use a **role with the `guacamole` policy** (as defined earlier). +- **Not expire** (set `ttl: 0`), or Guacamole may lose access after network issues or reboots. -``` -$ curl -sS \ +If expiring tokens are used, Guacamole will renew the token on expiration. +However, in case of network problems this renewal might not succeed in time. +Also, there is a hard limit on the maximum TTL of renewable tokens, that is by +default configured as 32 days in the Vault. After this period the token will fail +to be renewed + +#### Creating a Non-Expiring Token for Guacamole + +```bash +curl -sS \ --header "X-Vault-Token: $VAULT_TOKEN" \ --request POST \ - http://127.0.0.1:8200/v1/auth/token/create \ --data '{ "policies": ["guacamole"], "ttl": "0", "renewable": false, "display_name": "service-guacamole", "no_parent": true - }' + }' \ + http://127.0.0.1:8200/v1/auth/token/create ``` -where the `ttl` or zero ensures that the token will not expire. The previously created -`guacamole` policy is used for the created token. The token to use is stored in the value -of `.auth.client_token`. +:::{note} +- The `ttl: 0` ensures the token **never expires**. +- The `no_parent: true` option prevents the token from being revoked if its + parent token is revoked. +- The token value is returned in the `.auth.client_token` field of the + response. Use this value in Guacamole’s configuration (e.g., `vault-token` + in `guacamole.properties`). +::: -#### Authentication using a Token sink file +### Authentication Using a Token Sink File -Guacamole does not implement complex authentication methods, such as AppRole. But these -can still be used with the help of a [Vault agent](https://openbao.org/https://openbao.org/docs/agent-and-proxy/agent). -The Vault Agent manages the authentication and renewal of the authentication and just -writes a standard Vault service token to a sink file. Guacamole can read this file -at startup or in case of an expiring token and always have a valid token. +Guacamole **does not** implement complex authentication methods like +**AppRole** directly. However, you can use a **[Vault Agent](https://openbao.org/docs/agent-and-proxy/agent)** +to manage authentication (e.g., AppRole) and write a **standard Vault token** +to a **sink file**. Guacamole can then read this file at startup or when the +token is about to expire. -The configuration of the Vault Agent is beyond the scope of this document and the user is -encouraged to read the [OpenBao Vault Agent](https://openbao.org/docs/agent-and-proxy/agent) -or [Hashicorp Vault Agent](https://developer.hashicorp.com/vault/docs/agent-and-proxy/agent) -documentation. These vault agents can be used with AppRole authentication as discussed -for [OpenBao AppRole](https://openbao.org/docs/auth/approle) or -[Hashicorp AppRole](https://developer.hashicorp/vault/docs/auth/approle) -secrets. +#### How It Works -#### Authentication using Username and Password +1. The `vault_token property of **Guacamole** is configured with a location of a file. +2. **Vault Agent** authenticates to Vault (e.g., using AppRole) and writes a + token to this file (e.g., `/etc/guacamole/vault-token`). +3. **Guacamole** reads this file at startup and whenever the token is about to expire. +4. **Vault Agent** automatically renews the token as needed. -If the authentication to Vault is via a username and password, we first need to have -the `userpass` authentication method mounted which it isn't be default. Assuming -an authentication mount path of `auth/userpass` this can be done with the command +:::{note} +The configuration of Vault Agent is **beyond the scope of this document**. Refer to: -``` -curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ - --request POST --data '{"type": "userpass"}' \ - http://127.0.0.1:8200/v1/sys/auth/userpass -``` +- [OpenBao Vault Agent Documentation](https://openbao.org/docs/agent-and-proxy/agent) +- [HashiCorp Vault Agent Documentation](https://developer.hashicorp.com/vault/docs/agent-and-proxy/agent) +::: -We can then create a dediciated account for Guacamole in the Vault. This can be done -with a command like +This is the best solution for production, but it requires Vault Agent. +### Authentication Using Username and Password + +If you prefer to authenticate Guacamole to Vault using a **username and +password**, you must first enable the `userpass` authentication method in +Vault. + +#### Enabling the `userpass` Auth Method + +```bash +curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + --request POST \ + --data '{"type": "userpass"}' \ + http://127.0.0.1:8200/v1/sys/auth/userpass ``` -curl \ - -H "X-Vault-Token: $VAULT_TOKEN" \ - -X POST \ - http://127.0.0.1:8200/v1/auth/userpass/users/guacamole \ - -d '{ + +#### Creating a Guacamole User in Vault + +```bash +curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + --request POST \ + --data '{ "password": "strong-password-here", "policies": ["guacamole"], - "token-ttl": "10m", - "token-max-ttl": "60m" - }' + "token_ttl": "10m", + "token_max_ttl": "60m" + }' \ + http://127.0.0.1:8200/v1/auth/userpass/users/guacamole ``` -This creates a user `guacmaole` using the same policy as previously and sets the -password assuming the userpass engine is mounted on the default path `auth/userpass`. -In the above example the TTL of the token is 10 minutes. Guacamole with renew the -token recovered on the login upto 60 minutes or the maximum TTL for the token. After -Guacamole will reauthenticate and recover a new token. +:::{note} +- The `token_ttl` (10 minutes) is the default lifetime of tokens generated + for this user. +- Guacamole will **automatically renew** the token up to the `token_max_ttl` + (60 minutes). After this, Guacamole will reauthenticate and generate a new + token. +- Vault **cannot** rotate the password for `userpass` users. If you change the + password in Vault, you must also update it in Guacamole’s configuration. +::: -It should be noted that the Vault can not rotate the passwords of these user -accounts. So the maintenance and update of the password needs to be done manually, -updating the password configured in Guacamole at the same time. +Vault **cannot** rotate `userpass` passwords, as the user must know the value. +Therefore, the user is responsible for the rotation of this password. (guac-vault-config)= @@ -759,13 +1039,13 @@ Required configuration ---------------------- {% call ext.config('openbao', required=True) %} -Guacamole requires only a single configuration property to configure secret -retrieval from a Vault, `vault-uri`, the address of the vault server. The -option `vault-token` is not strictly necessary, but if it is absent it must -be replaced with `vault-username` and `vault-password` as discussed below. -All other {{ ext.properties() }} are optional. + +Strictly speaking, Guacamole requires only `vault-uri`, the address of the Vault +server. The property `vault-token` can be replaced by `vault-username` and +`vault-password`. {% endcall %} + ### Additional Configuration Options {% call ext.config('openbao-optional') %} @@ -785,183 +1065,290 @@ Completing installation Retrieving connection secrets from a vault ------------------------------------------ -Secrets for connection parameters are provided using [parameter -tokens](parameter-tokens) that can be either automatically or manually defined. -Automatic tokens are [defined dynamically by Guacamole when the connection is -used](vault-dynamic-secrets) based on other configuration values within the -connection, such as the connection's `hostname` or `username`. Manual tokens -are injected by Guacamole based on secrets that are [statically mapped using an -additional configuration file](vault-static-secrets). +Secrets for connection parameters provided by **[parameter tokens](parameter-tokens)** +by Vault all take the form {samp}`${vault://{MOUNT_PATH}/{PATH}/{SECRET}}`, where -(vault-dynamic-secrets)= +- `{MOUNT_PATH}`: The mount path of the Vault secret engine used (e.g., `kv2`, `ssh`, `ldap`). +- `{PATH}`: The path to the secret record. +- `{SECRET}`: The key within the secret record (e.g., `username`, `password`). -### Automatic injection of secrets based on connection parameters +Guacamole generally uses the values for `{MOUNT_PATH}` and `{PATH}` as used by the +Vault itself. For example if the OpenBao command used to recover a secret record is -Parameter tokens containing the values of secrets within a record are -automatically injected for connections whose parameter values match specific -criteria, such as having a particular `username` or `hostname`. This happens -whenever a connection is used and is fully dynamic, affecting only the state of -the connection from the perspective of the user accessing it. +```bash +bao read kv2/guacamole/myaccount +``` -:::{important} -There are limitations to the degree that secrets can be automatically applied -based on connection parameters: +the **parameter token** in Guacamole will start with `vault://kv2/guacamole/myaccount`. +The exception to this rule is for LDAP Service accounts, where OpenBao would check +out the account with the command -* Automatic injection of secrets cannot currently be used with balancing - connection groups, as the underlying connection that the balancing - implementation will choose cannot be known before token values must be made - available. +```bash +bao write -f ldap/library/guacamole/myteam/check-out +``` -If automatic injection of secrets cannot work for your use case, consider using -[manually-specified secrets via `vault-token-mapping.yml`](vault-static-secrets). -::: +the corresponding Guacamole token will start with `vault://ldap/library/guacamole/myteam`. -Parameter tokens injected from Vault records usually take the form -{samp}`$\{VAULT:\{MOUNT_PATH\}/\{PATH\}/\{SECRET\}\}`, where `MOUNT_PATH` is the -mount path of the Vault secret engine used, `PATH` is the path to the secret -record and `SECRET` determines what value is retrieved from that record. +The secret record recovered from the Vault is JSON with both data +and metadata. Guacamole requires that `{SECRET}` corresponds to one of the +parameters in a data block. For example the command -#### Mount path detection +```bash +curl --header "X-Vault-Token: $VAULT_TOKEN" http://127.0.0.1:8200/v1/kv2/secrets/my-secret | jq +``` +will return the JSON -Guacamole automatically detecting all of the available mount paths on the vault server -and detects their type. The selection of which secret engine that Guacamole used is -therefore based the value of `MOUNT_PATH`. +```json +{ + "data": { + "data": { + "foo": "bar" + }, + "metadata": { + "created_time": "2018-03-22T02:24:06.945319214Z", + "custom_metadata": { + "owner": "jdoe", + "mission_critical": "false" + }, + "deletion_time": "", + "destroyed": false, + "version": 2 + } + } +} +``` -It is possible to have a value of the mount path like `subpath1` and a second mount path -`subpath1/subpath2`. Guacamole therefore chooses the secret engine with the longest -match to the start of the token. +and the only possible value of `{SECRET}` for this secret record is `foo`. +So for this case the only possible **parameter token** is +{samp}`${vault://kv2/secrets/my-secret/foo}`. -#### Secret Engines and paths supported +#### Mount Path Detection -The following Vault secret engines are supported +Guacamole **automatically detects** all available mount paths on the Vault +server and their types. The selection of which secret engine Guacamole uses is +based on the **longest matching prefix** of the `{MOUNT_PATH}` in the token. -`LDAP` -: The Vault secret engine supports both open source ldap servers and Microsoft Active - Directory. There are three types of LDAP accounts in the Vault. A `static` role has - a fixed usename, a `dynamic` role is an account created and deleted by the vault for - the demanded operation, and a `service` role is a pool of accounts managed by the - vault that can be used. +For example, if you have two mount paths: - Guacamole simplifies the tokens for the LDAP paths in the following manner +- `subpath1` +- `subpath1/subpath2` + +A token with `{MOUNT_PATH}=subpath1/subpath2` will use the secret engine +mounted at `subpath1/subpath2`. + +#### Secret Engines and Paths Supported + +The following Vault secret engines are supported by Guacamole: + +##### LDAP Secret Engine + +The Vault LDAP secret engine supports both **open-source LDAP servers** (e.g., OpenLDAP) and **Microsoft Active Directory**. There are three types of LDAP accounts in Vault: + +1. **Static roles**: Use a **fixed username** (e.g., `guacamole`). Vault rotates the password automatically. +2. **Dynamic roles**: Vault **creates and deletes** an account for each request. +3. **Service roles**: Vault manages a **pool of accounts** that can be reused. + +Guacamole simplifies the token format for LDAP paths as follows: ``` -${vault://{MOUNT_PATH}/{static|dynamic|service}/{ROLE}/{username/password}} +${VAULT:{MOUNT_PATH}/{static-role|static-cred|creds|library}/{ROLE}/{username|password}} ``` - An example of a valid token might then be `${vault://ldap/static/guacamole/username}`, - where the value of `ROLE` here is `guacamole`. +As the paths of OpenBao and Hashicorp Vaults are different for static passwords, it +is up to the user to use tokens that are acceptable to the Vault being used. An +example of a static credential for OpenBao is of the form - Please note that Guacamole does not touch the lease times associated with these accounts - in the vault and it does not check in the service accounts after use. +``` +${vault://{MOUNT_PATH}/static-cred/{ROLE}/{username/password}} +``` -`SSH` -: The Vault secret engine support both one-time passwords and signed certificate modes of - the secret engine. This secret engine required a helper command on the client machines - and is defined by its role. Tokens for one-time passwords are of the form +and for Hashicorp Vault is of the form ``` -${vault://{MOUNT_PATH}/otp/{ROLE}/{username/password}} +${vault://{MOUNT_PATH}/static-role/{ROLE}/{username/password}} ``` - An example of a valid token might then be `${vault://ssh/otp/myaccount/username}`, - where the value of `ROLE` here is `myaccount`. +For dynamic credentials both OpenBao and Hashicorp use the same paths, which are +of the form - Vault does not support the creation of certificates itself, so the role for SSH certificate - creation is left to Guacamole. A valid SSH token for the creation of a certificate is +``` +${vault://{MOUNT_PATH}/creds/{ROLE}/{username/password}} +``` + +Guacamole simplifies the tokens for the LDAP paths in the following manner ``` -${vault://{MOUNT_PATH}/cert/{ROLE}/{public/private}} +${vault://{MOUNT_PATH}/library/{ROLE}/{username/password}} ``` - An example of a valid token might then be `${vault://ssh/cert/myaccount/public}`, - where the value of `ROLE` here is `myaccount`, and the public certificate for use with - Guacamole is recovered. For SSH signed certificates the principal name used is determined - by the `username` of the connection and the certificate will be limited for the use of - only this user. Also as Guacamole does not need port forwarding the certificate is created - in a manner that refuses all use of port forwarding. +An example of a valid token might then be `${vault://ldap/creds/guacamole/username}`, +where the value of `ROLE` here is `guacamole`. + +Please note that Guacamole does not touch the lease times associated with these accounts +in the vault and it does not check in the service accounts after use. + + +:::{warning} +Guacamole attempts to check-in the service accounts when the tunnel is closed. +However, a maximum lease time of the service accounts of 2 hours is imposed by +Guacamole and afterthis time the Vault will automatically assume the account is +again available. +::: + +##### SSH Secret Engine + +The Vault SSH secret engine supports: -`DATABASE` -: Guacamole supports all databases supported by the Vault database secret engine. The form - of the database tokens are +1. **One-time passwords (OTP)**. +2. **Signed certificates**. + +Guacamole supports both methods, but **signed certificates are recommended** +for security and ease of use. + +###### Tokens for One-Time Passwords (OTP) + +Tokens for OTP take the form: ``` -${vault://{MOUNT_PATH}/{ROLE}/{username/password}} +${VAULT:{MOUNT_PATH}/creds/{ROLE}/{username|password}} ``` - So that Guacamole can use thes values, they must be placed the the - `guacamole.properties.vlt` file. +Example: + +``` +${VAULT:ssh/otp/myaccount/username} +``` + +Here, `{ROLE}` is `myaccount`. + +:::{note} +OTP authentication requires **client-side configuration** (e.g., PAM and the +`verify_otp.sh` script, as described earlier). +::: -`Key-Values` -: The Vault key-values secret engine is a generic secret engine to store arbitrary - secrets. This means that it is adapted to clients that are not managed by one of - the other three secret engines supported by Guacamole. For example, this engine - might be used for a password on an unmanaged account of a firewall. +###### Tokens for Signed Certificates - Both the path and secret values of key-value tokens are completely arbitrary and - a valid token might be of the form +Tokens for signed certificates take the form: ``` -${vault://{MOUNT_PATH}/{PATH}/{SECRET} +${VAULT:{MOUNT_PATH}/sign/{ROLE}/{public/private}} ``` - So a token like `vault://kv/org/example/fw1/adminuser` where `kv` is the mount path, - `org/example/fw1` the path the the record and `adminuser` the username of associated - with a generic account. +Example: +``` +${VAULT:ssh/sign/guacamole_cert/public} +``` + +This token returns a **signed SSH public certificate** that Guacamole can +use for authentication. + +:::{note} +The SSH secret engine **does not support** generating certificates directly. +Instead, it **signs** certificates using a CA stored in Vault. The creation +of the temporary certificates is performed by Guacamole itself. +::: + +##### Database Secret Engine + +The Vault database secret engine generates **dynamic database credentials** for +Guacamole. Tokens for database credentials take the form: + +``` +${VAULT:{MOUNT_PATH}/creds/{ROLE}/{username|password}} +``` + +Example: + +``` +${VAULT:db/creds/guacamole/username} +``` + +Here, `{ROLE}` is the name of the database role (e.g., `guacamole`). So that +Guacamole can use thes values, they must be placed the the +`guacamole.properties.vlt` file. + +:::{note} +- The credentials are **rotated automatically** by Vault based on the + `default_ttl` and `max_ttl` settings of the role. +::: + +##### Key-Value (KV) Secret Engine -In addition, the following sub-tokens can be used with the vault token to modify -its value when it is referenced. These sub-tokens are of the form `{SUB_TOKEN}` -within the vault token itself. If `{SUB_TOKEN}` is valid literal with the vault -token, you can use `$${SUB_TOKEN}` to ensure it is interpreted as `{SUB_TOKEN}`. -The allowed sub-token values are: +The KV secret engine stores **static secrets** (e.g., passwords, certificates). +Tokens for KV secrets take the form: -`{USER}` +``` +${VAULT:{MOUNT_PATH}/{PATH}/{SECRET}} +``` + +Example: + +``` +${VAULT:kv2/guacamole/batman/password} +``` + +Here: + +- `{MOUNT_PATH}` is the mount path of the KV engine (e.g., `kv2`). +- `{PATH}` is the path to the secret record (e.g., `guacamole/batman`). +- `{SECRET}` is the key within the secret record (e.g., `password`). + +:::{note} +For **KV v2**, Guacamole automatically handles the `/data/` prefix in the API +path. You **do not** need to include `/data/` in your token paths. +::: + +##### sub-token replacement + +In addition, the following sub-tokens can be used with the **parameter token** +to modify its value when it is referenced. These sub-tokens are of the form +`{SUB_TOKEN}` within the **parameter token** itself. If `{SUB_TOKEN}` is valid +literal with the **parameter token**, you can use `$${SUB_TOKEN}` to ensure it +is interpreted as `{SUB_TOKEN}`. The allowed sub-token values are: + +`{GUAC_USERNAME}` : The value of the token ${GUAC_USERNAME}, the current logged in user, is substituted into the vault token before it is looked up. -`{SERVER}` +`{USERNAME}` +: The username field of the connection is substituted into the vault token + before it is looked up. The username of the connection must not be empty + or itself include a token. + +`{HOSTNAME}` : The hostname field of the connection is substituted into the vault token before it is looked up. The hostname of the connection must not be empty or itself include a token. `{GATEWAY}` -: Identical to `SERVER`, except that the value of the `gateway-hostname` +: Identical to `HOSTNAME`, except that the value of the `gateway-hostname` parameter is used. This is only applicable to RDP connections. The `gateway-hostname` of the connection must not be empty or itself include a token. `{GATEWAY_USER}` -: Identical to `USER`, except that the value of the `gateway-username` +: Identical to `USERNAME`, except that the value of the `gateway-username` parameter is used. This is only applicable to RDP connections. The `gateway-username` of the connection must not be empty or itself include a token. -For example `vault://ldap/org/example/{SERVER}/{USER}/password` is a valid vault token. - -(vault-static-secrets)= +For example {samp}`${vault://ldap/org/example/{SERVER}/{USER}/password` is a +valid **paramater token**. -### Manual definition of secrets +### Manual Mapping of Secrets -Parameter tokens can be manually defined by placing a YAML file within -`GUACAMOLE_HOME` called `vault-token-mapping.yml`. This file must contain a set -of name/value pairs where each name is the name of a token to define and each -value is a token in the same form as the above. +For cases where **automatic injection** is not suitable (e.g., load-balancing +connection groups), you can **manually map** secrets to connections using a +`vault-token-mapping.yml` file. -For example, the following `vault-token-mapping.yml` defines two parameter -tokens, `${WINDOWS_ADMIN_PASSWORD}` and `${LINUX_SERVER_KEY}`, each pulling -their values from different parts of different records in KSM: +#### Example `vault-token-mapping.yml` ```yaml WINDOWS_ADMIN_PASSWORD: vault://ldap/static/org/host/account/password LINUX_SERVER_KEY: vault://kv/org/host/server_key.pem ``` -Token substitution of other parameter tokens like `${GUAC_USERNAME}` is -performed *on the reference to the secret* to allow the reference to vary by -values that may be relevant to the connection. The values of substituted tokens -are URL-encoded before being placed into the reference. In addition, the -sub-tokens as above are allowed within the secret reference. - (guacamole-properties-vlt)= Retrieving configuration properties from a vault @@ -969,8 +1356,8 @@ Retrieving configuration properties from a vault Secrets for Guacamole configuration properties are provided through [an additional file within `GUACAMOLE_HOME` called `guacamole.properties.vlt`](guacamole-properties-vlt). -This file is _identical_ to `guacamole.properties` except that the values of properties -are references to Vault secrets in the format as above. +This file is _identical_ to `guacamole.properties` except that the values of +properties are references to **parameter tokens** in the format as above. Secrets can be used for any Guacamole configuration property that isn't required to configure the Vault support. @@ -986,3 +1373,4 @@ postgresql-password: vault://db/guacamole_db/password The secret engine in this case is mounted on the path `db/` and this secret engine defines a role `guacamole`. + diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.sh b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.sh index e5e8a165cf..8c3146dad4 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.sh +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.sh @@ -1,12 +1,16 @@ #! /bin/sh # # Test script to start openbao server for testing and print a test plan +# This is my teeest scrippt , don't excpect it to work on your mmachine pwgen() { _alpha=${2:-'_A-Z-a-z0-9'} tr -dc "$_alpha" < /dev/random 2> /dev/null | head -c "$1" } +[ -z "$LDAP_URI" ] && { echo "Must supply a LDAP_URI with the address of the LDAP server" && exit 1; } +[ -z "$LDAP_DN" ] && { echo "Must supply a LDAP_DN with the distinguished name to use with teh vault" && exit 1; } + docker rm -f openbao > /dev/null 2>&1 docker run --detach --name openbao --publish 8200:8200 openbao/openbao:2.5 > /dev/null 2>&1 sleep 2 @@ -65,6 +69,103 @@ curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: applicati --request POST --data '{"type": "ldap"}' \ http://127.0.0.1:8200/v1/sys/mounts/ldap > /dev/null 2>&1 +echo "# Create ldap password policy" +cat << EOHCL > ldap-policy.hcl +length = 20 + +rule "charset" { + charset = "abcdefghijklmnopqrstuvwxyz" + min_chars = 1 +} + +rule "charset" { + charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + min_chars = 1 +} + +rule "charset" { + charset = "0123456789" + min_chars = 1 +} + +rule "charset" { + charset = "!@#%^&*" + min_chars = 1 +} +EOHCL +curl -s --header "X-Vault-Token: $VAULT_TOKEN" \ + --request POST --data-urlencode "policy@ldap-policy.hcl" \ + http://127.0.0.1:8200/v1/sys/policies/password/ldap-policy +rm ldap-policy.hcl + +echo "# Create LDAP account in the vault" +USER_DN=$(echo $LDAP_DN | cut -d, -f2-) +curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + --request PUT \ + --data '{ + "schema": "openldap", + "url": "'$LDAP_URI'", + "binddn": "'$LDAP_DN'", + "userdn": "'$USER_DN'", + "userattr": "cn", + "bindpass": "your_secret_password_here", + "password_policy": "ldap-policy", + "password_hash": "plaintext", + "insecure_tls": true + }' \ + http://127.0.0.1:8200/v1/ldap/config + +echo "# Rotate the vault LDAP password" +curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --request POST \ + http://127.0.0.1:8200/v1/ldap/rotate-root + +echo "# Add a static LDAP account" +curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + --request POST \ + --data '{ + "username": "testuser", + "dn": "uid=testuser,'$USER_DN'", + "rotation_period": "1h" + }' \ + http://127.0.0.1:8200/v1/ldap/static-role/testuser + +echo "# Add Dynamic LDAP role" +curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + --request POST \ + --data '{ + "creation_ldif": "dn:uid={{.Username}},'$USER_DN'\nobjectClass: top\nobjectClass: inetOrgPerson\nobjectClass: posixAccount\nobjectClass: shadowAccount\nobjectClass: enabledObjectClass\nuid: {{.Username}}\ncn: {{.Username}}\nsn: {{.Username}}\nuserPassword: {{.Password}}\ngidNumber: 50\nuidNumber: 2000\nhomeDirectory: /home/{{.Username}}\nloginShell: /bin/sh\nshadowLastChange: 99999\nshadowMax: 180\nshadowWarning: 7\nenabled: TRUE", + "deletion_ldif": "dn: uid={{.Username}},'$USER_DN'\nchangetype: delete", + "username_template": "v_{{.RoleName}}_{{random 6}}", + "dn": "uid=guacamole,'$USER_DN'", + "default_ttl": "1h", + "max_ttl": "24h" + }' \ + http://127.0.0.1:8200/v1/ldap/role/guacamole + + +echo "# Add LDAP service accounts" +curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + --request POST \ + --data '{ + "userdn": "'$USER_DN'", + "userattr": "cn", + "ttl": "1h", + "max_ttl": "24h", + "disable_checkin_enforcement": "true", + "service_account_names": "testuser" + }' \ + http://127.0.0.1:8200/v1/ldap/library/guacamole + echo "# Enable Postgres database secret engine" curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ --request POST --data '{"type": "database"}' \ @@ -90,7 +191,7 @@ curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: applicati --request POST --data '{"key_type": "otp", "default_user": "testuser", "cidr_list": "0.0.0.0/0"}' \ http://127.0.0.1:8200/v1/ssh/roles/guacamole_otp curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ - --request POST --data '{"roles": "guacamole"}' \ + --request POST --data '{"roles": "guacamole_otp"}' \ http://127.0.0.1:8200/v1/ssh/config/zeroaddress echo "# Enable userpass authentication and create guacamole user in vault" @@ -130,7 +231,7 @@ cat << EOF # Test that connection kali server actually works # 3. Test Guacamole with a token with limited rights and an infinite TTL -# First generate the token with the short TTL and limited right +# First generate the token with the zéro TTL and limited right curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ --request POST --data '{"policies": ["guacamole"], ttl: "0m", "renewable": true}' \ @@ -139,7 +240,7 @@ curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: applicati # Add the printed token to the 'vault-token' in gaucamole.properties and restart # Guacamole. # -# Test that the previous test still works. This token will be used from on out +# Test that the previous test still works. This token will be used from here on out # so that the limited permissions are tested # 4. Add the tokens to my test kali server with SSH # \${vault://kv1/users/kali/password} and \${vault://kv1/users/kali/username} @@ -157,12 +258,12 @@ EOT chmod 700 /etc/ssh/trusted_user_ca.pem chown sshd:sshd /etc/ssh/trusted_user_ca.pem echo "TrustedUserCAKeys /etc/ssh/trusted_user_ca.pem" >> /etc/ssh/sshd_config -pkill -SIGHUP sshd +pkill -SIGHUP /usr/sbin/sshd -# Use the username "kali" in SSH connection and add theses tokens to +# Use the username "kali" in SSH connection and add these tokens to # the private and public ssh keys # -# \${vault://ssh/cert/guacamole_sign/private} and \${vault://ssh/cert/guacamole_sign/public} +# \${vault://ssh/sign/guacamole_sign/private} and \${vault://ssh/sign/guacamole_sign/public} # # Test that the SSH connection to the kali machine works # 7. Use RSA SSH certiciates. The previous tested used the default "ed25519" ssh @@ -174,10 +275,11 @@ pkill -SIGHUP sshd # Run the following command on the test kali server sed -i -e "/^TrustedUserCAKeys/d" /etc/ssh/sshd_config +pkill -SIGHUP /usr/sbin/sshd -# 8. Ensure the vault otp helper is on the test kali machine and configure +# 8. Ensure the vault otp helper is on the test kali machine and configured # Add the following tokens to the test kali connection -# \${vault://ssh/otp/guacamole_otp/username} and \${vault://ssh/otp/guacamole_otp/password} +# \${vault://ssh/creds/guacamole_otp/username} and \${vault://ssh/creds/guacamole_otp/password} # Use the username 'testuser' on the SSH connection, so and this user to the test machine # if needed. Now setup the test machine with the code @@ -211,20 +313,20 @@ pkill -SIGHUP sshd rm /usr/local/sbin/verify_otp.sh sed -i '1d' /etc/pam.d/sshd -# 9. [TODO LDAP Static] Add the static ldap tokens to the SSH connection -# \${vault://ldap/static/users/kali/password} and \${vault://ldap/static/users/kali/username} +# 9. Add the static ldap tokens to the SSH connection +# \${vault://ldap/static-cred/testuser/password} and \${vault://ldap/static-cred/testuser/username} # Test that the SSH connection to the kali machine works -# 10. [TODO LDAP Dynamic] Add the dynamic ldaptokens to the SSH connection -# \${vault://ldap/dynamic/users/kali/password} and \${vault://ldap/dynamic/users/kali/username} +# 10. Add the dynamic ldaptokens to the SSH connection +# \${vault://ldap/creds/guacamole/password} and \${vault://ldap/dynamic/guacamole/username} # Test that the SSH connection to the kali machine works # 11. [TODO LDAP Service] Add the ldap service tokens to the SSH connection # \${vault://ldap/static/users/kali/password} and \${vault://ldap/static/users/kali/username} # Test that the SSH connection to the kali machine works # 12. A VaultAgent can be simulated by using a token sink file as follows. First create -# A short lived (10 minutes token) with the command +# A short lived (10 minutes) non renewable token with the command curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ - --request POST --data '{"policies": ["guacamole"], ttl: "10m", "renewable": true}' \ + --request POST --data '{"policies": ["guacamole"], ttl: "10m", "renewable": false}' \ http://127.0.0.1:8200/v1/auth/token/create | jq -r .auth.client_token > /etc/guacamole.token chmod 700 /etc/guacamole.token chown guacamole:guacamole /etc/guacamole.token @@ -248,18 +350,18 @@ curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: applicati # # Wait 10 minutes, so as to test the static token renewal of the openbao driver # and retest that one of the connections above works -# 14. [TODO Database password test] Create a `guacamole.properties.vlt` file with +# 14. [TODO Database password test] Create a "guacamole.properties.vlt" file with # entries for the database like # mysql-username: vault://db/guacamole/username # mysql-password: vault://db/guacamole/password # or adapt for your database engine. Restart guacamole. If it functions at all the # database is accessible -# 15. Create a file `vault-token-mapping.yml` with the tokens +# 15. Create a file "vault-token-mapping.yml" with the tokens # KALI_USERNAME: vault://kv1/users/kali/password # KALI_PASSWORD: vault://kv1/users/kali/username -# Add to the kali ssh connection the tokens ${KALI_USERNAME} and ${KALI_PASSWORD} +# Add to the kali ssh connection the tokens \${KALI_USERNAME} and \${KALI_PASSWORD} # and see if the connection still works -# 16. In the kali machine leave the user as `kali` but the password should use the -# token ${vault://kv1/users/{USER}/password}. Test that the connection works +# 16. In the kali machine leave the user as "kali" but the password should use the +# token \${vault://kv1/users/{USERNAME}/password}. Test that the connection works EOF diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java index 747fe9d677..24a315fd5f 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java @@ -31,12 +31,18 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.time.Duration; +import java.time.Instant; import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.UUID; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.GuacamoleServerException; import org.apache.guacamole.net.auth.UserContext; +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.net.GuacamoleTunnel; import org.apache.guacamole.protocol.GuacamoleConfiguration; import org.apache.guacamole.vault.openbao.conf.OpenBaoConfigurationService; import org.apache.guacamole.vault.openbao.vault.FileTokenAuthentication; @@ -58,7 +64,7 @@ import org.slf4j.LoggerFactory; @Singleton -public class OpenBaoClient { +public class OpenBaoClient implements Listener { /** * Logger for this class. */ @@ -101,8 +107,13 @@ public class OpenBaoClient { private ClientAuthentication authentication; /** - * Constructor allowing early injection f configuartion and initialization - * to start thetoken renewal process as early as possible + * A HashMap of the checked out LDAP Sessions + */ + private final Map ldapSessions = new HashMap<>(); + + /** + * Constructor allowing early injection of configuration and initialization + * to start the token renewal process as early as possible */ public OpenBaoClient(OpenBaoConfigurationService configService) { this.configService = configService; @@ -241,7 +252,7 @@ public Map listMountPaths() { Map mountInfo = (Map) mountInfoObj; Object type = mountInfo.get("type"); if (type instanceof String) { - if (type.equals("ssh") || type.equals("database")) { + if (type.equals("ssh") || type.equals("database") || type.equals("ldap")) { mountPaths.put(mountPath, type); } else if (type.equals("kv")) { @@ -272,6 +283,70 @@ else if (type.equals("kv")) { return mountPaths; } + /** + * Contains information about the checked out LDAP sessions + */ + private static class LDAPSessionInfo { + final String checkInPath; + final String username; + final Instant created; + + LDAPSessionInfo(String checkInPath, String username, Instant created) { + this.checkInPath = checkInPath; + this.username = username; + this.created = created; + } + } + + /** + * 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. Here + * we assume that the tunnel will connect soon after the call to getTokens + * and so we can tag the session information created in getTokens with + * tunnel ID. So we store the tunnel ID with the session data being created + * and then use it in a close event for the check-in + * + * FIXME : This is fragile, as if two connections in parallel are being + * created, the check-in event might be associate with the incorrect + * tunnel ID. Need a better solution + * + * @param event + * A tunnel connect event + */ + @Override + public void handleEvent(Object event) throws GuacamoleException { + + if (event instanceof TunnelConnectEvent) { + GuacamoleTunnel tunnel = ((TunnelConnectEvent) event).getTunnel(); + String uuid = tunnel.getUUID().toString(); + LDAPSessionInfo info = ldapSessions.get("creating"); + if (info != null) { + logger.info("Saving connection UUID: {}", uuid); + // Assume we have a service account + ldapSessions.put(uuid, info); + ldapSessions.remove("creating"); + } + } + else if (event instanceof TunnelCloseEvent) { + GuacamoleTunnel tunnel = ((TunnelCloseEvent) event).getTunnel(); + String uuid = tunnel.getUUID().toString(); + LDAPSessionInfo session = ldapSessions.get(uuid); + if (session != null) { + logger.info("Closing connection UUID: {}", uuid); + vaultTemplate.write(session.checkInPath, Map.of("service_account_names", session.username)); + ldapSessions.remove(uuid); + } + } + + // 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))); + } + /** * Retrieves a value from a vault by its path. It first parses the * leading mount path from the token, ensures it is valid and uses @@ -310,16 +385,22 @@ public String getValue(String token, UserContext userContext, GuacamoleConfigura // FIXME Could this be done with the TokenFilter in OpenBaoSecretService ? // 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 username = userContext == null ? "" : userContext.self().getIdentifier(); - if (username != null && !username.isEmpty() - && token.contains("{USER}") && ! token.contains("$${USER}")) { - token = token.replace("{USER}", username); + 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}", guac_username); } String hostname = config.getParameter("hostname"); if (hostname != null && !hostname.isEmpty() && !hostname.contains("${") - && token.contains("{SERVER}") && ! token.contains("$${SERVER}")) { - token = token.replace("{SERVER}", hostname); + && token.contains("{HOSTNAME}") && ! token.contains("$${HOSTNAME}")) { + token = token.replace("{HOSTNAME}", hostname); + + } + String username = config.getParameter("username"); + if (username != null && !username.isEmpty() && !username.contains("${") + && token.contains("{USERNAME}") && ! token.contains("$${USERNAME}")) { + token = token.replace("{USERNAME}", hostname); } String gatewayHostname = config.getParameter("gateway-hostname"); @@ -564,7 +645,7 @@ else if (path.startsWith("sign/")) { "ttl", configService.getSshConnectionTimeout()); VaultResponse vaultResponse = - vaultTemplate.write(mountPath + "/sign/" + path.substring(5), request); + vaultTemplate.write(mountPath + path, request); if (vaultResponse == null || vaultResponse.getData() == null) { throw new GuacamoleException("No response from Vault SSH engine"); @@ -611,14 +692,17 @@ else if (secret.equals("public")) { * If the secret cannot be retrieved from OpenBao. */ private String getValueLDAP(String mountPath, String path, String secret) throws GuacamoleException { - if (secret != "username" && secret != "password") { - throw new GuacamoleException("LDAP secret '" + secret + "' not found on the path : " + mountPath +"/" + path); + if (!secret.equals("username") && ! secret.equals("password")) { + throw new GuacamoleException("LDAP secret '" + secret + "' not found on the path : " + mountPath + path); } // Is the value already in the cache - Map cacheResponse = (Map) cache.getIfPresent(mountPath + "/" + path); + // FIXME Need a unique ConnectID while caching for dynamic at service roles as token same + // are the same for different credentials + Map cacheResponse = (Map) cache.getIfPresent(mountPath + path); if (cacheResponse != null) { + logger.info("Cached Response"); String retval; // The path is already cached?. Use it if (cacheResponse.get(secret) instanceof String) { @@ -635,20 +719,17 @@ private String getValueLDAP(String mountPath, String path, String secret) throws } if (retval == null || retval.isEmpty()) { - throw new GuacamoleException("SSH secret '" + secret + "' not found on the path : " + mountPath +"/" + path); + throw new GuacamoleException("LDAP secret '" + secret + "' not found on the path : " + mountPath + path); } return retval; } VaultResponse response; - if (path.startsWith("static/")) { - response = vaultTemplate.read(mountPath + "/static-creds/" + path.substring(7)); - } - else if (path.startsWith("dynamic/")) { - response = vaultTemplate.read(mountPath + "/creds/" + path.substring(8)); + if (path.startsWith("static") || path.startsWith("creds/")) { + response = vaultTemplate.read(mountPath + path); } - else if (path.startsWith("service/")) { - response = vaultTemplate.read(mountPath + "/" + path.substring(8) + "/check-out"); + else if (path.startsWith("library/")) { + response = vaultTemplate.write(mountPath + path + "/check-out", Map.of("ttl", "2h")); } else { throw new GuacamoleException("Unknown LDAP type on path: " + mountPath +"/" + path); @@ -660,7 +741,7 @@ else if (path.startsWith("service/")) { String username; String password = (String) response.getData().get("password"); - if (path.startsWith("service/")) { + if (path.startsWith("library/")) { username = (String) response.getData().get("service_account_name"); } else { @@ -668,12 +749,20 @@ else if (path.startsWith("service/")) { } if (username == null || password == null) { + // Particularly for service accounts this might happen if there is + // no account available for checkout throw new IllegalStateException("Invalid LDAP static credential response"); } + if (path.startsWith("library/")) { + // Register a listener to check-in the account in a TunnelCloseListener + LDAPSessionInfo info = new LDAPSessionInfo(mountPath + path + "/check-in", username, Instant.now()); + ldapSessions.put("creating", info); + } + // Cache the retrieved user and password - cache.put(mountPath + "/" + path, Map.of("username", username, "password", password)); - logger.debug("LDAP : " + username + " : " + password); + cache.put(mountPath + path, Map.of("username", username, "password", password)); + logger.info("LDAP : " + username + " : " + password); if (secret.equals("username")) { return username; @@ -700,12 +789,12 @@ else if (path.startsWith("service/")) { * If the secret cannot be retrieved from OpenBao. */ private String getValueDB(String mountPath, String path, String secret) throws GuacamoleException { - if (secret != "username" && secret != "password") { + if (!secret.equals("username") && !secret.equals("password")) { throw new GuacamoleException("Database secret '" + secret + "' not found on the path : " + mountPath +"/" + path); } // Is the key-value already in the cache - Map cacheResponse = (Map) cache.getIfPresent(mountPath + "/" + path); + Map cacheResponse = (Map) cache.getIfPresent(mountPath + path); if (cacheResponse != null) { String retval; @@ -743,7 +832,7 @@ private String getValueDB(String mountPath, String path, String secret) throws G } // Cache the retrieved user and password - cache.put(mountPath + "/" + path, Map.of("username", username, "password", password)); + cache.put(mountPath + path, Map.of("username", username, "password", password)); logger.debug("DB : " + username + " : " + password); if (secret.equals("username")) { diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java index e2f3f534ae..2bf5f2889d 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java @@ -118,7 +118,7 @@ public Future getValue(String token) throws GuacamoleException { @Override public Future getValue(UserContext userContext, Connectable connectable, String token) throws GuacamoleException { - String value = client().getValue(token, null, new GuacamoleConfiguration()); + String value = client().getValue(token, userContext, new GuacamoleConfiguration()); return CompletableFuture.completedFuture(value); } From 42f74faceefcaadc89647210090b14645b748a2c Mon Sep 17 00:00:00 2001 From: David Bateman Date: Wed, 13 May 2026 15:25:07 +0200 Subject: [PATCH 08/16] GUACAMOLE-2196: LDAP Service accounts also work after modification of the token permissions in my test script. --- .../guacamole-vault-openbao/docs/openbao.md.j2 | 16 +++++++++++++--- .../guacamole-vault-openbao/docs/openbao.sh | 11 ++++++----- .../vault/openbao/secret/OpenBaoClient.java | 2 +- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 index cf26a36772..34e3e94eac 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 @@ -100,8 +100,13 @@ path "ssh/creds/guacamole_otp" { capabilities = ["create"] } -# Read static LDAP credentials -path "ldap/static/guacamole/*" { +# Read/Create for LDAP service credentials +path "ldap/library/guacamole/*" { + capabilities = ["read", "create"] +} + +# Read static/dynamic LDAP credentials +path "ldap/*/guacamole/*" { capabilities = ["read"] } @@ -111,6 +116,12 @@ path "db/guacamole/*" { } ``` +:::{note} +The first matching rule in the policy is used. It is therefore important to +place the specific rules in the policy before the general more lax rules. The +LDAP rules above are an example of this. +::: + #### Uploading the Policy to Vault To upload the policy to Vault, use the following command: @@ -130,7 +141,6 @@ curl -sS \ - The mount paths in the policy (e.g., `kv/guacamole/*`) are **examples** and should be adapted to match your actual paths. ::: - ## Configuring Secret Engines for Guacamole Guacamole supports the following Vault secret engines: diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.sh b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.sh index 8c3146dad4..71b0f9b7eb 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.sh +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.sh @@ -33,6 +33,9 @@ path "ssh/sign/guacamole_cert" { path "ssh/creds/guacamole_otp" { capabilities = ["update"] } +path "ldap/library/*" { + capabilities = ["read", "create"] +} path "ldap/*" { capabilities = ["read"] } @@ -109,7 +112,7 @@ curl -s \ "url": "'$LDAP_URI'", "binddn": "'$LDAP_DN'", "userdn": "'$USER_DN'", - "userattr": "cn", + "userattr": "uid", "bindpass": "your_secret_password_here", "password_policy": "ldap-policy", "password_hash": "plaintext", @@ -157,8 +160,6 @@ curl -s \ --header "Content-Type: application/json" \ --request POST \ --data '{ - "userdn": "'$USER_DN'", - "userattr": "cn", "ttl": "1h", "max_ttl": "24h", "disable_checkin_enforcement": "true", @@ -319,8 +320,8 @@ sed -i '1d' /etc/pam.d/sshd # 10. Add the dynamic ldaptokens to the SSH connection # \${vault://ldap/creds/guacamole/password} and \${vault://ldap/dynamic/guacamole/username} # Test that the SSH connection to the kali machine works -# 11. [TODO LDAP Service] Add the ldap service tokens to the SSH connection -# \${vault://ldap/static/users/kali/password} and \${vault://ldap/static/users/kali/username} +# 11. Add the ldap service tokens to the SSH connection +# \${vault://ldap/library/guacamole/password} and \${vault://ldap/library/guacamole/username} # Test that the SSH connection to the kali machine works # 12. A VaultAgent can be simulated by using a token sink file as follows. First create # A short lived (10 minutes) non renewable token with the command diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java index 24a315fd5f..1f466a85dd 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java @@ -755,7 +755,7 @@ else if (path.startsWith("library/")) { } if (path.startsWith("library/")) { - // Register a listener to check-in the account in a TunnelCloseListener + // Register a listener to check-in the account for a TunnelClose Event LDAPSessionInfo info = new LDAPSessionInfo(mountPath + path + "/check-in", username, Instant.now()); ldapSessions.put("creating", info); } From fd6d788286d9c7ac197c6d61b62ec320da0b7b31 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Thu, 21 May 2026 11:07:19 +0200 Subject: [PATCH 09/16] GUACAMOLE-2196: Significantly simplify OpenBaoClient by caching at a single point, resolve sub-tokens before inclusion, update documentation and remove old README.md --- .../modules/guacamole-vault-openbao/README.md | 209 ------ .../docs/openbao.md.j2 | 116 +++- .../guacamole-vault-openbao/docs/openbao.sh | 69 +- .../OpenBaoAuthenticationProviderModule.java | 5 +- .../conf/OpenBaoConfigurationService.java | 32 +- .../vault/openbao/secret/OpenBaoClient.java | 595 ++++++------------ .../openbao/secret/OpenBaoClientProvider.java | 12 +- .../openbao/secret/OpenBaoSecretService.java | 169 ++++- .../vault/openbao/secret/OpenBaoSshKeys.java | 26 +- .../secret/OpenBaoTunnelEventListener.java | 72 +++ .../vault/FileTokenAuthentication.java | 17 +- .../openbao/vault/TtlAwareSessionManager.java | 21 +- .../vault/UsernamePasswordAuthentication.java | 13 +- ...UsernamePasswordAuthenticationOptions.java | 13 +- .../resource-templates/guac-manifest.json | 4 + 15 files changed, 647 insertions(+), 726 deletions(-) delete mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/README.md create mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoTunnelEventListener.java diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/README.md b/extensions/guacamole-vault/modules/guacamole-vault-openbao/README.md deleted file mode 100644 index ba6996d49a..0000000000 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/README.md +++ /dev/null @@ -1,209 +0,0 @@ -# OpenBao Vault Extension for Apache Guacamole - -This extension integrates Apache Guacamole with [OpenBao](https://openbao.org/) vault to automatically retrieve connection credentials from OpenBao at connection time. - -## Overview - -The OpenBao vault extension allows Guacamole to retrieve credentials from an OpenBao server using token-based authentication. Connection parameters configured with special tokens like `${OPENBAO_SECRET}` are automatically replaced with values retrieved from OpenBao when a user connects. - -## Features - -- **Automatic Credential Retrieval**: Fetches credentials from OpenBao without requiring users to re-enter passwords -- **Token-Based Resolution**: Uses `${OPENBAO_SECRET}` and `${GUAC_USERNAME}` tokens in connection parameters -- **KV v2 Support**: Works with OpenBao KV v2 secrets engine -- **Simple Configuration**: Minimal configuration required in `guacamole.properties` -- **Secure**: Uses OpenBao's token-based authentication for secure API access - -## How It Works - -1. User logs into Guacamole with their username -2. User initiates a connection configured with `${OPENBAO_SECRET}` token -3. Extension queries OpenBao API to retrieve the secret for that username -4. Password is extracted and injected into the connection parameters -5. Connection proceeds with the retrieved credentials - -## Configuration - -### OpenBao Server Setup - -Before using this extension, you need: - -1. An OpenBao server running and accessible from the Guacamole server -2. A KV v2 secrets engine mounted (eg path: `guacamole-credentails`) -3. An OpenBao authentication token with read access to the secrets -4. Secrets stored with a `password` field in the data - -Example secret structure: -```json -{ - "data": { - "data": { - "username": "user1", - "password": "SecretPassword123" - } - } -} -``` - -### Guacamole Configuration - -Add the following properties to `guacamole.properties`: - -```properties -# OpenBao server URL (required) -openbao-server-url: http://openbao.example.com:8200 - -# OpenBao authentication token (required) -openbao-token: s.YourTokenHere - -# KV mount path (optional, default: guacamole-credentails) -openbao-mount-path: guacamole-credentails -``` - -**Note**: The extension uses hardcoded defaults for: -- KV version: `2` (KV v2 secrets engine) -- Connection timeout: `5000ms` (5 seconds) -- Request timeout: `10000ms` (10 seconds) - -### Connection Configuration - -When creating connections in Guacamole, use these token patterns: - -- **`${OPENBAO_SECRET}`**: Replaced with the password from OpenBao -- **`${GUAC_USERNAME}`**: Replaced with the logged-in Guacamole username - -Example RDP connection: -- Username: `${GUAC_USERNAME}` -- Password: `${OPENBAO_SECRET}` -- Hostname: `192.168.1.100` - -## Secret Path Mapping - -The extension maps Guacamole usernames directly to OpenBao secret paths: - -``` -Guacamole username: "john" -OpenBao secret path: /v1/guacamole-credentails/data/john -``` - -For each user, create a corresponding secret in OpenBao at the path matching their Guacamole username. - -## Building - -Build the extension from the guacamole-client source tree: - -```bash -cd extensions/guacamole-vault -mvn clean package -``` - -The built extension will be located at: -``` -modules/guacamole-vault-openbao/target/guacamole-vault-openbao-.jar -``` - -## Installation - -1. Copy the built JAR to the Guacamole extensions directory: - ```bash - cp guacamole-vault-openbao-*.jar /etc/guacamole/extensions/ - ``` - -2. Ensure `guacamole-vault-base-*.jar` is also present in the extensions directory (it's a dependency) - -3. Configure `guacamole.properties` as described above - -4. Restart Guacamole (e.g., restart Tomcat) - -## Security Considerations - -1. **Protect the OpenBao Token**: Use a dedicated token with minimal permissions (read-only access to required secret paths) - -2. **Use TLS in Production**: Always use HTTPS URLs for OpenBao in production: - ```properties - openbao-server-url: https://openbao.example.com:8200 - ``` - -3. **Network Security**: Restrict OpenBao access to the Guacamole server using firewall rules - -4. **Audit Logging**: Enable OpenBao audit logging to track credential access - -5. **Token Rotation**: Regularly rotate OpenBao tokens and update the configuration - -## Troubleshooting - -### Extension Not Loading - -Check the Guacamole logs (typically in Tomcat's `catalina.out`) for errors. Common issues: - -- Missing `guacamole-vault-base` dependency -- Incorrect permissions on JAR files -- Configuration errors in `guacamole.properties` - -### Secret Not Found - -Error: `Secret not found in OpenBao for username: john` - -Solutions: -1. Verify the secret exists in OpenBao at the expected path -2. Check that the Guacamole username matches the secret name in OpenBao -3. Verify the token has read access to the secret - -### Permission Denied - -Error: `Permission denied accessing OpenBao. Check token permissions.` - -Solutions: -1. Verify the token has appropriate policies attached -2. Check that the token hasn't expired -3. Ensure the token has read access to the KV mount path - -### Connection Timeout - -Error: `Failed to communicate with OpenBao` - -Solutions: -1. Verify OpenBao is accessible from the Guacamole server -2. Check firewall rules between Guacamole and OpenBao -3. Verify the OpenBao URL is correct in the configuration - -## Example Deployment - -1. **Setup OpenBao**: - ```bash - # Start OpenBao - bao server -dev - - # Enable KV v2 engine - bao secrets enable -path=guacamole-credentails kv-v2 - - # Create a secret - bao kv put guacamole-credentails/john password=SecretPass123 - ``` - -2. **Configure Guacamole**: - ```properties - openbao-server-url: http://openbao.example.com:8200 - openbao-token: s.yourtokenhere - openbao-mount-path: guacamole-credentails - ``` - -3. **Create Connection**: - - Name: Windows Server - - Protocol: RDP - - Hostname: 192.168.1.100 - - Username: `${GUAC_USERNAME}` - - Password: `${OPENBAO_SECRET}` - -4. **Connect**: Log in as user "john" and connect to the Windows Server connection. The password will be automatically retrieved from OpenBao. - -## License - -This extension is licensed under the Apache License, Version 2.0. See the LICENSE file for details. - -## Support - -For issues or questions: -- Apache Guacamole: https://guacamole.apache.org/ -- OpenBao: https://openbao.org/ -- Issue Tracker: https://issues.apache.org/jira/browse/GUACAMOLE/ diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 index 34e3e94eac..2f6a570e7a 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 @@ -100,9 +100,9 @@ path "ssh/creds/guacamole_otp" { capabilities = ["create"] } -# Read/Create for LDAP service credentials +# Read/Update for LDAP service credentials path "ldap/library/guacamole/*" { - capabilities = ["read", "create"] + capabilities = ["read", "update"] } # Read static/dynamic LDAP credentials @@ -561,6 +561,8 @@ curl -s --header "X-Vault-Token: $VAULT_TOKEN" \ "url": "ldaps://ldap.example.com", "binddn": "uid=vault,ou=users,dc=example,dc=com", "bindpass": "your_secret_password_here", + "userdn": "ou=users,dc=example,dc=com", + "userattr": "uid", "password_policy": "ldap-policy", "insecure_tls": false }' \ @@ -791,7 +793,60 @@ The response will include: #### Creating a LDAP Service Accounts for Guacamole - #TODO +Before continuing you should confirm that the Vault LDAP configuration +includes valid values for `userdn` and `userattr`, with a command like: + +```bash +curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + http://127.0.0.1:8200/v1/ldap/config | \ + jq '.data.userdn, .data.userattr' +``` +This should produce something like + +``` +"ou=users,dc=example,dc=com" +"uid" +``` +This means that user records in your LDAP will be of the form +`uid=testuser,ou=users,dc=example,dc=com`. If this is not the case +the LDAP secret engine should be reconfigured before continuing + +Give the returned value of `userattr`, Vault will search with the account +`uid` values. To create a LDAP service account in the Vault we can then +do something like + +```bash +curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + --request POST \ + --data '{ + "ttl": "2h", + "max_ttl": "24h", + "disable_checkin_enforcement": "true", + "service_account_names": "user1,user2" + }' \ + http://127.0.0.1:8200/v1/ldap/library/guacamole +``` + +where `user1` and `user2` and valid existing user accounts managed by the +Vault. + +:::{note} +- Vault will change the password of these accounts on check-out and so these + account should be dedicated to Vault. +- Guacamole requests a TTL of 2 hours. If the role is configured with a shorter + TTL, the account will be assumed to be checked in after this shorter delay. +::: + +:::{warning} +At the moment Guacamole does not guarantee that the account will be checked in +when the connection is terminated, though it does attempt to do so. The pool of +service accounts available should take this into account and assume the check-in +will happen at the end of the 2 hours TTL. +::: ### Creating a Database Secret Engine for Guacamole @@ -805,12 +860,25 @@ database access (e.g., PostgreSQL, MySQL). - This role should have permissions to: - **Create new roles** (for dynamic credentials). - **Modify passwords** (for rotation). - - Example for PostgreSQL: + - **Revoke roles** (to remove dynamic credentials) + - **Grant permission on tables** (to dynamic credentials to alter the tables) + +This poses a problem, of ownership on the tables in the database. Using +Postgresql you could use + ```sql CREATE ROLE vault_admin WITH LOGIN PASSWORD 'secure_password_here' CREATEROLE NOINHERIT; GRANT CREATE ON DATABASE guacamole_db TO vault_admin; GRANT USAGE ON SCHEMA public TO vault_admin; - ``` +``` + +if and only if vault_admin is owner of the tables in the database. Otherwise the +role needs to be a `superuser` and created like + +```sql +CREATE ROLE vault_admin WITH LOGIN PASSWORD 'secure_password_here' SUPERUSER NOINHERIT; +``` + 2. **Apply the SQL Commands**: Save the above SQL to a file (e.g., `create_vault_role.sql`) and run it: @@ -844,14 +912,19 @@ curl -s \ "db_name": "guacamole_db", "default_ttl": "1h", "max_ttl": "24h", - "create_statements": [ - "CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD \"{{password}}\" VALID UNTIL \"{{expiration}}\";", + "creation_statements": [ + "CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '\''{{password}}'\'' VALID UNTIL '\''{{expiration}}'\'';", "GRANT SELECT,INSERT,UPDATE,DELETE ON ALL TABLES IN SCHEMA public TO \"{{name}}\";", "GRANT SELECT,USAGE ON ALL SEQUENCES IN SCHEMA public TO \"{{name}}\";", "GRANT ALL ON SCHEMA public TO \"{{name}}\";" ], - "rotation_statements": [ - "ALTER ROLE \"{{name}}\" WITH PASSWORD \"{{password}}\";" + "renew_statements": [ + "ALTER ROLE \"{{name}}\" WITH PASSWORD '\''{{password}}'\'' VALID UNTIL '\''{{expiration}}'\'';" + ], + "revocation_statements": [ + "REASSIGN OWNED BY \"{{name}}\" TO CURRENT_USER;", + "DROP OWNED BY \"{{name}}\";", + "DROP ROLE IF EXISTS \"{{name}}\";" ] }' \ http://127.0.0.1:8200/v1/db/roles/guacamole @@ -882,14 +955,14 @@ curl -s \ "db_name": "guacamole_db", "default_ttl": "1h", "max_ttl": "24h", - "create_statements": [ - "CREATE ROLE \"guacamole_user\" WITH LOGIN PASSWORD \"{{password}}\" VALID UNTIL \"{{expiration}}\";", + "creation_statements": [ + "CREATE ROLE \"guacamole_user\" WITH LOGIN PASSWORD '\''{{password}}'\'' VALID UNTIL '\''{{expiration}}'\'';", "GRANT SELECT,INSERT,UPDATE,DELETE ON ALL TABLES IN SCHEMA public TO \"guacamole_user\";", "GRANT SELECT,USAGE ON ALL SEQUENCES IN SCHEMA public TO \"guacamole_user\";", "GRANT ALL ON SCHEMA public TO \"guacamole_user\";" ], - "rotation_statements": [ - "ALTER ROLE \"guacamole_user\" WITH PASSWORD \"{{password}}\";" + "renew_statements": [ + "ALTER ROLE \"guacamole_user\" WITH PASSWORD '\''{{password}}'\'';" ], "static_username": true }' \ @@ -907,17 +980,18 @@ curl -s \ --request POST \ --data '{ "plugin_name": "postgresql-database-plugin", - "connection_url": "postgresql://{{.Username}}:{{.Password}}@127.0.0.1:5432/guacamole_db", + "connection_url": "postgresql://{{username}}:{{password}}@127.0.0.1:5432/guacamole_db", "allowed_roles": "guacamole", "username": "vault_admin", - "password": "secure_password_here" + "password": "secure_password_here", + "password_authentication": "scram-sha-256" }' \ http://127.0.0.1:8200/v1/db/config/guacamole_db ``` :::{note} - `connection_url`: The URL template for connecting to the database. - `{{.Username}}` and `{{.Password}}` are placeholders that Vault replaces + `{{username}}` and `{{password}}` are placeholders that Vault replaces with dynamic credentials. - `allowed_roles`: Comma-separated list of roles that can use this connection. - `username` and `password`: Credentials for the Vault database account (e.g. @@ -1257,6 +1331,10 @@ use for authentication. The SSH secret engine **does not support** generating certificates directly. Instead, it **signs** certificates using a CA stored in Vault. The creation of the temporary certificates is performed by Guacamole itself. + +The username of the connection is a necessary field for the generated +certificate. It can be an explicit value or another token, that will be +resolved before inclusion in the ssh certificate. ::: ##### Database Secret Engine @@ -1275,7 +1353,7 @@ ${VAULT:db/creds/guacamole/username} ``` Here, `{ROLE}` is the name of the database role (e.g., `guacamole`). So that -Guacamole can use thes values, they must be placed the the +Guacamole can use these values, they must be placed in the `guacamole.properties.vlt` file. :::{note} @@ -1343,8 +1421,8 @@ is interpreted as `{SUB_TOKEN}`. The allowed sub-token values are: `gateway-username` of the connection must not be empty or itself include a token. -For example {samp}`${vault://ldap/org/example/{SERVER}/{USER}/password` is a -valid **paramater token**. +For example {samp}`${vault://ldap/org/example/{SERVER}/{USERNAME}/password` is +a valid **paramater token**. ### Manual Mapping of Secrets diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.sh b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.sh index 71b0f9b7eb..f5ef44d06e 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.sh +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.sh @@ -9,7 +9,7 @@ pwgen() { } [ -z "$LDAP_URI" ] && { echo "Must supply a LDAP_URI with the address of the LDAP server" && exit 1; } -[ -z "$LDAP_DN" ] && { echo "Must supply a LDAP_DN with the distinguished name to use with teh vault" && exit 1; } +[ -z "$LDAP_DN" ] && { echo "Must supply a LDAP_DN with the distinguished name to use with the vault" && exit 1; } docker rm -f openbao > /dev/null 2>&1 docker run --detach --name openbao --publish 8200:8200 openbao/openbao:2.5 > /dev/null 2>&1 @@ -34,7 +34,7 @@ path "ssh/creds/guacamole_otp" { capabilities = ["update"] } path "ldap/library/*" { - capabilities = ["read", "create"] + capabilities = ["read", "update"] } path "ldap/*" { capabilities = ["read"] @@ -153,7 +153,6 @@ curl -s \ }' \ http://127.0.0.1:8200/v1/ldap/role/guacamole - echo "# Add LDAP service accounts" curl -s \ --header "X-Vault-Token: $VAULT_TOKEN" \ @@ -163,7 +162,7 @@ curl -s \ "ttl": "1h", "max_ttl": "24h", "disable_checkin_enforcement": "true", - "service_account_names": "testuser" + "service_account_names": "testuser2" }' \ http://127.0.0.1:8200/v1/ldap/library/guacamole @@ -172,6 +171,47 @@ curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: applicati --request POST --data '{"type": "database"}' \ http://127.0.0.1:8200/v1/sys/mounts/db > /dev/null 2>&1 +echo "# Create Dynamic Role in database secret engine" +curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + --request POST \ + --data '{ + "db_name": "guacamole_db", + "default_ttl": "1h", + "max_ttl": "24h", + "creation_statements": [ + "CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '\''{{password}}'\'' VALID UNTIL '\''{{expiration}}'\'';", + "GRANT SELECT,INSERT,UPDATE,DELETE ON ALL TABLES IN SCHEMA public TO \"{{name}}\";", + "GRANT SELECT,USAGE ON ALL SEQUENCES IN SCHEMA public TO \"{{name}}\";", + "GRANT ALL ON SCHEMA public TO \"{{name}}\";" + ], + "renew_statements": [ + "ALTER ROLE \"{{name}}\" WITH PASSWORD '\''{{password}}'\'' VALID UNTIL '\''{{expiration}}'\'';" + ], + "revocation_statements": [ + "REASSIGN OWNED BY \"{{name}}\" TO CURRENT_USER;", + "DROP OWNED BY \"{{name}}\";", + "DROP ROLE IF EXISTS \"{{name}}\";" + ] + }' \ + http://127.0.0.1:8200/v1/db/roles/guacamole + +echo "# Add database connection information" +curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + --request POST \ + --data '{ + "plugin_name": "postgresql-database-plugin", + "connection_url": "postgresql://{{username}}:{{password}}@10.0.2.15:5432/guacamole_db", + "allowed_roles": "guacamole", + "username": "vault_admin", + "password": "secure_password_here", + "password_authentication": "scram-sha-256" + }' \ + http://127.0.0.1:8200/v1/db/config/guacamole_db + echo "# Enable SSH secret engine" curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ --request POST --data '{"type": "ssh"}' \ @@ -305,8 +345,8 @@ RESPONSE=\$(curl -s \ [ "\$(echo \$RESPONSE | jq -r .data.username)" = "\$USERNAME" ] || exit 4 EOT -chmod 755 /usr/local/bin/vault_verify_otp.sh -sed -i '1s:^:auth sufficent pam_exec.so expose_authtok /usr/local/sbin/verify_otp.sh:' /etc/pam.d/sshd +chmod 755 /usr/local/sbin/verify_otp.sh +sed -i '1s:^:auth sufficient pam_exec.so expose_authtok /usr/local/sbin/verify_otp.sh:' /etc/pam.d/sshd pkill -SIGHUP sshd # Test that the connexion works. After remove the test code from the test machine like @@ -318,7 +358,7 @@ sed -i '1d' /etc/pam.d/sshd # \${vault://ldap/static-cred/testuser/password} and \${vault://ldap/static-cred/testuser/username} # Test that the SSH connection to the kali machine works # 10. Add the dynamic ldaptokens to the SSH connection -# \${vault://ldap/creds/guacamole/password} and \${vault://ldap/dynamic/guacamole/username} +# \${vault://ldap/creds/guacamole/password} and \${vault://ldap/creds/guacamole/username} # Test that the SSH connection to the kali machine works # 11. Add the ldap service tokens to the SSH connection # \${vault://ldap/library/guacamole/password} and \${vault://ldap/library/guacamole/username} @@ -351,17 +391,20 @@ curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: applicati # # Wait 10 minutes, so as to test the static token renewal of the openbao driver # and retest that one of the connections above works -# 14. [TODO Database password test] Create a "guacamole.properties.vlt" file with -# entries for the database like +# 14. Create a "guacamole.properties.vlt" file with +# entries for the database like # mysql-username: vault://db/guacamole/username # mysql-password: vault://db/guacamole/password -# or adapt for your database engine. Restart guacamole. If it functions at all the -# database is accessible +# or adapt for your database engine. Restart guacamole. If it functions at all the +# database is accessible. +# +# Wait for the duration of the credential lease time (1h or 10 minutes ?) and see +# if Guacamole still functions proving credentials were renewed or reauthenticated. # 15. Create a file "vault-token-mapping.yml" with the tokens # KALI_USERNAME: vault://kv1/users/kali/password # KALI_PASSWORD: vault://kv1/users/kali/username -# Add to the kali ssh connection the tokens \${KALI_USERNAME} and \${KALI_PASSWORD} -# and see if the connection still works +# Add to the kali ssh connection the tokens \${KALI_USERNAME} and \${KALI_PASSWORD} +# and see if the connection still works # 16. In the kali machine leave the user as "kali" but the password should use the # token \${vault://kv1/users/{USERNAME}/password}. Test that the connection works diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProviderModule.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProviderModule.java index 81d50808d1..239d093429 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProviderModule.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProviderModule.java @@ -29,13 +29,13 @@ import org.apache.guacamole.vault.openbao.secret.OpenBaoClient; import org.apache.guacamole.vault.openbao.secret.OpenBaoClientProvider; import org.apache.guacamole.vault.openbao.secret.OpenBaoSecretService; +import org.apache.guacamole.vault.openbao.secret.OpenBaoTunnelEventListener; import org.apache.guacamole.vault.openbao.user.OpenBaoAttributeService; import org.apache.guacamole.vault.openbao.user.OpenBaoDirectoryService; import org.apache.guacamole.vault.secret.VaultSecretService; import org.apache.guacamole.vault.conf.VaultAttributeService; import org.apache.guacamole.vault.user.VaultDirectoryService; - /** * Guice module for configuring OpenBao vault integration. * Binds the OpenBao-specific implementations to the vault base interfaces. @@ -70,5 +70,8 @@ protected void configureVault() { // Bind client bind(OpenBaoClient.class).toProvider(OpenBaoClientProvider.class).asEagerSingleton(); + + // Static Injection of the Listener to get an OpenBaoClient instance in the listener + requestStaticInjection(OpenBaoTunnelEventListener.class); } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfigurationService.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfigurationService.java index 02d86e6edb..87d35f593b 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfigurationService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfigurationService.java @@ -63,7 +63,6 @@ public class OpenBaoConfigurationService extends VaultConfigurationService { /** * The default ssh certificate type. - * FIXME : ed25519 not supportted before java 15. Waiting for upgrade */ public static final String DEFAULT_SSH_TYPE = "ed25519"; @@ -186,10 +185,6 @@ public class OpenBaoConfigurationService extends VaultConfigurationService { @Inject private Environment environment; - /** - * Creates a new OpenBaoConfigurationService. - */ - /** * Creates a new OpenBaoConfigurationService which reads the configuration * from "vault-token-mapping.yml" and properties from @@ -199,7 +194,6 @@ public class OpenBaoConfigurationService extends VaultConfigurationService { * alternative to guacamole.properties where each property value is the * name of a secret containing the actual value. */ - public OpenBaoConfigurationService() { super(TOKEN_MAPPING_FILENAME, PROPERTIES_FILENAME); } @@ -207,7 +201,7 @@ public OpenBaoConfigurationService() { /** * The URI of the hashicorp or OpenBao vault to use. * - * @return URI + * @return * The Hashicorp or OpenBao server URI (e.g., "http://localhost:8200"). * * @throws GuacamoleException @@ -221,7 +215,7 @@ public URI getVaultUri() throws GuacamoleException { /** * The authentication token to use to access the vault. * - * @return String + * @return * The Hashicorp or OpenBao authentication token. * * @throws GuacamoleException @@ -235,7 +229,7 @@ public String getVaultToken() throws GuacamoleException { /** * The authentication Username to use to access the vault. * - * @return String + * @return * The Hashicorp or OpenBao authentication Username * * @throws GuacamoleException @@ -263,7 +257,7 @@ public String getVaultPassword() throws GuacamoleException { * The maximum time that the cached data is considered valid in * milliseconds. * - * @return int + * @return * The cache lifetime in milliseconds. * * @throws GuacamoleException @@ -277,7 +271,7 @@ public int getVaultCacheLifetime() throws GuacamoleException { * The maximum time that a request to the vault server can take in * milliseconds. * - * @return int + * @return * The request timeout in milliseconds. * * @throws GuacamoleException @@ -291,7 +285,7 @@ public int getRequestTimeout() throws GuacamoleException { * The maximum time that a connection to the vault server can take in * milliseconds. * - * @return int + * @return * The connection timeout in milliseconds. * * @throws GuacamoleException @@ -305,8 +299,8 @@ public int getConnectionTimeout() throws GuacamoleException { * The renewal delay, in milliseconds of expiring token. A token will be renewed * prior to it expiration by this delay * - * @return int - * The renewl delay in milliseconds. + * @return + * The renewal delay in milliseconds. * * @throws GuacamoleException * If guacamole.properties can not be parsed. @@ -317,9 +311,9 @@ public int getTokenRenewalDelay() throws GuacamoleException { /** * The type of SSH certificates are will be generated. Must be either - * 'rsa' of 4096-bit RSA keys or 'ed25519'. + * 'rsa' for 4096-bit RSA keys or 'ed25519'. * - * @return String + * @return * The ssh type to use. * * @throws GuacamoleException @@ -328,7 +322,7 @@ public int getTokenRenewalDelay() throws GuacamoleException { 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 certicate types 'rsa' (4096-bit) and 'ed25519' are supported"); + throw new GuacamoleException("Only ssh certificate types 'rsa' (4096-bit) and 'ed25519' are supported"); } return type; } @@ -337,8 +331,8 @@ public String getSshType() throws GuacamoleException { * The maximum time that a signed SSH certificate is considered valid in * milliseconds. * - * @return int - * The ssh conenction timeout in milliseconds. + * @return + * The ssh connection timeout in milliseconds. * * @throws GuacamoleException * If guacamole.properties can not be parsed. diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java index 1f466a85dd..805bc3597b 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java @@ -38,12 +38,9 @@ import java.util.UUID; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.GuacamoleServerException; -import org.apache.guacamole.net.auth.UserContext; -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.net.GuacamoleTunnel; -import org.apache.guacamole.protocol.GuacamoleConfiguration; import org.apache.guacamole.vault.openbao.conf.OpenBaoConfigurationService; import org.apache.guacamole.vault.openbao.vault.FileTokenAuthentication; import org.apache.guacamole.vault.openbao.vault.UsernamePasswordAuthentication; @@ -64,7 +61,7 @@ import org.slf4j.LoggerFactory; @Singleton -public class OpenBaoClient implements Listener { +public class OpenBaoClient { /** * Logger for this class. */ @@ -110,31 +107,51 @@ public class OpenBaoClient implements Listener { * A HashMap of the checked out LDAP Sessions */ private final Map ldapSessions = new HashMap<>(); + + /** + * A task scheduler for remove terminated checked out LDAP Sessions + */ + ThreadPoolTaskScheduler scheduler; /** * Constructor allowing early injection of configuration and initialization * to start the token renewal process as early as possible + * + * @param configService + * The injected configuration service */ public OpenBaoClient(OpenBaoConfigurationService configService) { this.configService = configService; - } - /** - * Complete the instantiation of the class on first use - */ - public void init() throws GuacamoleException { try { VaultEndpoint endpoint = VaultEndpoint.from(configService.getVaultUri().resolve("v1")); + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + try { + requestFactory.setConnectTimeout(configService.getConnectionTimeout()); + } + catch (GuacamoleException e) { + logger.debug("Using default vault endpoint connection timeout: " + e.getMessage()); + } + try { + requestFactory.setReadTimeout(configService.getRequestTimeout()); + } + catch (GuacamoleException e) { + logger.debug("Using default vault endpoint request timeout: " + e.getMessage()); + } + + RestTemplate restTemplate = new RestTemplate(requestFactory); + restTemplate.setUriTemplateHandler(new DefaultUriBuilderFactory( + configService.getVaultUri().resolve("v1").toString())); + if (configService.getVaultToken() != null) { if (isTokenReadableFile(configService.getVaultToken())) { - logger.info("File Token : {}", configService.getVaultToken()); + logger.debug("File Token : {}", configService.getVaultToken()); this.authentication = - new FileTokenAuthentication(configService.getVaultToken(), - endpoint, new RestTemplate()); + new FileTokenAuthentication(configService.getVaultToken()); } else { - logger.info("Token : {}", configService.getVaultToken()); + logger.debug("Token : {}", configService.getVaultToken()); this.authentication = new TokenAuthentication(configService.getVaultToken()); } } @@ -145,43 +162,29 @@ else if (configService.getVaultUsername() != null && configService.getVaultPassw .password(configService.getVaultPassword()) .build(); + logger.debug("Username/Password : {}, {}", configService.getVaultUsername(), + configService.getVaultPassword()); this.authentication = - new UsernamePasswordAuthentication(options, endpoint, new RestTemplate()); + new UsernamePasswordAuthentication(options, endpoint, restTemplate); } else { throw new GuacamoleException("Either a vault token or Username/Password must be supplied"); } // Create a task scheduler for our token renewal - ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); - scheduler.setPoolSize(1); - scheduler.setThreadNamePrefix("vault-renewal-"); - scheduler.initialize(); + ThreadPoolTaskScheduler taskscheduler = new ThreadPoolTaskScheduler(); + taskscheduler.setPoolSize(1); + taskscheduler.setThreadNamePrefix("vault-renewal-"); + taskscheduler.initialize(); - // Automatically renew tokens before expiration - RestTemplate restTemplate = new RestTemplate(); - restTemplate.setUriTemplateHandler(new DefaultUriBuilderFactory( - configService.getVaultUri().resolve("v1").toString())); + // Session manager to automatically renew tokens before expiration this.sessionManager = new TtlAwareSessionManager(this.authentication, - restTemplate, scheduler, configService.getTokenRenewalDelay()); + restTemplate, taskscheduler, configService.getTokenRenewalDelay()); - SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); - try { - requestFactory.setConnectTimeout(configService.getConnectionTimeout()); - } - catch (GuacamoleException e) { - logger.debug("Using default vault endpoint connection timeout: " + e.getMessage()); - } - try { - requestFactory.setReadTimeout(configService.getRequestTimeout()); - } - catch (GuacamoleException e) { - logger.debug("Using default vault endpoint request timeout: " + e.getMessage()); - } this.vaultTemplate = new VaultTemplate(endpoint, requestFactory, this.sessionManager); } catch (Exception e) { - throw new GuacamoleException("Error initializing Vault client: " + e.getMessage()); + logger.error("Error initializing Vault client: {}", e.getMessage()); } // Initialize the cache with maximum size of 1MB, and cache expiry with @@ -190,7 +193,7 @@ else if (configService.getVaultUsername() != null && configService.getVaultPassw // 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 eleswhere as String + // 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. try { @@ -200,18 +203,18 @@ else if (configService.getVaultUsername() != null && configService.getVaultPassw .build(); } catch (GuacamoleException e) { - throw new GuacamoleException("Can't setup OpenBao cache"); + logger.error("Can't setup OpenBao cache"); } } - /* + /** * Function to detect if the the token is in fact a readable file * rather than a token string * - * @param String token + * @param token * The string with the token returned from configService * - * @return boolean + * @return * True is the token is a readable file */ private static boolean isTokenReadableFile(String token) { @@ -227,9 +230,9 @@ private static boolean isTokenReadableFile(String token) { /** * Lists the valid mount paths and their type * - * @return Map + * @return * The map of the mount path and with the value being the type. - * The type can be ssh, database, kv_1 or kv_2 + * The type can be ssh, ldap, database, kv_1 or kv_2 */ public Map listMountPaths() { @@ -289,62 +292,66 @@ else if (type.equals("kv")) { private static class LDAPSessionInfo { final String checkInPath; final String username; + final Boolean initialized; final Instant created; - LDAPSessionInfo(String checkInPath, String username, 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.created = created; + 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. Here - * we assume that the tunnel will connect soon after the call to getTokens - * and so we can tag the session information created in getTokens with - * tunnel ID. So we store the tunnel ID with the session data being created - * and then use it in a close event for the check-in + * we don't have access to the tunnel ID and so can't identify it. + * Guacamole is also not guarenteed to generate a TunnelCloseEvent. * - * FIXME : This is fragile, as if two connections in parallel are being - * created, the check-in event might be associate with the incorrect - * tunnel ID. Need a better solution + * 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 event - * A tunnel connect event + * A TunnelConnectEvent or TunnelCloseEvent */ - @Override - public void handleEvent(Object event) throws GuacamoleException { - + public void connectLdapSession(Object event) { if (event instanceof TunnelConnectEvent) { - GuacamoleTunnel tunnel = ((TunnelConnectEvent) event).getTunnel(); - String uuid = tunnel.getUUID().toString(); - LDAPSessionInfo info = ldapSessions.get("creating"); - if (info != null) { - logger.info("Saving connection UUID: {}", uuid); - // Assume we have a service account - ldapSessions.put(uuid, info); - ldapSessions.remove("creating"); + LDAPSessionInfo session = ldapSessions.get("checkin"); + if (session != null) { + String id = ((TunnelConnectEvent) event).getTunnel().getUUID().toString(); + logger.debug("Storing connection ID: {}", id); + ldapSessions.put(id, new LDAPSessionInfo(session.checkInPath, session.username, true)); + ldapSessions.remove("checkin"); } } - else if (event instanceof TunnelCloseEvent) { - GuacamoleTunnel tunnel = ((TunnelCloseEvent) event).getTunnel(); - String uuid = tunnel.getUUID().toString(); - LDAPSessionInfo session = ldapSessions.get(uuid); - if (session != null) { - logger.info("Closing connection UUID: {}", uuid); + else { + String id = ((TunnelCloseEvent) event).getTunnel().getUUID().toString(); + LDAPSessionInfo session = ldapSessions.get(id); + if (session != null && 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(uuid); + 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))); + // 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))); } /** @@ -356,66 +363,27 @@ else if (event instanceof TunnelCloseEvent) { * @param token * The Guacamole token to look up in OpenBao. * - * @param config - * A GuacamoleConfiguration containing the connection configuration - * parameters. + * @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 - * The value associated with the key. + * The value associated with the token. * * @throws GuacamoleException - * If the secret cannot be retrieved from OpenBao. + * If the secret cannot be retrieved from the Vault. */ - public String getValue(String token, UserContext userContext, GuacamoleConfiguration config) throws GuacamoleException { + public String getValue(String token, String username, String key) throws GuacamoleException { try { - if (authentication == null) { - logger.debug("Initializing OpenBao"); - init(); - logger.debug("Initialized OpenBao"); - } - if (! token.startsWith(VAULT_TOKEN_PREFIX)) { throw new GuacamoleException("Invalid token Vault token: " + token); } - // Before going further replace the arguments "{USERNAME}", "{SERVER}", - // "{GATEWAY_USERNAME}" and "{GATEWAY_HOSTNAME}" in the token with their - // with values supplied in the parameters. The value of USER here corresponds - // to GUAC_USERNAME - // FIXME Could this be done with the TokenFilter in OpenBaoSecretService ? - // 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}", guac_username); - - } - String hostname = config.getParameter("hostname"); - if (hostname != null && !hostname.isEmpty() && !hostname.contains("${") - && token.contains("{HOSTNAME}") && ! token.contains("$${HOSTNAME}")) { - token = token.replace("{HOSTNAME}", hostname); - - } - String username = config.getParameter("username"); - if (username != null && !username.isEmpty() && !username.contains("${") - && token.contains("{USERNAME}") && ! token.contains("$${USERNAME}")) { - token = token.replace("{USERNAME}", hostname); - - } - String gatewayHostname = config.getParameter("gateway-hostname"); - if (gatewayHostname != null && !gatewayHostname.isEmpty() && !gatewayHostname.contains("${") - && token.contains("{GATEWAY}") && ! token.contains("$${GATEWAY}")) { - token = token.replace("{GATEWAY}", gatewayHostname); - - } - 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}", gatewayUsername); - - } - // Detect and validate the mount path Map mountpaths = listMountPaths(); String mountPath = null; @@ -439,26 +407,62 @@ public String getValue(String token, UserContext userContext, GuacamoleConfigura String path = token.substring(VAULT_TOKEN_PREFIX.length() + mountPath.length(), lastSlashIndex); String secret = token.substring(lastSlashIndex + 1); - logger.info("MountPath {}, path {}, secret {}, type {}", mountPath, path, secret, type); - - switch (type) { - case "ssh": - return getValueSSH(mountPath, path, secret, config); - case "ldap": - return getValueLDAP(mountPath, path, secret); - case "database": - return getValueDB(mountPath, path, secret); - case "kv_1": - case "kv_2": - return getValueKV(mountPath, path, secret, type); - default: - throw new GuacamoleException("Unknown secret engine for the token: " + token); + logger.debug("MountPath {}, path {}, secret {}, type {}", mountPath, path, secret, type); + + Map cacheResponse = (Map) cache.getIfPresent(key); + + Object raw; + if (cacheResponse != null) { + raw = cacheResponse.get(secret); + } + else { + Map response; + + switch (type) { + case "ssh": + response = getValueSSH(mountPath, path, username); + break; + case "ldap": + response = getValueLDAP(mountPath, path); + break; + case "database": + response = getValueDB(mountPath, path); + break; + case "kv_1": + response = getValueKV(mountPath, path, VaultKeyValueOperations.KeyValueBackend.KV_1); + break; + case "kv_2": + response = getValueKV(mountPath, path, VaultKeyValueOperations.KeyValueBackend.KV_2); + break; + default: + throw new GuacamoleException("Unknown secret engine for the token: " + token); + } + + cache.put(key, response); + raw = response.get(secret); + } + + if (raw == null) { + throw new VaultException("Secret '" + secret + "' not found on the path '" + mountPath + path + "'"); + } + else if (raw instanceof String || raw instanceof Number || raw instanceof Boolean) { + return String.valueOf(raw); + } + else { + try { + // Stored JSON value.. Probably not usable, but return as a string + return objectMapper.writeValueAsString(raw); + } + catch (JsonProcessingException e) { + throw new GuacamoleException("Error json parsing returned secret: ", e); + } } } catch (VaultException e) { - logger.error("ERROR : {}", e.getMessage()); + logger.error("Failed to retrieve secret from the vault : {}", e.getMessage()); throw new GuacamoleServerException("Failed to retrieve secret from Vault Server : " + e.getMessage()); } + } /** @@ -470,74 +474,27 @@ public String getValue(String token, UserContext userContext, GuacamoleConfigura * @param path * The path of the secret record * - * @param secret - * The secret value to return - * * @param type - * The type of key-value store. Either "kv_1" or "kv_2" + * The type of key-value store. Either "kv_1" or "kv_2" * * @return - * The value associated with the secret. + * The values associated with the path. * * @throws GuacamoleException - * If the secret cannot be retrieved from OpenBao. + * If the secrets cannot be retrieved from the Vault. */ - private String getValueKV(String mountPath, String path, String secret, String type) throws GuacamoleException { - // Is the key-value already in the cache - Map cacheResponse = cache.getIfPresent(mountPath + "/" + path); - - if (cacheResponse != null) { - // The path is already cached?. Use it - Object raw = cacheResponse.get(secret); - if (raw instanceof String || raw instanceof Number || raw instanceof Boolean) { - return String.valueOf(raw); - } - else { - try { - // Stored JSON value.. Probably not usable, but return as a string - return objectMapper.writeValueAsString(raw); - } - catch (JsonProcessingException e) { - throw new GuacamoleException("Error json parsing returned secret: ", e); - } - } - } - - VaultKeyValueOperations kvOperations; - if ("kv_2".equals(type)) { - kvOperations = vaultTemplate.opsForKeyValue( - mountPath, - VaultKeyValueOperations.KeyValueBackend.KV_2); - } - else { - kvOperations = vaultTemplate.opsForKeyValue( - mountPath, - VaultKeyValueOperations.KeyValueBackend.KV_1); - } + private Map getValueKV(String mountPath, String path, VaultKeyValueOperations.KeyValueBackend type) throws GuacamoleException { + 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 GuacamoleServerException( - "Value not found in OpenBao for path: " + mountPath + "/" + path); - } - cache.put(mountPath + "/" + path, response.getData()); - - Object raw = response.getData().get(secret); - if (raw instanceof String || raw instanceof Number || raw instanceof Boolean) { - return String.valueOf(raw); - } - else { - try { - // Stored JSON value.. Probably not usable, but return as a string - return objectMapper.writeValueAsString(raw); - } - catch (JsonProcessingException e) { - throw new GuacamoleException("Error json parsing returned secret: ", e); - } + throw new GuacamoleServerException("Value not found in Vault for path: " + mountPath + path); } + + return response.getData(); } /** @@ -549,94 +506,32 @@ private String getValueKV(String mountPath, String path, String secret, String t * @param path * The path of the secret record representing the SSH role * - * @param secret - * The secret value to return - * - * @param config - * A GuacamoleConfiguration containing the connection configuration - * parameters. + * @param username + * The connection username that must be non null for SSH certificate + * generation. * * @return - * The value associated with the secret. + * The values associated with the path. * * @throws GuacamoleException - * If the secret cannot be retrieved from OpenBao. + * If the secrets cannot be retrieved from the Vault. */ - private String getValueSSH(String mountPath, String path, String secret, GuacamoleConfiguration config) throws GuacamoleException { - - // Is the key-value already in the cache. The SSH connection is for a specific - // user, so add username in the cache path - Map cacheResponse; - String username = config.getParameter("username"); - if (path.startsWith("sign/")) { + private Map getValueSSH(String mountPath, String path, String username) throws GuacamoleException { + if (path.startsWith("creds/")) { if (username == null || username.isEmpty()) { throw new GuacamoleException("The username can not be empty for SSH signed certificates"); } - cacheResponse = (Map) cache.getIfPresent(username + "-" + mountPath + path); - } - else if (path.startsWith("creds/")) { - cacheResponse = (Map) cache.getIfPresent(mountPath + path); - } - else { - throw new GuacamoleException("Unknown SSH type on path: " + mountPath + path); - } - - if (cacheResponse != null) { - logger.info("Cached Response"); - String retval; - // The path is already cached?. Use it - if (cacheResponse.get(secret) instanceof String) { - retval = (String) cacheResponse.get(secret); - } - else { - try { - // Stored JSON value.. Probably not usable, but return as a string - retval = objectMapper.writeValueAsString(cacheResponse.get(secret)); - } - catch (JsonProcessingException e) { - throw new GuacamoleException("Error json parsing returned secret: ", e); - } - } - - if (retval == null || retval.isEmpty()) { - throw new GuacamoleException("SSH secret '" + secret + "' not found on the path : " + mountPath +"/" + path); - } - // One-time password so needs to be cleared after first use - if (path.startsWith("creds/")) { - cache.invalidate(mountPath + path); - } - - return retval; - } - - // Detect if wanting one-time password or signed certificate - if (path.startsWith("creds/")) { + VaultResponse response = vaultTemplate.write(mountPath + path, Map.of("ip", "0.0.0.0")); if (response == null || response.getData() == null) { throw new GuacamoleException("No response from Vault SSH engine"); } - - String password = (String) response.getData().get("key"); - String user = (String) response.getData().get("username"); - - if (password == null || user == null) { - throw new GuacamoleException("Invalid SSH OTP response"); - } - - // Cache the retrieved user and otp - cache.put(mountPath + path, Map.of("username", user, "password", password)); - - if (secret.equals("username")) { - return user; - } - else if (secret.equals("password")) { - return password; - } + + return response.getData(); } else if (path.startsWith("sign/")) { - OpenBaoSshKeys sshKeys = new OpenBaoSshKeys(configService.getSshType()); Map request = Map.of( "public_key", sshKeys.publicSsh, @@ -652,24 +547,17 @@ else if (path.startsWith("sign/")) { } String signedCert = (String) vaultResponse.getData().get("signed_key"); - logger.info("SSH Cert response keys: {}", vaultResponse.getData().keySet()); if (signedCert == null) { throw new GuacamoleException("Vault did not return a signed SSH certificate"); } - cache.put(username + "-" + mountPath + path, Map.of( - "private", sshKeys.privateSshPem, - "public", signedCert)); - - if (secret.equals("private")) { - return sshKeys.privateSshPem; - } - else if (secret.equals("public")) { - return signedCert; } + return Map.of("private", sshKeys.privateSshPem, + "public", signedCert); + } + else { + throw new GuacamoleException("Unknown SSH type on path: " + mountPath + path); } - - throw new GuacamoleException("SSH secret '" + secret + "' not found on the path : " + mountPath + path); } /** @@ -682,48 +570,13 @@ else if (secret.equals("public")) { * @param path * The path of the secret record representing the LDAP role * - * @param secret - * The secret value to return - * * @return - * The value associated with the secret. + * The values associated with the path. * * @throws GuacamoleException - * If the secret cannot be retrieved from OpenBao. + * If the secrets cannot be retrieved from the Vault. */ - private String getValueLDAP(String mountPath, String path, String secret) throws GuacamoleException { - if (!secret.equals("username") && ! secret.equals("password")) { - throw new GuacamoleException("LDAP secret '" + secret + "' not found on the path : " + mountPath + path); - } - - // Is the value already in the cache - // FIXME Need a unique ConnectID while caching for dynamic at service roles as token same - // are the same for different credentials - Map cacheResponse = (Map) cache.getIfPresent(mountPath + path); - - if (cacheResponse != null) { - logger.info("Cached Response"); - String retval; - // The path is already cached?. Use it - if (cacheResponse.get(secret) instanceof String) { - retval = (String) cacheResponse.get(secret); - } - else { - try { - // Stored JSON value.. Probably not usable, but return as a string - return objectMapper.writeValueAsString(cacheResponse.get(secret)); - } - catch (JsonProcessingException e) { - throw new GuacamoleException("Error json parsing returned secret: ", e); - } - } - - if (retval == null || retval.isEmpty()) { - throw new GuacamoleException("LDAP secret '" + secret + "' not found on the path : " + mountPath + path); - } - return retval; - } - + private Map getValueLDAP(String mountPath, String path) throws GuacamoleException { VaultResponse response; if (path.startsWith("static") || path.startsWith("creds/")) { response = vaultTemplate.read(mountPath + path); @@ -733,45 +586,31 @@ else if (path.startsWith("library/")) { } else { throw new GuacamoleException("Unknown LDAP type on path: " + mountPath +"/" + path); - } + } if (response == null || response.getData() == null) { throw new GuacamoleException("No response from LDAP secrets engine"); } - - String username; - String password = (String) response.getData().get("password"); - if (path.startsWith("library/")) { - username = (String) response.getData().get("service_account_name"); - } - else { - username = (String) response.getData().get("username"); - } - - if (username == null || password == null) { - // Particularly for service accounts this might happen if there is - // no account available for checkout - throw new IllegalStateException("Invalid LDAP static credential response"); - } - + + 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 - LDAPSessionInfo info = new LDAPSessionInfo(mountPath + path + "/check-in", username, Instant.now()); - ldapSessions.put("creating", info); + logger.info("Caching session : {}", username); + LDAPSessionInfo info = new LDAPSessionInfo(mountPath + path + "/check-in", username); + ldapSessions.put("checkin", info); } - - // Cache the retrieved user and password - cache.put(mountPath + path, Map.of("username", username, "password", password)); - logger.info("LDAP : " + username + " : " + password); - - if (secret.equals("username")) { - return username; - } - return password; + + return retval; } /** * Retrieves a username or password from a Databse 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. @@ -779,66 +618,20 @@ else if (path.startsWith("library/")) { * @param path * The path of the secret record representing the LDAP role * - * @param secret - * The secret value to return - * * @return - * The value associated with the secret. + * The values associated with the path. * * @throws GuacamoleException - * If the secret cannot be retrieved from OpenBao. + * If the secrets cannot be retrieved from the Vault. */ - private String getValueDB(String mountPath, String path, String secret) throws GuacamoleException { - if (!secret.equals("username") && !secret.equals("password")) { - throw new GuacamoleException("Database secret '" + secret + "' not found on the path : " + mountPath +"/" + path); - } - - // Is the key-value already in the cache - Map cacheResponse = (Map) cache.getIfPresent(mountPath + path); - - if (cacheResponse != null) { - String retval; - // The path is already cached?. Use it - if (cacheResponse.get(secret) instanceof String) { - retval = (String) cacheResponse.get(secret); - } - else { - try { - // Stored JSON value.. Probably not usable, but return as a string - return objectMapper.writeValueAsString(cacheResponse.get(secret)); - } - catch (JsonProcessingException e) { - throw new GuacamoleException("Error json parsing returned secret: ", e); - } - } - - if (retval == null || retval.isEmpty()) { - throw new GuacamoleException("Data secret '" + secret + "' not found on the path : " + mountPath +"/" + path); - } - return retval; - } - - VaultResponse response = vaultTemplate.read(mountPath + "/creds/" + path); - + private Map getValueDB(String mountPath, String path) throws GuacamoleException { + VaultResponse response = vaultTemplate.read(mountPath + path); + if (response == null || response.getData() == null) { throw new GuacamoleException("No response from Databse secrets engine"); } - String username = (String) response.getData().get("username"); - String password = (String) response.getData().get("password"); - - if (username == null || password == null) { - throw new IllegalStateException("Invalid Database credential response"); - } - - // Cache the retrieved user and password - cache.put(mountPath + path, Map.of("username", username, "password", password)); - logger.debug("DB : " + username + " : " + password); - - if (secret.equals("username")) { - return username; - } - return password; + return response.getData(); } /** diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClientProvider.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClientProvider.java index 7ee761f6eb..5de91ee104 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClientProvider.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClientProvider.java @@ -32,6 +32,9 @@ public class OpenBaoClientProvider implements Provider { private final OpenBaoConfigurationService configService; + /** + * Creates a new OpenBaoClientProvider. + */ @Inject public OpenBaoClientProvider(OpenBaoConfigurationService configService) { this.configService = configService; @@ -39,13 +42,6 @@ public OpenBaoClientProvider(OpenBaoConfigurationService configService) { @Override public OpenBaoClient get() { - OpenBaoClient client = new OpenBaoClient(configService); - try { - client.init(); - } - catch (GuacamoleException e) { - throw new ProvisionException("Failed to initialize OpenBaoClient : " + e.getMessage()); - } - return client; + return new OpenBaoClient(configService); } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java index 2bf5f2889d..34780f4aa8 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java @@ -24,14 +24,16 @@ import com.google.inject.Singleton; import java.io.UnsupportedEncodingException; import java.net.URLEncoder; -import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; import java.util.HashMap; +import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.UUID; import org.apache.guacamole.GuacamoleException; import org.apache.guacamole.net.auth.Connectable; +import org.apache.guacamole.net.auth.Connection; import org.apache.guacamole.net.auth.UserContext; import org.apache.guacamole.protocol.GuacamoleConfiguration; import org.apache.guacamole.token.TokenFilter; @@ -59,28 +61,44 @@ public class OpenBaoSecretService implements VaultSecretService { private final Provider openBaoClientProvider; /** - * Constructor that logs when the service is created. Inject OpenBaoClient via - * Provider to avoid circular Guice dependency + * Constructor that loads when the service is created, forcing early + * start of Vault token renewal. Inject OpenBaoClient via Provider + * to avoid circular Guice dependency + * + * @param openBaoClientProvider + * A Provider for the OpenBaoClient singleton */ @Inject public OpenBaoSecretService(Provider openBaoClientProvider) { this.openBaoClientProvider = openBaoClientProvider; - logger.info("OpenBaoSecretService initialized"); + logger.debug("OpenBaoSecretService initialized"); } /** - * Get a Guice cahced copy of the OpenBaoClient Singleton + * Get a Guice cached copy of the OpenBaoClient Singleton + * + * @return + * A singleton OpenBaoClient instance */ private OpenBaoClient client() { return openBaoClientProvider.get(); } + /** + * 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 { - // As vault notation is essentially a URL, encode all components - // using standard URL escaping + // return URLEncoder.encode(nameComponent, "UTF-8"); } @@ -88,6 +106,62 @@ public String canonicalize(String nameComponent) { throw new UnsupportedOperationException("Unexpected lack of UTF-8 support.", e); } } + + /** + * 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)); + + } + 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)); + + } + 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)); + + } + 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)); + + } + 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)); + + } + + return token; + } /** * Returns a Future which eventually completes with the value of the secret @@ -111,14 +185,59 @@ public String canonicalize(String nameComponent) { */ @Override public Future getValue(String token) throws GuacamoleException { - String value = client().getValue(token, null, new GuacamoleConfiguration()); + // This function is only called for connection less tokens defined in + // guacamole.properties.vlt. Should probably refuse SSH and LDAP vault + // secrets in that case, but the key can be generic without risk + String value = client().getValue(token, "", token.substring(0, token.lastIndexOf('/'))); + logger.debug("getValue1 {} : {}", token, value); return CompletableFuture.completedFuture(value); } + /** + * 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 token) throws GuacamoleException { - String value = client().getValue(token, userContext, new GuacamoleConfiguration()); + GuacamoleConfiguration config; + if (connectable instanceof Connection) { + config = ((Connection) connectable).getConfiguration(); + } + else { + config = new GuacamoleConfiguration(); + } + // 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 + String username = config.getParameter("username"); + String guac_username = userContext.self().getIdentifier(); + String key = guac_username + "-" + username + "-" + token.substring(0, token.lastIndexOf('/')); + String value = client().getValue(prepareToken(token, userContext, config, new TokenFilter()), username, key); + logger.debug("getValue2 {} : {}", token, value); return CompletableFuture.completedFuture(value); } @@ -127,7 +246,7 @@ public Future getValue(UserContext userContext, Connectable connectable, * 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 implementedopenBaoClient to provide automatic tokens for those + * function should be implemented to provide automatic tokens for those * secrets and remove the need for manual mapping via YAML. * * @param userContext @@ -164,23 +283,37 @@ public Map> getTokens(UserContext userContext, Map parameters = config.getParameters(); Pattern tokenPattern = Pattern.compile("\\$\\{(" + client().VAULT_TOKEN_PREFIX + ".+)\\}"); + + // 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(); for (Map.Entry entry : parameters.entrySet()) { Matcher tokenMatcher = tokenPattern.matcher(entry.getValue()); while (tokenMatcher.find()) { String token = tokenMatcher.group(1); - String value = client().getValue(token, userContext, config); + + // Resolve any tokens in the username for use in possible ssh certificate + String username = filter.filter(config.getParameter("username")); + + String value = client().getValue(prepareToken(token, userContext, config, filter), username, key); tokens.put(token, CompletableFuture.completedFuture(value)); } } - logger.info("Returning {} OpenBao tokens:", tokens.size()); - tokens.forEach((k, v) -> { - try { - logger.info(" {} : {}", k, v.get()); - } catch (Exception e) { - logger.info(" {} => ERROR: {}", k, e); - }}); + // The get on the Future value below will cause the debug code below + // to wait for completion. Only run if in debug mode + if (logger.isDebugEnabled()) { + 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); + }}); + } + return tokens; } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSshKeys.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSshKeys.java index dd4db77e86..2e3691ce12 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSshKeys.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSshKeys.java @@ -32,7 +32,6 @@ import org.slf4j.LoggerFactory; public class OpenBaoSshKeys { - /** * Logger for this class. */ @@ -48,8 +47,19 @@ public class OpenBaoSshKeys { */ public final String publicSsh; + /** + * Class instantiation to return generated SSH keys. Default type + */ + public OpenBaoSshKeys() { + this(OpenBaoConfigurationService.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 OpenBaoSshKeys(String type) { KeyPair keyPair; @@ -57,9 +67,12 @@ public OpenBaoSshKeys(String type) { if ("rsa".equals(type)) { keyPair = generateRsa(); } - else { + else if ("ed25519".equals(type)) { keyPair = generateEd25519WithFallback(); } + else { + throw new IllegalArgumentException("Unrecognized SSH encryption : "+ type); + } try { this.publicSsh = PublicKeyEntry.toString(keyPair.getPublic()); @@ -75,13 +88,6 @@ public OpenBaoSshKeys(String type) { } } - /** - * Class instantiation to return generated SSH keys. Default type - */ - public OpenBaoSshKeys() { - this(OpenBaoConfigurationService.DEFAULT_SSH_TYPE); - } - /** * Generate a ed25519 key-pair * @@ -95,7 +101,7 @@ private KeyPair generateEd25519WithFallback() { return keyPairGenerator.generateKeyPair(); } catch (Exception e) { - logger.info("Ed25519 not available via SSHD EdDSA. Falling back to RSA : {}", e.getMessage()); + logger.warn("Ed25519 not available via SSHD EdDSA. Falling back to RSA : {}", e.getMessage()); return generateRsa(); } } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoTunnelEventListener.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoTunnelEventListener.java new file mode 100644 index 0000000000..4efd635576 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoTunnelEventListener.java @@ -0,0 +1,72 @@ +/* + * 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.openbao.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.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class OpenBaoTunnelEventListener implements Listener { + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(OpenBaoTunnelEventListener.class); + + /** + * A Provider for the OpenBaoClient that is injected by Guice + */ + @Inject + private static Provider clientProvider; + + /** + * Function to get an instance of the Singleton OpenBaoClient Class + */ + private OpenBaoClient client() { + return clientProvider.get(); + } + + /** + * Default constructor for ProviderFactory + */ + public OpenBaoTunnelEventListener() { + logger.debug("OpenBaoTunnelEventListener constructed"); + } + + /** + * 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 { + logger.debug("Called OpenBaoListener : {}", event.getClass().getName()); + if (event instanceof TunnelConnectEvent || event instanceof TunnelCloseEvent) { + client().connectLdapSession(event); + } + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/FileTokenAuthentication.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/FileTokenAuthentication.java index 529ac65ac4..d7c1c8315b 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/FileTokenAuthentication.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/FileTokenAuthentication.java @@ -46,16 +46,6 @@ public final class FileTokenAuthentication implements ClientAuthentication { */ private final Path tokenPath; - /** - * The vault endpoint to lookup token metadata - */ - private final VaultEndpoint endpoint; - - /** - * A reusable restTemplate - */ - private final RestTemplate restTemplate; - /** * An instantiator for a Token Authentication class where the token * is reread from a file on renewal requests. This allows integration @@ -63,14 +53,9 @@ public final class FileTokenAuthentication implements ClientAuthentication { * * @param String tokenPath * A path to a readable file containing the token - * - * @param VaultEndpoint endpoint - * The Vault endpoint used for token metadata lookup */ - public FileTokenAuthentication(String tokenPath, VaultEndpoint endpoint, RestTemplate restTemplate) { + public FileTokenAuthentication(String tokenPath) { this.tokenPath = Path.of(tokenPath); - this.endpoint = endpoint; - this.restTemplate = restTemplate; } /* diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/TtlAwareSessionManager.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/TtlAwareSessionManager.java index 8376107995..02b969a72c 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/TtlAwareSessionManager.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/TtlAwareSessionManager.java @@ -48,10 +48,9 @@ * - 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 + * - Handle both LoginToken and VaultToken types. */ public class TtlAwareSessionManager implements SessionManager { - /** * Logger for this class. */ @@ -94,7 +93,7 @@ public class TtlAwareSessionManager implements SessionManager { * 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 LifesycleAwareSessionMnagerr and delegate + * 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 * @@ -385,13 +384,17 @@ private void renewTokenAsync() { /** * Renews a renewable token via the Vault API. * - * @param token The token to renew. + * @param token + * The token to renew. * - * @return A new LoginToken with updated TTL. + * @return + * A new LoginToken with updated TTL. * - * @throws VaultException If the renewal fails. + * @throws VaultException + * If the renewal fails, but the error is recoverable. Non + * recoverable exceptions are bubbled up. */ - private LoginToken renewToken(VaultToken token) { + private LoginToken renewToken(VaultToken token) throws VaultException { VaultResponse response; @@ -432,7 +435,7 @@ private LoginToken renewToken(VaultToken token) { /* * Attempt a login via clientAuthentication class, and reschedule - * in case of a recoverable failure. + * in case of a recoverable failure. */ private void attemptLogin() { @@ -484,7 +487,7 @@ public VaultToken getSessionToken() { * Can be either a Null, VaultToken or a LoginToken */ public void setSessionToken(VaultToken token) { - logger.info("Setting new token : {}", token.getToken()); + logger.debug("Setting new token : {}", token.getToken()); if (token != null && !token.getToken().isEmpty()) { this.token.set(token); } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/UsernamePasswordAuthentication.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/UsernamePasswordAuthentication.java index 113de2fb06..e705af7788 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/UsernamePasswordAuthentication.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/UsernamePasswordAuthentication.java @@ -53,6 +53,15 @@ public class UsernamePasswordAuthentication implements ClientAuthentication { /** * 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, @@ -69,13 +78,13 @@ public UsernamePasswordAuthentication( } /** - * A login method that attempts a username/password login to the vault + * 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 + * @throws VaultException * In case of a recoverable login issue, throws a VaultExecption, so tha the * SessionManager knows to try the authtication again */ diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/UsernamePasswordAuthenticationOptions.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/UsernamePasswordAuthenticationOptions.java index a23672791d..a9c4d2b185 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/UsernamePasswordAuthenticationOptions.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/UsernamePasswordAuthenticationOptions.java @@ -24,10 +24,21 @@ 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 final String mountPath; + private UsernamePasswordAuthenticationOptions(Builder builder) { this.username = builder.username; diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/resource-templates/guac-manifest.json b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/resource-templates/guac-manifest.json index 32b272cc52..7bfd7444a6 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/resource-templates/guac-manifest.json +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/resource-templates/guac-manifest.json @@ -7,6 +7,10 @@ "authProviders" : [ "org.apache.guacamole.vault.openbao.OpenBaoAuthenticationProvider" + ], + + "listeners" : [ + "org.apache.guacamole.vault.openbao.secret.OpenBaoTunnelEventListener" ] } From 0c85ef36883963b8fb87af123ee714dcfc82bd75 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Thu, 21 May 2026 12:55:49 +0200 Subject: [PATCH 10/16] GUACAMOLE-2196: Allow unsigned ssh certificates to be returned. The difference in creation of ldap static credentials between OpenBao and Hashicorp is only in the role creation, not the use by Guacamole --- .../modules/guacamole-vault-openbao/docs/openbao.md.j2 | 10 +--------- .../guacamole/vault/openbao/secret/OpenBaoClient.java | 3 ++- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 index 2f6a570e7a..c6fb8b43b7 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 @@ -1238,20 +1238,12 @@ Guacamole simplifies the token format for LDAP paths as follows: ${VAULT:{MOUNT_PATH}/{static-role|static-cred|creds|library}/{ROLE}/{username|password}} ``` -As the paths of OpenBao and Hashicorp Vaults are different for static passwords, it -is up to the user to use tokens that are acceptable to the Vault being used. An -example of a static credential for OpenBao is of the form +An example of a static credential for OpenBao is of the form ``` ${vault://{MOUNT_PATH}/static-cred/{ROLE}/{username/password}} ``` -and for Hashicorp Vault is of the form - -``` -${vault://{MOUNT_PATH}/static-role/{ROLE}/{username/password}} -``` - For dynamic credentials both OpenBao and Hashicorp use the same paths, which are of the form diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java index 805bc3597b..f926241597 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java @@ -553,7 +553,8 @@ else if (path.startsWith("sign/")) { } return Map.of("private", sshKeys.privateSshPem, - "public", signedCert); + "public", signedCert, + "unsigned", sshKeys.publicSsh); } else { throw new GuacamoleException("Unknown SSH type on path: " + mountPath + path); From ec89e4051b00e31ade914fef6216d0cb22fa4d6c Mon Sep 17 00:00:00 2001 From: David Bateman Date: Thu, 21 May 2026 20:31:15 +0200 Subject: [PATCH 11/16] GUACAMOLE-2196: Rebase against latest main branch --- doc/licenses/apache-sshd-2.17.1/README | 8 -------- .../apache-sshd-2.17.1/dep-coordinates.txt | 3 --- doc/licenses/apache-sshd/NOTICE | 16 ++++++++++++++++ doc/licenses/apache-sshd/dep-coordinates.txt | 3 +++ doc/licenses/caffeine-3.1.8/README | 7 ------- doc/licenses/caffeine-3.1.8/dep-coordinates.txt | 1 - doc/licenses/io-micrometer-1.14.7/README | 8 -------- .../io-micrometer-1.14.7/dep-coordinates.txt | 2 -- doc/licenses/net-i2p-crypto/LICENSE | 6 ++++++ doc/licenses/net-i2p-crypto/README | 12 ------------ doc/licenses/net-i2p-crypto/dep-coordinates.txt | 2 +- doc/licenses/org-jspecify-1.0.0/README | 8 -------- .../org-jspecify-1.0.0/dep-coordinates.txt | 1 - doc/licenses/spring-framework-5.3.29/README | 8 -------- .../spring-framework-5.3.29/dep-coordinates.txt | 7 ------- .../spring-framework/dep-coordinates.txt | 2 ++ doc/licenses/spring-vault-core-2.3.4/README | 8 -------- .../spring-vault-core-2.3.4/dep-coordinates.txt | 1 - doc/licenses/spring-vault/LICENSE | 4 ++++ doc/licenses/spring-vault/dep-coordinates.txt | 1 + 20 files changed, 33 insertions(+), 75 deletions(-) delete mode 100644 doc/licenses/apache-sshd-2.17.1/README delete mode 100644 doc/licenses/apache-sshd-2.17.1/dep-coordinates.txt create mode 100644 doc/licenses/apache-sshd/NOTICE create mode 100644 doc/licenses/apache-sshd/dep-coordinates.txt delete mode 100644 doc/licenses/caffeine-3.1.8/README delete mode 100644 doc/licenses/caffeine-3.1.8/dep-coordinates.txt delete mode 100644 doc/licenses/io-micrometer-1.14.7/README delete mode 100644 doc/licenses/io-micrometer-1.14.7/dep-coordinates.txt create mode 100644 doc/licenses/net-i2p-crypto/LICENSE delete mode 100644 doc/licenses/net-i2p-crypto/README delete mode 100644 doc/licenses/org-jspecify-1.0.0/README delete mode 100644 doc/licenses/org-jspecify-1.0.0/dep-coordinates.txt delete mode 100644 doc/licenses/spring-framework-5.3.29/README delete mode 100644 doc/licenses/spring-framework-5.3.29/dep-coordinates.txt delete mode 100644 doc/licenses/spring-vault-core-2.3.4/README delete mode 100644 doc/licenses/spring-vault-core-2.3.4/dep-coordinates.txt create mode 100644 doc/licenses/spring-vault/LICENSE create mode 100644 doc/licenses/spring-vault/dep-coordinates.txt diff --git a/doc/licenses/apache-sshd-2.17.1/README b/doc/licenses/apache-sshd-2.17.1/README deleted file mode 100644 index d35bad48f3..0000000000 --- a/doc/licenses/apache-sshd-2.17.1/README +++ /dev/null @@ -1,8 +0,0 @@ -Apache Mina (https://mina.apache.org/sshd-project) --------------------------------------- - - Version: 2.17.1 - From: 'Apache Software Foundation' (https://www.apache.org/) - License(s): - Apache v2.0 - diff --git a/doc/licenses/apache-sshd-2.17.1/dep-coordinates.txt b/doc/licenses/apache-sshd-2.17.1/dep-coordinates.txt deleted file mode 100644 index 549f6c89ab..0000000000 --- a/doc/licenses/apache-sshd-2.17.1/dep-coordinates.txt +++ /dev/null @@ -1,3 +0,0 @@ -org.apache.sshd:sshd-common:jar:2.17.1 -org.apache.sshd:sshd-core:jar:2.17.1 - 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/caffeine-3.1.8/README b/doc/licenses/caffeine-3.1.8/README deleted file mode 100644 index 2e5cf93923..0000000000 --- a/doc/licenses/caffeine-3.1.8/README +++ /dev/null @@ -1,7 +0,0 @@ -Caffeine (https://github.com/ben-manes/caffeine) ------------------------------------------------- - - Version: 3.1.8 - From: 'Ben Manes' (https://github.com/ben-manes) - License(s): - Apache v2.0 diff --git a/doc/licenses/caffeine-3.1.8/dep-coordinates.txt b/doc/licenses/caffeine-3.1.8/dep-coordinates.txt deleted file mode 100644 index 0b23b4e4d4..0000000000 --- a/doc/licenses/caffeine-3.1.8/dep-coordinates.txt +++ /dev/null @@ -1 +0,0 @@ -com.github.ben-manes.caffeine:caffeine:jar:3.1.8 diff --git a/doc/licenses/io-micrometer-1.14.7/README b/doc/licenses/io-micrometer-1.14.7/README deleted file mode 100644 index cca5249342..0000000000 --- a/doc/licenses/io-micrometer-1.14.7/README +++ /dev/null @@ -1,8 +0,0 @@ -Micrometer IO (http://micrometer.io/) --------------------------------------------------------------- - - Version: 1.14.7 - From: 'VMWare' (https://tanzu.vmware.com/) - License(s): - Apache v2.0 - diff --git a/doc/licenses/io-micrometer-1.14.7/dep-coordinates.txt b/doc/licenses/io-micrometer-1.14.7/dep-coordinates.txt deleted file mode 100644 index 37084e1f06..0000000000 --- a/doc/licenses/io-micrometer-1.14.7/dep-coordinates.txt +++ /dev/null @@ -1,2 +0,0 @@ -io.micrometer:micrometer-observation:jar:1.14.7 -io.micrometer:micrometer-commons:jar:1.14.7 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/README b/doc/licenses/net-i2p-crypto/README deleted file mode 100644 index 04a5600b96..0000000000 --- a/doc/licenses/net-i2p-crypto/README +++ /dev/null @@ -1,12 +0,0 @@ -net.i2p.crypto:eddsa --------------------- - - 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) - https://creativecommons.org/publicdomain/zero/1.0/ diff --git a/doc/licenses/net-i2p-crypto/dep-coordinates.txt b/doc/licenses/net-i2p-crypto/dep-coordinates.txt index de81025be8..506e28e461 100644 --- a/doc/licenses/net-i2p-crypto/dep-coordinates.txt +++ b/doc/licenses/net-i2p-crypto/dep-coordinates.txt @@ -1 +1 @@ -net.i2p.crypto:eddsa:jar:0.3.0 +net.i2p.crypto:eddsa:jar:* diff --git a/doc/licenses/org-jspecify-1.0.0/README b/doc/licenses/org-jspecify-1.0.0/README deleted file mode 100644 index 789a528355..0000000000 --- a/doc/licenses/org-jspecify-1.0.0/README +++ /dev/null @@ -1,8 +0,0 @@ -JSpecify (https://jspecify.dev) --------------------------------------------------------------- - - Version: 1.0.0 - From: 'JSpecify' (https://jspecify.dev) - License(s): - Apache v2.0 - diff --git a/doc/licenses/org-jspecify-1.0.0/dep-coordinates.txt b/doc/licenses/org-jspecify-1.0.0/dep-coordinates.txt deleted file mode 100644 index 36c489c95d..0000000000 --- a/doc/licenses/org-jspecify-1.0.0/dep-coordinates.txt +++ /dev/null @@ -1 +0,0 @@ -org.jspecify:jspecify:jar:1.0.0 diff --git a/doc/licenses/spring-framework-5.3.29/README b/doc/licenses/spring-framework-5.3.29/README deleted file mode 100644 index 25da871418..0000000000 --- a/doc/licenses/spring-framework-5.3.29/README +++ /dev/null @@ -1,8 +0,0 @@ -Spring Framework (https://spring.io/projects/spring-framework) --------------------------------------------------------------- - - Version: 5.3.29 - From: 'Spring' (https://spring.io/) - License(s): - Apache v2.0 - diff --git a/doc/licenses/spring-framework-5.3.29/dep-coordinates.txt b/doc/licenses/spring-framework-5.3.29/dep-coordinates.txt deleted file mode 100644 index 1de5225a24..0000000000 --- a/doc/licenses/spring-framework-5.3.29/dep-coordinates.txt +++ /dev/null @@ -1,7 +0,0 @@ -org.springframework:spring-aop:jar:5.3.29 -org.springframework:spring-beans:jar:5.3.29 -org.springframework:spring-context:jar:5.3.29 -org.springframework:spring-core:jar:5.3.29 -org.springframework:spring-expression:jar:5.3.29 -org.springframework:spring-jcl:jar:5.3.29 -org.springframework:spring-web:jar:5.3.29 diff --git a/doc/licenses/spring-framework/dep-coordinates.txt b/doc/licenses/spring-framework/dep-coordinates.txt index 2e107e9902..c9d1cb7d8b 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:* +org.springframework:spring-web:jar:* diff --git a/doc/licenses/spring-vault-core-2.3.4/README b/doc/licenses/spring-vault-core-2.3.4/README deleted file mode 100644 index a153153953..0000000000 --- a/doc/licenses/spring-vault-core-2.3.4/README +++ /dev/null @@ -1,8 +0,0 @@ -Spring Framework (https://spring.io/projects/spring-framework) --------------------------------------------------------------- - - Version: 2.3.4 - From: 'Spring' (https://spring.io/) - License(s): - Apache v2.0 - diff --git a/doc/licenses/spring-vault-core-2.3.4/dep-coordinates.txt b/doc/licenses/spring-vault-core-2.3.4/dep-coordinates.txt deleted file mode 100644 index a20922d66d..0000000000 --- a/doc/licenses/spring-vault-core-2.3.4/dep-coordinates.txt +++ /dev/null @@ -1 +0,0 @@ -org.springframework.vault:spring-vault-core:jar:2.3.4 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:* From f4dff69f4f5469d8ab8c03946fd0811456dedfb4 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Thu, 21 May 2026 23:33:49 +0200 Subject: [PATCH 12/16] GUACAMOLE-2196: Address review comments and security fix - Typos (review comment) - Don't print secret values even for debug logging (review comment) - Remove the need for access to sys/mounts that might be a security risk and use the Vault path-help API to get the mount-path and type - Fix missing SSH OTP token secrets after simplification of OpenBaoClient - Some documentation corrections --- .../docs/openbao.md.j2 | 27 ++- .../guacamole-vault-openbao/docs/openbao.sh | 5 +- .../vault/openbao/secret/OpenBaoClient.java | 174 ++++++++---------- .../openbao/secret/OpenBaoSecretService.java | 30 +-- .../openbao/vault/TtlAwareSessionManager.java | 1 - 5 files changed, 110 insertions(+), 127 deletions(-) diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 index c6fb8b43b7..a79f0dd438 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 @@ -70,7 +70,6 @@ Guacamole requires the following permissions in Vault: - **Read access** to the secrets it consumes. - **Limited write access** for certain secret engines (e.g., SSH certificate signing). -- **Read access to the `sys/mounts` path** to detect available secret engines and their types. For **security and auditability**, it is **strongly recommended** to create a **dedicated Vault policy** for Guacamole. Below is an example policy that grants @@ -80,11 +79,6 @@ in each engine. #### Example Policy (`guacamole.hcl`) ```hcl -# Read system mount table -path "sys/mounts" { - capabilities = ["read"] -} - # Read from key-value secrets dedicated to Guacamole path "kv/guacamole/*" { capabilities = ["read"] @@ -92,12 +86,12 @@ path "kv/guacamole/*" { # Allow SSH certificate signing for Guacamole path "ssh/sign/guacamole_cert" { - capabilities = ["create", "update"] + capabilities = ["update"] } # Allow SSH one-time password generation path "ssh/creds/guacamole_otp" { - capabilities = ["create"] + capabilities = ["update"] } # Read/Update for LDAP service credentials @@ -549,7 +543,20 @@ curl -s --header "X-Vault-Token: $VAULT_TOKEN" \ http://127.0.0.1:8200/v1/sys/policies/password/ldap-policy ``` -3. **Configure the LDAP Connection in Vault**: +3. **Enable the LDAP secrets engine**: + +To enable the database secret engine on a mount path named `db`: + +```bash +curl -s \ + --header "X-Vault-Token: $VAULT_TOKEN" \ + --header "Content-Type: application/json" \ + --request POST \ + --data '{"type": "ldap"}' \ + http://127.0.0.1:8200/v1/sys/mounts/ldap +`` + +4. **Configure the LDAP Connection in Vault**: ```bash curl -s \ @@ -574,7 +581,7 @@ The `insecure_tls: false` option is **not secure** for production. Instead: - Specify the CA certificate using the `tls_ca_cert` or `tls_ca_cert_file` options. ::: -4. **Test the LDAP Connection**: +5. **Test the LDAP Connection**: Before proceeding, verify that Vault can communicate with the LDAP server by forcing a password rotation: diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.sh b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.sh index f5ef44d06e..ac7567e472 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.sh +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.sh @@ -18,9 +18,6 @@ VAULT_TOKEN=$(docker logs openbao 2> /dev/null | grep "Root Token" | cut -d: -f2 UNSEAL_KEY=$(docker logs openbao 2> /dev/null | grep "Unseal" | cut -d: -f2 | xargs) echo "# Create Guacamole limited access policy" cat << EOF > guacamole.hcl -path "sys/mounts" { - capabilities = ["read"] -} path "kv1/*" { capabilities = ["read"] } @@ -28,7 +25,7 @@ path "kv2/*" { capabilities = ["read"] } path "ssh/sign/guacamole_cert" { - capabilities = ["update", "create"] + capabilities = ["update"] } path "ssh/creds/guacamole_otp" { capabilities = ["update"] diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java index f926241597..bde8a96080 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java @@ -83,6 +83,11 @@ public class OpenBaoClient { */ 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/"; + /** * Cache of secrets recently fetched */ @@ -107,7 +112,7 @@ public class OpenBaoClient { * A HashMap of the checked out LDAP Sessions */ private final Map ldapSessions = new HashMap<>(); - + /** * A task scheduler for remove terminated checked out LDAP Sessions */ @@ -118,7 +123,7 @@ public class OpenBaoClient { * to start the token renewal process as early as possible * * @param configService - * The injected configuration service + * The injected configuration service */ public OpenBaoClient(OpenBaoConfigurationService configService) { this.configService = configService; @@ -146,12 +151,10 @@ public OpenBaoClient(OpenBaoConfigurationService configService) { if (configService.getVaultToken() != null) { if (isTokenReadableFile(configService.getVaultToken())) { - logger.debug("File Token : {}", configService.getVaultToken()); this.authentication = new FileTokenAuthentication(configService.getVaultToken()); } else { - logger.debug("Token : {}", configService.getVaultToken()); this.authentication = new TokenAuthentication(configService.getVaultToken()); } } @@ -162,8 +165,6 @@ else if (configService.getVaultUsername() != null && configService.getVaultPassw .password(configService.getVaultPassword()) .build(); - logger.debug("Username/Password : {}, {}", configService.getVaultUsername(), - configService.getVaultPassword()); this.authentication = new UsernamePasswordAuthentication(options, endpoint, restTemplate); } @@ -228,62 +229,54 @@ private static boolean isTokenReadableFile(String token) { } /** - * Lists the valid mount paths and their type + * 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 + * + * @param path + * The path to test for the secret engine type * * @return - * The map of the mount path and with the value being the type. - * The type can be ssh, ldap, database, kv_1 or kv_2 + * A Map with the secret engine type and its mount path */ - public Map listMountPaths() { - - Map cacheMountPaths = cache.getIfPresent("sys/mounts"); - if (cacheMountPaths != null) { - return cacheMountPaths; + public Map getSecretsEngine(String path) { + Map cacheResponse = cache.getIfPresent(VAULT_PATH_HELP + path); + if (cacheResponse != null) { + return cacheResponse; } - VaultResponse response = vaultTemplate.read("sys/mounts"); + VaultResponse response = vaultTemplate.read(VAULT_PATH_HELP + path); + Map data = response.getData(); + String type = String.valueOf(data.get("type")); - if (response == null || response.getData() == null) { - return Collections.emptyMap(); - } - - Map mounts = response.getData(); - Map mountPaths = new HashMap<>(); - - mounts.forEach((mountPath, mountInfoObj) -> { - if (mountInfoObj instanceof Map) { - Map mountInfo = (Map) mountInfoObj; - Object type = mountInfo.get("type"); - if (type instanceof String) { - if (type.equals("ssh") || type.equals("database") || type.equals("ldap")) { - mountPaths.put(mountPath, type); - } - else if (type.equals("kv")) { - // Need to detect if type 1 or type 2 Key/Value engine - if (mountInfo.get("options") instanceof Map) { - Map options = (Map) mountInfo.get("options"); - Object version = options.get("version"); - if (version instanceof String) { - if (version.equals("2")) { - mountPaths.put(mountPath, "kv_2"); - } - else { - mountPaths.put(mountPath, "kv_1"); - } - } - } - else { - // No options, assume kv_1 - mountPaths.put(mountPath, "kv_1"); - } - } + 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"; + } + } + Map map = Map.of("type", type, "path", String.valueOf(data.get("path"))); + cache.put(VAULT_PATH_HELP + path, map); + logger.debug("getSecretsEngine {} : {} : {}", VAULT_PATH_HELP + path, type, String.valueOf(data.get("path"))); - cache.put("sys/mounts", mountPaths); + try { + logger.debug("getSecretsEngine {}", objectMapper.writeValueAsString(data)); + } + catch (JsonProcessingException e) { + logger.info("Error json parsing returned secret: " + e.getMessage()); + } - return mountPaths; + return map; } /** @@ -322,7 +315,7 @@ public LDAPSessionInfo(String checkInPath, String username, Boolean initialized) * of error here. * * @param event - * A TunnelConnectEvent or TunnelCloseEvent + * A TunnelConnectEvent or TunnelCloseEvent */ public void connectLdapSession(Object event) { if (event instanceof TunnelConnectEvent) { @@ -339,7 +332,7 @@ public void connectLdapSession(Object event) { LDAPSessionInfo session = ldapSessions.get(id); if (session != null && session.initialized) { // FIXME : We don't always receive the TunnelCloseEvent - // So this checkin function only kinda works + // 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); @@ -384,40 +377,26 @@ public String getValue(String token, String username, String key) throws Guacamo throw new GuacamoleException("Invalid token Vault token: " + token); } - // Detect and validate the mount path - Map mountpaths = listMountPaths(); - String mountPath = null; - String type = null; - for (String _path : mountpaths.keySet()) { - if (token.startsWith(VAULT_TOKEN_PREFIX + _path) && - (mountPath == null || _path.length() > mountPath.length())) { - mountPath = _path; - type = (String) mountpaths.get(_path); - } - } - if (mountPath == null || mountPath.isEmpty()) { - throw new GuacamoleException("The Vault mount path of the token is invalid: " + token); - } - // Find last slash to isolate the secret value in the record int lastSlashIndex = token.lastIndexOf('/'); if (lastSlashIndex == -1) lastSlashIndex = VAULT_TOKEN_PREFIX.length(); - String path = token.substring(VAULT_TOKEN_PREFIX.length() + mountPath.length(), lastSlashIndex); + String path = token.substring(VAULT_TOKEN_PREFIX.length(), lastSlashIndex); String secret = token.substring(lastSlashIndex + 1); - logger.debug("MountPath {}, path {}, secret {}, type {}", mountPath, path, secret, type); - Map cacheResponse = (Map) cache.getIfPresent(key); Object raw; if (cacheResponse != null) { - raw = cacheResponse.get(secret); + raw = cacheResponse.get(secret); } else { Map response; - + String type = String.valueOf(getSecretsEngine(path).get("type")); + String mountPath = String.valueOf(getSecretsEngine(path).get("path")); + path = path.substring(mountPath.length()); + switch (type) { case "ssh": response = getValueSSH(mountPath, path, username); @@ -441,9 +420,9 @@ public String getValue(String token, String username, String key) throws Guacamo cache.put(key, response); raw = response.get(secret); } - + if (raw == null) { - throw new VaultException("Secret '" + secret + "' not found on the path '" + mountPath + path + "'"); + throw new VaultException("Secret '" + secret + "' not found from the token '" + token + "'"); } else if (raw instanceof String || raw instanceof Number || raw instanceof Boolean) { return String.valueOf(raw); @@ -462,7 +441,7 @@ else if (raw instanceof String || raw instanceof Number || raw instanceof Boolea logger.error("Failed to retrieve secret from the vault : {}", e.getMessage()); throw new GuacamoleServerException("Failed to retrieve secret from Vault Server : " + e.getMessage()); } - + } /** @@ -491,9 +470,9 @@ private Map getValueKV(String mountPath, String path, VaultKeyVa if (response == null || response.getData() == null) { - throw new GuacamoleServerException("Value not found in Vault for path: " + mountPath + path); + throw new GuacamoleException("Value not found in Vault for path: " + path); } - + return response.getData(); } @@ -507,7 +486,7 @@ private Map getValueKV(String mountPath, String path, VaultKeyVa * The path of the secret record representing the SSH role * * @param username - * The connection username that must be non null for SSH certificate + * The connection username that must be non null for SSH certificate * generation. * * @return @@ -521,15 +500,17 @@ private Map getValueSSH(String mountPath, String path, String us if (username == null || username.isEmpty()) { throw new GuacamoleException("The username can not be empty for SSH signed certificates"); } - + VaultResponse response = - vaultTemplate.write(mountPath + path, Map.of("ip", "0.0.0.0")); + vaultTemplate.write(mountPath + path, Map.of("ip", "0.0.0.0")); if (response == null || response.getData() == null) { throw new GuacamoleException("No response from Vault SSH engine"); } - - return response.getData(); + Map retval = response.getData(); + retval.put("username", retval.get("key")); + + return retval; } else if (path.startsWith("sign/")) { OpenBaoSshKeys sshKeys = new OpenBaoSshKeys(configService.getSshType()); @@ -539,8 +520,7 @@ else if (path.startsWith("sign/")) { "extensions", Map.of("permit-pty", ""), "ttl", configService.getSshConnectionTimeout()); - VaultResponse vaultResponse = - vaultTemplate.write(mountPath + path, request); + VaultResponse vaultResponse = vaultTemplate.write(mountPath + path, request); if (vaultResponse == null || vaultResponse.getData() == null) { throw new GuacamoleException("No response from Vault SSH engine"); @@ -554,7 +534,7 @@ else if (path.startsWith("sign/")) { return Map.of("private", sshKeys.privateSshPem, "public", signedCert, - "unsigned", sshKeys.publicSsh); + "unsigned", sshKeys.publicSsh); } else { throw new GuacamoleException("Unknown SSH type on path: " + mountPath + path); @@ -577,7 +557,7 @@ else if (path.startsWith("sign/")) { * @throws GuacamoleException * If the secrets cannot be retrieved from the Vault. */ - private Map getValueLDAP(String mountPath, String path) throws GuacamoleException { + private Map getValueLDAP(String mountPath, String path) throws GuacamoleException { VaultResponse response; if (path.startsWith("static") || path.startsWith("creds/")) { response = vaultTemplate.read(mountPath + path); @@ -586,29 +566,29 @@ else if (path.startsWith("library/")) { response = vaultTemplate.write(mountPath + path + "/check-out", Map.of("ttl", "2h")); } else { - throw new GuacamoleException("Unknown LDAP type on path: " + mountPath +"/" + path); - } + throw new GuacamoleException("Unknown LDAP type on path: " + mountPath + path); + } if (response == null || response.getData() == null) { throw new GuacamoleException("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("checkin", info); } - + return retval; } /** - * Retrieves a username or password from a Databse secret engine. + * 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. @@ -626,10 +606,10 @@ else if (path.startsWith("library/")) { * If the secrets cannot be retrieved from the Vault. */ private Map getValueDB(String mountPath, String path) throws GuacamoleException { - VaultResponse response = vaultTemplate.read(mountPath + path); - + VaultResponse response = vaultTemplate.read(mountPath + path); + if (response == null || response.getData() == null) { - throw new GuacamoleException("No response from Databse secrets engine"); + throw new GuacamoleException("No response from Database secrets engine"); } return response.getData(); diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java index 34780f4aa8..707e1d2e7e 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java @@ -97,8 +97,6 @@ private OpenBaoClient client() { @Override public String canonicalize(String nameComponent) { try { - - // return URLEncoder.encode(nameComponent, "UTF-8"); } @@ -189,7 +187,7 @@ public Future getValue(String token) throws GuacamoleException { // guacamole.properties.vlt. Should probably refuse SSH and LDAP vault // secrets in that case, but the key can be generic without risk String value = client().getValue(token, "", token.substring(0, token.lastIndexOf('/'))); - logger.debug("getValue1 {} : {}", token, value); + return CompletableFuture.completedFuture(value); } @@ -237,7 +235,7 @@ public Future getValue(UserContext userContext, Connectable connectable, String guac_username = userContext.self().getIdentifier(); String key = guac_username + "-" + username + "-" + token.substring(0, token.lastIndexOf('/')); String value = client().getValue(prepareToken(token, userContext, config, new TokenFilter()), username, key); - logger.debug("getValue2 {} : {}", token, value); + return CompletableFuture.completedFuture(value); } @@ -302,17 +300,19 @@ public Map> getTokens(UserContext userContext, } } - // The get on the Future value below will cause the debug code below - // to wait for completion. Only run if in debug mode - if (logger.isDebugEnabled()) { - 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); - }}); - } + // 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); + // }}); + + // Simplier innoucous debugging message + logger.debug("Returning {} Vault tokens: {}", tokens.size(), tokens.keySet()); return tokens; } diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/TtlAwareSessionManager.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/TtlAwareSessionManager.java index 02b969a72c..dddb4eb133 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/TtlAwareSessionManager.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/TtlAwareSessionManager.java @@ -487,7 +487,6 @@ public VaultToken getSessionToken() { * Can be either a Null, VaultToken or a LoginToken */ public void setSessionToken(VaultToken token) { - logger.debug("Setting new token : {}", token.getToken()); if (token != null && !token.getToken().isEmpty()) { this.token.set(token); } From 51335c5a3eb8d417d3a7ff53a7cd659c20ebf45e Mon Sep 17 00:00:00 2001 From: David Bateman Date: Fri, 22 May 2026 09:31:46 +0200 Subject: [PATCH 13/16] GUACAMOLE-2196 Remove debug code in getSecretsEngine --- .../guacamole/vault/openbao/secret/OpenBaoClient.java | 8 -------- 1 file changed, 8 deletions(-) diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java index bde8a96080..a3235fa6f5 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java @@ -267,14 +267,6 @@ public Map getSecretsEngine(String path) { } Map map = Map.of("type", type, "path", String.valueOf(data.get("path"))); cache.put(VAULT_PATH_HELP + path, map); - logger.debug("getSecretsEngine {} : {} : {}", VAULT_PATH_HELP + path, type, String.valueOf(data.get("path"))); - - try { - logger.debug("getSecretsEngine {}", objectMapper.writeValueAsString(data)); - } - catch (JsonProcessingException e) { - logger.info("Error json parsing returned secret: " + e.getMessage()); - } return map; } From c7737527e7598427803e9798ebb5f3a36f42298b Mon Sep 17 00:00:00 2001 From: David Bateman Date: Fri, 22 May 2026 13:46:22 +0200 Subject: [PATCH 14/16] GUACAMOLE-2196: Remove documentation from the PR and move to guacamole-manual --- .../include/openbao-optional.properties.in | 46 - .../docs/include/openbao.properties.in | 6 - .../docs/openbao.md.j2 | 1463 ----------------- 3 files changed, 1515 deletions(-) delete mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao-optional.properties.in delete mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao.properties.in delete mode 100644 extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao-optional.properties.in b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao-optional.properties.in deleted file mode 100644 index 04032072ad..0000000000 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao-optional.properties.in +++ /dev/null @@ -1,46 +0,0 @@ -# A username of an account in the Vault for use with Guacamole -vault-username: guacamole - -# A password for the account in the Vault used by Guacamole -vault-password: my-secret-password - -# The lifetime of the data in the Guacamole cache in milliseconds. -# This is used to avoid multiple concurrent requests for the same -# Vault record with different secret values. After this time the -# secret values are cleared from Guacamole -vault-cache-lifetime: 5000 - -# The maximum time that a request to the vault server can take in -# milliseconds. After this time a null value is returned for the -# secret requested -vault-request-timeout: 5000 - -# The maximum time allowed for a connection to the vault server in -# milliseconds. After this time a null value is returned for the -# secret requested -vault-connection-timeout: 5000 - -# The duration validity of the certificates generated for signed -# certification SSH connections. After this time the signed certificate -# can not be reused for a connection. -# -# It should be noted that in the case of drift of the clock between the -# ssh clients and the Vault server in seconds, a certificate might be -# invalidated incorrectly. This value must be sufficiently large to -# account for clock drift. -vault-ssh-connection-timeout: 1800 - -# The renewal delay for expiring Vault tokens in milliseconds. Tokens -# will be renewed prior to expiration by this delay -vault-token-renewal-delay: 10000 - -# The type of the SSH certificates generated by Guacamole for signed -# SSH certificate access. Valid types are `ed25519` and `rsa`. Only -# ed25519 and 4096-bit RSA certificates are supported. -# -# Please note that if your server is configured for strict FIPS-140 -# compliance, then ed25519 certificates will not be available, and -# Guacamole will fallback to using 4096-bit RSA certificates -# automatically -vault-ssh-type: ed25519 - diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao.properties.in b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao.properties.in deleted file mode 100644 index 7b795bd5fe..0000000000 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/include/openbao.properties.in +++ /dev/null @@ -1,6 +0,0 @@ -# The URI of the OpenBao or Hashicorp Vault server to use. -vault-uri: http://localhost:8200 - -# The authentication token to use to access the vault -vault-token: s.IPzVb3b5dThhjIrBX245szisjTQcylwSjMEeyJqcidaH8Hf - diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 deleted file mode 100644 index a79f0dd438..0000000000 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.md.j2 +++ /dev/null @@ -1,1463 +0,0 @@ -{# vim: set filetype=markdown.jinja : #} -{%- import 'include/ext-macros.md.j2' as ext with context -%} - -# Retrieving Secrets from a Vault - -Guacamole supports retrieving secrets—such as connection-specific passwords, -private keys, certificates, and credentials—from a **Vault**. These secrets are -injected automatically into connection configurations using **parameter tokens** -or into Guacamole configuration properties via an additional Vault-specific -configuration file analogous to `guacamole.properties`. - -This extension supports both **[OpenBao](https://openbao.org)** and -**[HashiCorp Vault](https://www.hashicorp.com/products/vault)**. Throughout this -document, the generic term **Vault** is used to refer to either implementation -unless a distinction is required. - -```{include} include/warn-config-changes.md -``` - -(vault-downloading)= -Installing/Enabling the Vault Extension ---------------------------------------- - -{{ ext.install('openbao', 'guacamole-vault', 'openbao/guacamole-vault-openbao') }} - -(adding-guac-to-vlt)= - -## Adding Guacamole to a Vault - -This section provides concrete examples for setting up the supported secret -engines in Vault for use with Guacamole. To allow an application like Guacamole -to access secrets stored in a Vault, the following steps are required: - -1. **Enable the required secret engines** (e.g., KV, SSH, LDAP, Database). -2. **Create appropriate mount paths** for each secret engine. -3. **Define access policies** to restrict Guacamole’s permissions. -4. **Configure authentication credentials** for Guacamole to authenticate with Vault. - -:::{note} -This document **does not** cover general Vault administration. For details on -creating and managing mount paths, authentication backends, and policies, refer -to the official documentation: - -- [OpenBao Documentation](https://openbao.org/docs) -- [HashiCorp Vault Documentation](https://developer.hashicorp.com/vault/docs) -::: - -All configuration examples below use `**curl**` exclusively to remain agnostic to -the specific Vault implementation (OpenBao or HashiCorp). - -Unless otherwise stated, the supplied commands should work for both OpenBao and -Hashicorp Vault. - -### Vault Connection Information - -Guacamole requires the following to connect to a Vault server: - -- The **network address** of the Vault server (e.g., `https://vault.example.com:8200`). -- **Valid authentication credentials** (e.g., token, username/password, or file-based). - -Vault exposes a standard **HTTP REST API**, and a typical Vault URI looks like: - -``` -https://vault.example.com:8200 -``` - -### Setting Up a Guacamole Access Policy - -Guacamole requires the following permissions in Vault: - -- **Read access** to the secrets it consumes. -- **Limited write access** for certain secret engines (e.g., SSH certificate signing). - -For **security and auditability**, it is **strongly recommended** to create a -**dedicated Vault policy** for Guacamole. Below is an example policy that grants -Guacamole access to supported secret engines, restricted to `guacamole` roles -in each engine. - -#### Example Policy (`guacamole.hcl`) - -```hcl -# Read from key-value secrets dedicated to Guacamole -path "kv/guacamole/*" { - capabilities = ["read"] -} - -# Allow SSH certificate signing for Guacamole -path "ssh/sign/guacamole_cert" { - capabilities = ["update"] -} - -# Allow SSH one-time password generation -path "ssh/creds/guacamole_otp" { - capabilities = ["update"] -} - -# Read/Update for LDAP service credentials -path "ldap/library/guacamole/*" { - capabilities = ["read", "update"] -} - -# Read static/dynamic LDAP credentials -path "ldap/*/guacamole/*" { - capabilities = ["read"] -} - -# Read database credentials -path "db/guacamole/*" { - capabilities = ["read"] -} -``` - -:::{note} -The first matching rule in the policy is used. It is therefore important to -place the specific rules in the policy before the general more lax rules. The -LDAP rules above are an example of this. -::: - -#### Uploading the Policy to Vault - -To upload the policy to Vault, use the following command: - -```bash -export VAULT_TOKEN="your_admin_token" -curl -sS \ - --header "X-Vault-Token: $VAULT_TOKEN" \ - --request PUT \ - --data @guacamole.hcl \ - http://127.0.0.1:8200/v1/sys/policies/acl/guacamole -``` - -:::{note} -- The variable `VAULT_TOKEN`, here and below, contains a valid Vault token withhaving **administrative access**. -- Replace `127.0.0.1:8200` with the address of your Vault server. -- The mount paths in the policy (e.g., `kv/guacamole/*`) are **examples** and should be adapted to match your actual paths. -::: - -## Configuring Secret Engines for Guacamole - -Guacamole supports the following Vault secret engines: - -- **[Key-Value (KV)](https://openbao.org/docs/secrets/kv/kv-v2)**: For static secrets like passwords, certificates, and unmanaged account credentials. -- **[SSH](https://openbao.org/docs/secrets/ssh)**: For signed SSH certificates or one-time passwords (OTP). -- **[LDAP](https://openbao.org/docs/secrets/ldap)**: For dynamic or static LDAP credentials. -- **[Database](https://openbao.org/docs/secrets/databases)**: For dynamic database credentials. - -Below are detailed instructions for configuring each engine. - - -### Creating a Key-Value Store for Guacamole - -The **Key-Value (KV) secret engine** is the simplest way to store and retrieve -secrets in Vault. It can store arbitrary key-value pairs, such as usernames, -passwords, or certificates. The KV engine comes in two versions: - -- **KV v1**: Stores only the latest values. -- **KV v2**: Stores metadata (e.g., version history, last modified time) alongside the values. - -:::{warning} -The KV secret engine **does not support secret rotation**. If you use it to store passwords, -you are **entirely responsible** for adhering to corporate password policies (e.g., manual rotation). -::: - -#### Enabling the KV v1 Secret Engine - -To enable the **KV v1** secret engine on a mount path named `kv1`, use the -following command: - -```bash -curl -s \ - --header "X-Vault-Token: $VAULT_TOKEN" \ - --header "Content-Type: application/json" \ - --request POST \ - --data '{"type": "kv", "options": {"version": "1"}}' \ - http://127.0.0.1:8200/v1/sys/mounts/kv1 -``` - -#### Enabling the KV v2 Secret Engine - -To enable the **KV v2** secret engine on a mount path named `kv2`, use the -following command: - -```bash -curl -s \ - --header "X-Vault-Token: $VAULT_TOKEN" \ - --header "Content-Type: application/json" \ - --request POST \ - --data '{"type": "kv", "options": {"version": "2"}}' \ - http://127.0.0.1:8200/v1/sys/mounts/kv2 -``` - -#### Adding a Secret to KV v1 - -To add a secret (e.g., username and password for a connection named `batman`) -to the **KV v1** engine: - -```bash -curl -s \ - --header "X-Vault-Token: $VAULT_TOKEN" \ - --header "Content-Type: application/json" \ - --request POST \ - --data '{"username": "bruce", "password": "wayne"}' \ - http://127.0.0.1:8200/v1/kv1/guacamole/batman -``` - -Here: - -- The **path** of the secret is `guacamole/batman`. -- The stored secrets are `username` and `password`. You can use any keys you like. - -#### Adding a Secret to KV v2 - -To add the same secret to the **KV v2** engine, use the following command -(note the `/data/` prefix): - -```bash -curl -s \ - --header "X-Vault-Token: $VAULT_TOKEN" \ - --header "Content-Type: application/json" \ - --request POST \ - --data '{"data": {"username": "bruce", "password": "wayne"}}' \ - http://127.0.0.1:8200/v1/kv2/data/guacamole/batman -``` - -:::{note} -Although the KV v2 requires the `/data/` prefix in API paths (e.g. -`kv2/data/guacamole/batman`). Guacamole handles this internally, so you **do -not** need to include `/data/` in your token paths. -::: - -:::{tip} -Use **complex paths** (e.g., `guacamole/production/batman`) to organize secrets -by environment, team, or application. -::: - -### Creating an SSH Secret Engine for Guacamole - -The **SSH secret engine** allows Vault to generate: - -1. **Signed SSH certificates** (recommended). -2. **One-time passwords (OTP)**. - -Guacamole supports both methods, but **signed certificates** are the more modern and secure approach. - -#### Enabling the SSH Secret Engine - -To enable the SSH secret engine on a mount path named `ssh`: - -```bash -curl -s \ - --header "X-Vault-Token: $VAULT_TOKEN" \ - --header "Content-Type: application/json" \ - --request POST \ - --data '{"type": "ssh"}' \ - http://127.0.0.1:8200/v1/sys/mounts/ssh -``` - -#### Generating a CA for Certificate Signing - -For **signed SSH certificates**, Vault needs a **Certificate Authority (CA)**. -You can either: - -- Upload an existing CA certificate and private key. -- Let Vault generate a new CA. - -To generate a new CA in Vault: - -```bash -curl -s \ - --header "X-Vault-Token: $VAULT_TOKEN" \ - --header "Content-Type: application/json" \ - --request POST \ - --data '{"generate_signing_key": true}' \ - http://127.0.0.1:8200/v1/ssh/config/ca -``` - -The response will include a `public_key`. **Save this key** to -`/etc/ssh/trusted_user_ca.pem` on all target machines where Guacamole will -connect via SSH. - -#### Configuring SSH to Trust the CA - -1. On each target machine, add the following line to `/etc/ssh/sshd_config`: -``` - TrustedUserCAKeys /etc/ssh/trusted_user_ca.pem -``` -2. Reload the SSH daemon to apply the changes: -```bash - pkill -SIGHUP /usr/sbin/sshd -``` - -:::{note} -This sends a `SIGHUP` signal to the SSH daemon, forcing it to reload its -configuration **without interrupting existing connections**. -::: - -#### Creating a Signing Role for Guacamole - -Guacamole requires the `permit-pty` option for SSH sessions. To create a -signing role named `guacamole_cert`: - -```bash -curl -sS \ - --header "X-Vault-Token: $VAULT_TOKEN" \ - --header "Content-Type: application/json" \ - --request POST \ - --data '{ - "algorithm_signer": "rsa-sha2-256", - "allow_user_certificates": true, - "allowed_users": "*", - "key_type": "ca", - "max_ttl": "30m", - "allowed_extensions": "permit-pty" - }' \ - http://127.0.0.1:8200/v1/ssh/roles/guacamole_cert -``` - -:::{note} -- `max_ttl`: Limits the lifetime of signed certificates to **30 minutes**. Adjust this value based on: - - Network latency between Guacamole and the target machines. - - Clock drift between the Vault server and SSH clients. -- `permit-pty`: Required for Guacamole to allocate a pseudo-terminal (PTY) for SSH sessions. -::: - -#### Creating an OTP Role for Guacamole - -For **one-time passwords (OTP)**, create a role named `guacamole_otp` for a -default user (e.g., `testuser`): - -```bash -curl -s \ - --header "X-Vault-Token: $VAULT_TOKEN" \ - --header "Content-Type: application/json" \ - --request POST \ - --data '{"key_type": "otp", "default_user": "testuser"}' \ - http://127.0.0.1:8200/v1/ssh/roles/guacamole_otp -``` - -:::{warning} -- Each OTP role is **tied to a single username**. If you need OTPs for multiple - users, create **separate roles** for each user. -- Vault’s OTP engine **does not support** specifying the client IP address. - Since Guacamole’s SSH connections originate from the `guacd` daemon (and - Guacamole’s Vault extension cannot access the IP of `guacd`), you must - disable IP restrictions for the OTP role, by not specifying a `cidr_list`. -::: - -To allow the `guacamole_otp` role to be used **without IP restrictions**, run: - -```bash -curl -s \ - --header "X-Vault-Token: $VAULT_TOKEN" \ - --header "Content-Type: application/json" \ - --request POST \ - --data '{"roles": "guacamole"}' \ - http://127.0.0.1:8200/v1/ssh/config/zeroaddress -``` - -#### Configuring Target Machines for OTP - -To use **OTP authentication**, the target machines must be configured to -validate OTPs via **PAM (Pluggable Authentication Modules)**. - -1. **Modify `/etc/pam.d/sshd**` to include the following line at the **top** of the file: -``` - auth sufficient pam_exec.so expose_authtok /usr/local/bin/verify_otp.sh -``` -:::{note} -This line tells PAM to use the `verify_otp.sh` script to validate OTPs. The -`expose_authtok` option passes the OTP to the script. -::: - -2. **Create the OTP Verification Script** (`/usr/local/bin/verify_otp.sh`): -```bash - #!/bin/sh - set -u - IFS= read -r OTP - USERNAME="${PAM_USER}" - [ -z "${OTP}" ] && exit 1 - [ -z "${USERNAME}" ] && exit 2 - RESPONSE=$(curl -s \ - --fail \ - --request POST \ - --header "Content-Type: application/json" \ - --data "{\"otp\": \"${OTP}\"}" \ - http://127.0.0.1:8200/v1/ssh/verify) - [ "$?" -eq 0 ] || exit 3 - [ "$(echo $RESPONSE | jq -r .data.username)" = "$USERNAME" ] || exit 4 -``` - -3. **Make the Script Executable**: -```bash - chmod 755 /usr/local/bin/verify_otp.sh -``` - -:::{warning} -Using a **shell script for authentication is not a best practice** for -production environments. Instead, use the **[Hashicorp Vault SSH Helper](https://github.com/hashicorp/vault-ssh-helper)**, -which is designed for this purpose and integrates directly with OpenSSH. -::: - -### Creating an LDAP Secret Engine for Guacamole - -The **LDAP secret engine** allows Vault to manage credentials for LDAP -directories, such as **OpenLDAP** or **Microsoft Active Directory**. -Guacamole can use these credentials for connections that authenticate against -LDAP. - -:::{note} -Vault **rotates its own LDAP password** automatically. For this reason, it is -**highly recommended** to use a **dedicated LDAP account** for Vault (e.g. -`uid=vault,ou=users,dc=example,dc=com`). -::: - -#### Prerequisites for LDAP - -1. **Generate a Password Hash**: -For modern LDAP servers (e.g., OpenLDAP with Argon2 support), generate a password hash like this: - -``` -$ slappasswd -o module-load=argon2.so -h '{ARGON2}' -s your_secret_password_here -{ARGON2}$argon2id$v=19$m=7168,t=5,p=1$4JbzrJjMiH8x6Qfe5WsNtA$n5tzU8dGW2lFE+KDJRCLImKK+feE+mZaBmDP7nE9fyI -``` - -Replace `your_secret_password_here` with the actual password for the Vault LDAP account. - -2. **Create a Dedicated LDAP Account for Vault (file: adduser.ldif) **: - - This account should have permissions to: - - **Read** user attributes (for static roles). - - **Modify passwords** (for dynamic roles or static roles with rotation). - - Example LDIF for creating a Vault LDAP account (using the above Argon2 password hash): -```ldif -dn: uid=vault,ou=users,dc=example,dc=com -objectClass: top -objectClass: person -objectClass: organizationalPerson -objectClass: inetOrgPerson -uid: vault -cn: Vault User -sn: Vault -displayName: Vault -description: Account used by Vault Server -userPassword: {ARGON2}$argon2id$v=19$m=7168,t=5,p=1$4JbzrJjMiH8x6Qfe5WsNtA$n5tzU8dGW2lFE+KDJRCLImKK+feE+mZaBmDP7nE9fyI -``` - -3. **Add the LDAP Account to the Directory**: -```bash -ldapadd -x -D "cn=admin,dc=example,dc=com" -W -f adduser.ldif -``` -4. **Grant Vault Permissions to Modify Passwords**: -Modify the LDAP ACLs to allow the Vault account to read and modify the -`userPassword` attribute. Example LDIF for OpenLDAP: - -```ldif -dn: olcDatabase={1}mdb,cn=config -changetype: modify -add: olcAccess -olcAccess: {0}to attrs=userPassword - by dn.exact="uid=vault,ou=users,dc=example,dc=com" write - by self write - by anonymous auth - by dn.base="cn=admin,dc=example,dc=com" manage - by * none -``` - -:::{warning} -Extreme care must be taken when modifying the permissions to access the LDAP. -The risk of removing all administrative access the the LDAP server exists. -Please respect any existing olcAccess attributes and double check any changes -you make. -::: - -```bash -ldapmodify x -D "cn=admin,cn=config" -W -f userpassword.ldif -``` -:::{warning} -The above rule grants the Vault server **write access to all passwords** in -the `ou=users,dc=example,dc=com` subtree. **This is not secure for production!** -**Best Practice**: Isolate Vault-managed accounts in a **dedicated DN** -(e.g., `ou=vault,dc=example,dc=com`) and restrict write permissions to this DN -only. Example: - -```ldif -dn: olcDatabase={2}mdb,cn=config -changetype: modify -add: olcAccess -olcAccess: {0}to dn.subtree="ou=vault,dc=example,dc=com" attrs=userPassword - by dn.exact="uid=vault,ou=users,dc=example,dc=com" manage - by anonymous auth - by dn.base="cn=admin,dc=example,dc=com" manage - by * none -``` - -This rule **must appear before** any generic `userPassword` rules to take effect. -::: - -5. **For Dynamic LDAP Roles**: -If you plan to use **dynamic LDAP roles**, Vault needs permissions to **create -and delete accounts** in the LDAP directory. Example LDIF: - -```ldif -dn: olcDatabase={1}mdb,cn=config -changetype: modify -add: olcAccess -olcAccess: {3}to dn.subtree="dc=example,dc=com" - by dn.exact="uid=vault,ou=users,dc=example,dc=com" write -``` - -#### Configuring the LDAP Secret Engine in Vault - -1. **Create a Password Policy for LDAP**: -Vault can generate random passwords for LDAP accounts based on a **password -policy**. Example policy (`ldap-policy.hcl`): - -```hcl -length = 20 - -rule "charset" { - charset = "abcdefghijklmnopqrstuvwxyz" - min_chars = 1 -} - -rule "charset" { - charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" - min_chars = 1 -} - -rule "charset" { - charset = "0123456789" - min_chars = 1 -} - -rule "charset" { - charset = "!@#%^&*" - min_chars = 1 -``` - -2. **Upload the policy to Vault:** - -``bash -curl -s --header "X-Vault-Token: $VAULT_TOKEN" \ - --request POST --data-urlencode "policy@ldap-policy.hcl" \ - http://127.0.0.1:8200/v1/sys/policies/password/ldap-policy -``` - -3. **Enable the LDAP secrets engine**: - -To enable the database secret engine on a mount path named `db`: - -```bash -curl -s \ - --header "X-Vault-Token: $VAULT_TOKEN" \ - --header "Content-Type: application/json" \ - --request POST \ - --data '{"type": "ldap"}' \ - http://127.0.0.1:8200/v1/sys/mounts/ldap -`` - -4. **Configure the LDAP Connection in Vault**: - - ```bash - curl -s \ - --header "X-Vault-Token: $VAULT_TOKEN" \ - --header "Content-Type: application/json" \ - --request PUT \ - --data '{ - "schema": "openldap", - "url": "ldaps://ldap.example.com", - "binddn": "uid=vault,ou=users,dc=example,dc=com", - "bindpass": "your_secret_password_here", - "userdn": "ou=users,dc=example,dc=com", - "userattr": "uid", - "password_policy": "ldap-policy", - "insecure_tls": false - }' \ - http://127.0.0.1:8200/v1/ldap/config - ``` -:::{warning} -The `insecure_tls: false` option is **not secure** for production. Instead: -- Ensure your LDAP server uses **TLS with a valid certificate**. -- Specify the CA certificate using the `tls_ca_cert` or `tls_ca_cert_file` options. -::: - -5. **Test the LDAP Connection**: -Before proceeding, verify that Vault can communicate with the LDAP server by -forcing a password rotation: - -```bash -curl -s \ - --header "X-Vault-Token: $VAULT_TOKEN" \ - --request POST \ - http://127.0.0.1:8200/v1/ldap/rotate-root -``` - -If the command succeeds, Vault has successfully rotated its LDAP password and -can communicate with the LDAP server. - -#### Creating a Static LDAP Role for Guacamole - -Static roles use **existing LDAP accounts** and rotate their passwords -automatically. The syntax is different here depending on whether you are -using OpenBao or Hashicorp Vault. - -**For HashiCorp Vault**: - -```bash -curl -s \ - --header "X-Vault-Token: $VAULT_TOKEN" \ - --header "Content-Type: application/json" \ - --request POST \ - --data '{ - "role_name": "guacamole", - "username": "guacamole", - "dn": "cn=guacamole,ou=users,dc=example,dc=com", - "rotation_period": "1h" - }' \ - http://127.0.0.1:8200/v1/ldap/static-role -``` - -**For OpenBao**: - -```bash -curl -s \ - --header "X-Vault-Token: $VAULT_TOKEN" \ - --header "Content-Type: application/json" \ - --request POST \ - --data '{ - "username": "guacamole", - "dn": "cn=guacamole,ou=users,dc=example,dc=com", - "rotation_period": "1h" - }' \ - http://127.0.0.1:8200/v1/ldap/static-role/guacamole -``` - -:::{note} -- Guacamole **does not** manage LDAP lease revocation. Rotated credentials - remain valid until manually revoked or rotated again by Vault. -- The `rotation_period` specifies how often Vault rotates the password for - the LDAP account (e.g., every 1 hour). -::: - -#### Creating a Dynamic LDAP Role for Guacamole - -To enable **dynamic LDAP account creation** in Vault for Guacamole, you need -to configure Vault to **automatically create, delete, and roll back LDAP -accounts** on demand. This is useful for scenarios where you want Vault to -**temporarily provision LDAP accounts** for Guacamole connections and -**automatically clean them up** after use. - -Dynamic roles require **three LDIF files** to define how Vault interacts with -your LDAP directory: - -`**creation.ldif**`: Defines how new accounts are created. -`**deletion.ldif**`: Defines how accounts are deleted. -`**rollback.ldif**`: Defines how to revert changes if account creation fails (often identical to `deletion.ldif`). - -:::{note} -The exact content of these files **depends on your LDAP schema and directory -structure**. The examples below are for **Unix-like accounts** (e.g. -`inetOrgPerson`, `posixAccount`) and will need to be adapted to match your -environment. -::: - -1. **Create the `creation.ldif` File** - -The `creation.ldif` file defines the **attributes of new LDAP accounts** -created by Vault. Below is an example for creating **Unix user accounts** in an -OpenLDAP directory: - -```ldif -dn: uid={{.Username}},ou=users,dc=example,dc=com -objectClass: inetOrgPerson -objectClass: posixAccount -objectClass: shadowAccount -uid: {{.Username}} -cn: {{.Username}} -sn: {{.Username}} -gidNumber: 50 -homeDirectory: /home/{{.Username}} -loginShell: /bin/sh -userPassword: {{.Password}} -``` - -:::{note} -- `**{{.Username}}**` and `**{{.Password}}**` are **placeholders** that Vault - replaces with dynamically generated values. -- The `**uidNumber**` attribute is **intentionally omitted** in this example. - Vault **does not** automatically increment `uidNumber` values. If you need unique - `uidNumber` values, consider: - - Using the `**autonumber` overlay** in OpenLDAP to auto-assign `uidNumber`. - - Pre-defining a `uidNumber` value knowing all dynamic accounts will reuse it. -::: - -:::{warning} -- The `**userPassword**` field is provided in **plaintext** in this LDIF file. To - ensure the password is **hashed before storage**, configure your LDAP server with: - -```ldif -olcPPolicyHashCleartext: TRUE -``` - - This ensures the LDAP server **hashes the password** before storing it in the - directory. -::: - -2. **Create the `deletion.ldif` and `rollback.ldif` Files** - -The `deletion.ldif` and `rollback.ldif` files define how Vault **removes -accounts** from the LDAP directory. In most cases, these files are -**identical**, as rolling back a failed creation typically involves deleting -the account. - -Example `deletion.ldif` (and `rollback.ldif`): - -```ldif -dn: uid={{.Username}},ou=users,dc=example,dc=com -changetype: delete -``` - -:::{note} -- The `**changetype: delete**` directive tells LDAP to **remove the entire - entry** matching the `dn`. -- The `**{{.Username}}**` placeholder is replaced with the username of the - account to delete. -::: - -3. **Configure LDAP Permissions for Dynamic Roles** - -For Vault to **create and delete accounts**, it needs **sufficient -permissions** in your LDAP directory. Specifically: - -- **Write access** to the **DN subtree** where accounts will be created (e.g. - `ou=users,dc=example,dc=com`). -- **Write access** for the `userPassword` attribute - -To grant Vault **full write access** to a subtree (e.g., `ou=dynamic,dc=example,dc=com`), use the following LDIF: - -```ldif -dn: olcDatabase={1}mdb,cn=config -changetype: modify -add: olcAccess -olcAccess: {2}to dn.subtree="ou=dynamic,dc=example,dc=com" - by dn.exact="uid=vault,ou=users,dc=example,dc=com" write - by * none -``` - -:::{warning} -Granting **write access** to a broad subtree (e.g., `ou=users,dc=example,dc=com`) -can be a **security risk**. **Best practice** is to: - -- **Isolate dynamic accounts** in a dedicated subtree (e.g., `ou=dynamic,dc=example,dc=com`). -- **Restrict Vault’s permissions** to only this subtree. -::: - -4. **Create the Dynamic Role in Vault** - -Once the LDIF files are ready, create a **dynamic role** in Vault using the -`curl` command below. This role defines how Vault generates and manages dynamic -LDAP accounts for Guacamole. - -```bash -curl -s \ - --header "X-Vault-Token: $VAULT_TOKEN" \ - --header "Content-Type: application/json" \ - --request POST \ - --data '{ - "creation_ldif": "dn: uid={{.Username}},ou=users,dc=example,dc=com\nobjectClass: inetOrgPerson\nobjectClass: posixAccount\nobjectClass: shadowAccount\nuid: {{.Username}}\ncn: {{.Username}}\nsn: {{.Username}}\ngidNumber: 50\nhomeDirectory: /home/{{.Username}}\nloginShell: /bin/sh\nuserPassword: {{.Password}}", - "deletion_ldif": "dn: uid={{.Username}},ou=users,dc=example,dc=com\nchangetype: delete", - "rollback_ldif": "dn: uid={{.Username}},ou=users,dc=example,dc=com\nchangetype: delete", - "username_template": "v_{{.RoleName}}_{{random 6}}", - "dn": "uid=vault,ou=users,dc=example,dc=com", - "default_ttl": "1h", - "max_ttl": "24h" - }' \ - http://127.0.0.1:8200/v1/ldap/roles/guacamole -``` - -5. Test the Dynamic Role - -After creating the role, test it by **requesting a dynamic LDAP credential** -from Vault: - -```bash -curl -s \ - --header "X-Vault-Token: $VAULT_TOKEN" \ - http://127.0.0.1:8200/v1/ldap/creds/guacamole -``` - -The response will include: - -- A **dynamically generated username** (e.g., `v_guacamole_abc123`). -- A **password** for the account. -- The **TTL** (time-to-live) of the credential. - -:::{note} -- The account will be **automatically deleted** from LDAP after the `default_ttl` - (1 hour in the example). -- You can **manually revoke** the credential by deleting its lease in Vault. -::: - -#### Creating a LDAP Service Accounts for Guacamole - -Before continuing you should confirm that the Vault LDAP configuration -includes valid values for `userdn` and `userattr`, with a command like: - -```bash -curl -s \ - --header "X-Vault-Token: $VAULT_TOKEN" \ - --header "Content-Type: application/json" \ - http://127.0.0.1:8200/v1/ldap/config | \ - jq '.data.userdn, .data.userattr' -``` -This should produce something like - -``` -"ou=users,dc=example,dc=com" -"uid" -``` -This means that user records in your LDAP will be of the form -`uid=testuser,ou=users,dc=example,dc=com`. If this is not the case -the LDAP secret engine should be reconfigured before continuing - -Give the returned value of `userattr`, Vault will search with the account -`uid` values. To create a LDAP service account in the Vault we can then -do something like - -```bash -curl -s \ - --header "X-Vault-Token: $VAULT_TOKEN" \ - --header "Content-Type: application/json" \ - --request POST \ - --data '{ - "ttl": "2h", - "max_ttl": "24h", - "disable_checkin_enforcement": "true", - "service_account_names": "user1,user2" - }' \ - http://127.0.0.1:8200/v1/ldap/library/guacamole -``` - -where `user1` and `user2` and valid existing user accounts managed by the -Vault. - -:::{note} -- Vault will change the password of these accounts on check-out and so these - account should be dedicated to Vault. -- Guacamole requests a TTL of 2 hours. If the role is configured with a shorter - TTL, the account will be assumed to be checked in after this shorter delay. -::: - -:::{warning} -At the moment Guacamole does not guarantee that the account will be checked in -when the connection is terminated, though it does attempt to do so. The pool of -service accounts available should take this into account and assume the check-in -will happen at the end of the 2 hours TTL. -::: - -### Creating a Database Secret Engine for Guacamole - -The **Database secret engine** allows Vault to generate **dynamic database -credentials** for Guacamole. This is useful for connections that require -database access (e.g., PostgreSQL, MySQL). - -#### Prerequisites for Database Engine - -1. **Create a Dedicated Database Role for Vault**: - - This role should have permissions to: - - **Create new roles** (for dynamic credentials). - - **Modify passwords** (for rotation). - - **Revoke roles** (to remove dynamic credentials) - - **Grant permission on tables** (to dynamic credentials to alter the tables) - -This poses a problem, of ownership on the tables in the database. Using -Postgresql you could use - -```sql -CREATE ROLE vault_admin WITH LOGIN PASSWORD 'secure_password_here' CREATEROLE NOINHERIT; -GRANT CREATE ON DATABASE guacamole_db TO vault_admin; -GRANT USAGE ON SCHEMA public TO vault_admin; -``` - -if and only if vault_admin is owner of the tables in the database. Otherwise the -role needs to be a `superuser` and created like - -```sql -CREATE ROLE vault_admin WITH LOGIN PASSWORD 'secure_password_here' SUPERUSER NOINHERIT; -``` - -2. **Apply the SQL Commands**: - Save the above SQL to a file (e.g., `create_vault_role.sql`) and run it: - -```bash -cat create_vault_role.sql | psql --dbname "guacamole_db" -``` - -#### Enabling the Database Secret Engine - -To enable the database secret engine on a mount path named `db`: - -```bash -curl -s \ - --header "X-Vault-Token: $VAULT_TOKEN" \ - --header "Content-Type: application/json" \ - --request POST \ - --data '{"type": "database"}' \ - http://127.0.0.1:8200/v1/sys/mounts/db -``` - -#### Creating a Dynamic Database Role for Guacamole - -To create a role that generates **dynamic credentials** for Guacamole: - -```bash -curl -s \ - --header "X-Vault-Token: $VAULT_TOKEN" \ - --header "Content-Type: application/json" \ - --request POST \ - --data '{ - "db_name": "guacamole_db", - "default_ttl": "1h", - "max_ttl": "24h", - "creation_statements": [ - "CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '\''{{password}}'\'' VALID UNTIL '\''{{expiration}}'\'';", - "GRANT SELECT,INSERT,UPDATE,DELETE ON ALL TABLES IN SCHEMA public TO \"{{name}}\";", - "GRANT SELECT,USAGE ON ALL SEQUENCES IN SCHEMA public TO \"{{name}}\";", - "GRANT ALL ON SCHEMA public TO \"{{name}}\";" - ], - "renew_statements": [ - "ALTER ROLE \"{{name}}\" WITH PASSWORD '\''{{password}}'\'' VALID UNTIL '\''{{expiration}}'\'';" - ], - "revocation_statements": [ - "REASSIGN OWNED BY \"{{name}}\" TO CURRENT_USER;", - "DROP OWNED BY \"{{name}}\";", - "DROP ROLE IF EXISTS \"{{name}}\";" - ] - }' \ - http://127.0.0.1:8200/v1/db/roles/guacamole -``` - -:::{note} -- `default_ttl`: Default time-to-live for generated credentials (e.g., 1 hour). -- `max_ttl`: Maximum time-to-live for generated credentials (e.g., 24 hours). -- `create_statements`: SQL commands to create a new database role with the - generated credentials. -- `rotation_statements`: SQL commands to rotate the password for an existing - role. -- `{{name}}` and `{{password}}`: Placeholders for the dynamically generated - username and password. -::: - -#### Creating a Static Database Role for Guacamole - -If you prefer to use a **static username** (e.g., `guacamole_user`) instead of -dynamic usernames, modify the role as follows: - -```bash -curl -s \ - --header "X-Vault-Token: $VAULT_TOKEN" \ - --header "Content-Type: application/json" \ - --request POST \ - --data '{ - "db_name": "guacamole_db", - "default_ttl": "1h", - "max_ttl": "24h", - "creation_statements": [ - "CREATE ROLE \"guacamole_user\" WITH LOGIN PASSWORD '\''{{password}}'\'' VALID UNTIL '\''{{expiration}}'\'';", - "GRANT SELECT,INSERT,UPDATE,DELETE ON ALL TABLES IN SCHEMA public TO \"guacamole_user\";", - "GRANT SELECT,USAGE ON ALL SEQUENCES IN SCHEMA public TO \"guacamole_user\";", - "GRANT ALL ON SCHEMA public TO \"guacamole_user\";" - ], - "renew_statements": [ - "ALTER ROLE \"guacamole_user\" WITH PASSWORD '\''{{password}}'\'';" - ], - "static_username": true - }' \ - http://127.0.0.1:8200/v1/db/roles/guacamole -``` - -#### Configuring the Database Connection - -Configure Vault to connect to your database (e.g., PostgreSQL): - -```bash -curl -s \ - --header "X-Vault-Token: $VAULT_TOKEN" \ - --header "Content-Type: application/json" \ - --request POST \ - --data '{ - "plugin_name": "postgresql-database-plugin", - "connection_url": "postgresql://{{username}}:{{password}}@127.0.0.1:5432/guacamole_db", - "allowed_roles": "guacamole", - "username": "vault_admin", - "password": "secure_password_here", - "password_authentication": "scram-sha-256" - }' \ - http://127.0.0.1:8200/v1/db/config/guacamole_db -``` - -:::{note} -- `connection_url`: The URL template for connecting to the database. - `{{username}}` and `{{password}}` are placeholders that Vault replaces - with dynamic credentials. -- `allowed_roles`: Comma-separated list of roles that can use this connection. -- `username` and `password`: Credentials for the Vault database account (e.g. - `vault_admin`). -::: - -## Authenticating Guacamole to Vault - -Guacamole supports the following authentication methods to Vault: - -| Method | Description | -| --------------------- | ------------------------------------------------------------------------------------------ | -| **Token String** | Static or renewable token provided directly in Guacamole’s configuration. | -| **Token Sink File** | Vault Agent writes a token to a file; Guacamole reads it. Supports complex roles. | -| **Username/Password** | Uses Vault’s `userpass` auth method. Tokens are short-lived and auto-renewed by Guacamole. | - -### Authentication Using a Token String - -Guacamole can authenticate to Vault using a **static token**. This token must: - -- Use a **role with the `guacamole` policy** (as defined earlier). -- **Not expire** (set `ttl: 0`), or Guacamole may lose access after network issues or reboots. - -If expiring tokens are used, Guacamole will renew the token on expiration. -However, in case of network problems this renewal might not succeed in time. -Also, there is a hard limit on the maximum TTL of renewable tokens, that is by -default configured as 32 days in the Vault. After this period the token will fail -to be renewed - -#### Creating a Non-Expiring Token for Guacamole - -```bash -curl -sS \ - --header "X-Vault-Token: $VAULT_TOKEN" \ - --request POST \ - --data '{ - "policies": ["guacamole"], - "ttl": "0", - "renewable": false, - "display_name": "service-guacamole", - "no_parent": true - }' \ - http://127.0.0.1:8200/v1/auth/token/create -``` - -:::{note} -- The `ttl: 0` ensures the token **never expires**. -- The `no_parent: true` option prevents the token from being revoked if its - parent token is revoked. -- The token value is returned in the `.auth.client_token` field of the - response. Use this value in Guacamole’s configuration (e.g., `vault-token` - in `guacamole.properties`). -::: - -### Authentication Using a Token Sink File - -Guacamole **does not** implement complex authentication methods like -**AppRole** directly. However, you can use a **[Vault Agent](https://openbao.org/docs/agent-and-proxy/agent)** -to manage authentication (e.g., AppRole) and write a **standard Vault token** -to a **sink file**. Guacamole can then read this file at startup or when the -token is about to expire. - -#### How It Works - -1. The `vault_token property of **Guacamole** is configured with a location of a file. -2. **Vault Agent** authenticates to Vault (e.g., using AppRole) and writes a - token to this file (e.g., `/etc/guacamole/vault-token`). -3. **Guacamole** reads this file at startup and whenever the token is about to expire. -4. **Vault Agent** automatically renews the token as needed. - -:::{note} -The configuration of Vault Agent is **beyond the scope of this document**. Refer to: - -- [OpenBao Vault Agent Documentation](https://openbao.org/docs/agent-and-proxy/agent) -- [HashiCorp Vault Agent Documentation](https://developer.hashicorp.com/vault/docs/agent-and-proxy/agent) -::: - -This is the best solution for production, but it requires Vault Agent. - -### Authentication Using Username and Password - -If you prefer to authenticate Guacamole to Vault using a **username and -password**, you must first enable the `userpass` authentication method in -Vault. - -#### Enabling the `userpass` Auth Method - -```bash -curl -s \ - --header "X-Vault-Token: $VAULT_TOKEN" \ - --header "Content-Type: application/json" \ - --request POST \ - --data '{"type": "userpass"}' \ - http://127.0.0.1:8200/v1/sys/auth/userpass -``` - -#### Creating a Guacamole User in Vault - -```bash -curl -s \ - --header "X-Vault-Token: $VAULT_TOKEN" \ - --header "Content-Type: application/json" \ - --request POST \ - --data '{ - "password": "strong-password-here", - "policies": ["guacamole"], - "token_ttl": "10m", - "token_max_ttl": "60m" - }' \ - http://127.0.0.1:8200/v1/auth/userpass/users/guacamole -``` - -:::{note} -- The `token_ttl` (10 minutes) is the default lifetime of tokens generated - for this user. -- Guacamole will **automatically renew** the token up to the `token_max_ttl` - (60 minutes). After this, Guacamole will reauthenticate and generate a new - token. -- Vault **cannot** rotate the password for `userpass` users. If you change the - password in Vault, you must also update it in Guacamole’s configuration. -::: - -Vault **cannot** rotate `userpass` passwords, as the user must know the value. -Therefore, the user is responsible for the rotation of this password. - -(guac-vault-config)= - -Required configuration ----------------------- - -{% call ext.config('openbao', required=True) %} - -Strictly speaking, Guacamole requires only `vault-uri`, the address of the Vault -server. The property `vault-token` can be replaced by `vault-username` and -`vault-password`. -{% endcall %} - - -### Additional Configuration Options - -{% call ext.config('openbao-optional') %} -The following additional, optional {{ ext.properties() }} may be set as desired -to tailor the behavior of the Vault support: -{% endcall %} - - -(completing-vault-install)= - -Completing installation ------------------------ - -```{include} include/ext-completing.md -``` - -Retrieving connection secrets from a vault ------------------------------------------- - -Secrets for connection parameters provided by **[parameter tokens](parameter-tokens)** -by Vault all take the form {samp}`${vault://{MOUNT_PATH}/{PATH}/{SECRET}}`, where - -- `{MOUNT_PATH}`: The mount path of the Vault secret engine used (e.g., `kv2`, `ssh`, `ldap`). -- `{PATH}`: The path to the secret record. -- `{SECRET}`: The key within the secret record (e.g., `username`, `password`). - -Guacamole generally uses the values for `{MOUNT_PATH}` and `{PATH}` as used by the -Vault itself. For example if the OpenBao command used to recover a secret record is - -```bash -bao read kv2/guacamole/myaccount -``` - -the **parameter token** in Guacamole will start with `vault://kv2/guacamole/myaccount`. -The exception to this rule is for LDAP Service accounts, where OpenBao would check -out the account with the command - -```bash -bao write -f ldap/library/guacamole/myteam/check-out -``` - -the corresponding Guacamole token will start with `vault://ldap/library/guacamole/myteam`. - -The secret record recovered from the Vault is JSON with both data -and metadata. Guacamole requires that `{SECRET}` corresponds to one of the -parameters in a data block. For example the command - -```bash -curl --header "X-Vault-Token: $VAULT_TOKEN" http://127.0.0.1:8200/v1/kv2/secrets/my-secret | jq -``` -will return the JSON - -```json -{ - "data": { - "data": { - "foo": "bar" - }, - "metadata": { - "created_time": "2018-03-22T02:24:06.945319214Z", - "custom_metadata": { - "owner": "jdoe", - "mission_critical": "false" - }, - "deletion_time": "", - "destroyed": false, - "version": 2 - } - } -} -``` - -and the only possible value of `{SECRET}` for this secret record is `foo`. -So for this case the only possible **parameter token** is -{samp}`${vault://kv2/secrets/my-secret/foo}`. - -#### Mount Path Detection - -Guacamole **automatically detects** all available mount paths on the Vault -server and their types. The selection of which secret engine Guacamole uses is -based on the **longest matching prefix** of the `{MOUNT_PATH}` in the token. - -For example, if you have two mount paths: - -- `subpath1` -- `subpath1/subpath2` - -A token with `{MOUNT_PATH}=subpath1/subpath2` will use the secret engine -mounted at `subpath1/subpath2`. - -#### Secret Engines and Paths Supported - -The following Vault secret engines are supported by Guacamole: - -##### LDAP Secret Engine - -The Vault LDAP secret engine supports both **open-source LDAP servers** (e.g., OpenLDAP) and **Microsoft Active Directory**. There are three types of LDAP accounts in Vault: - -1. **Static roles**: Use a **fixed username** (e.g., `guacamole`). Vault rotates the password automatically. -2. **Dynamic roles**: Vault **creates and deletes** an account for each request. -3. **Service roles**: Vault manages a **pool of accounts** that can be reused. - -Guacamole simplifies the token format for LDAP paths as follows: - -``` -${VAULT:{MOUNT_PATH}/{static-role|static-cred|creds|library}/{ROLE}/{username|password}} -``` - -An example of a static credential for OpenBao is of the form - -``` -${vault://{MOUNT_PATH}/static-cred/{ROLE}/{username/password}} -``` - -For dynamic credentials both OpenBao and Hashicorp use the same paths, which are -of the form - -``` -${vault://{MOUNT_PATH}/creds/{ROLE}/{username/password}} -``` - -Guacamole simplifies the tokens for the LDAP paths in the following manner - -``` -${vault://{MOUNT_PATH}/library/{ROLE}/{username/password}} -``` - -An example of a valid token might then be `${vault://ldap/creds/guacamole/username}`, -where the value of `ROLE` here is `guacamole`. - -Please note that Guacamole does not touch the lease times associated with these accounts -in the vault and it does not check in the service accounts after use. - - -:::{warning} -Guacamole attempts to check-in the service accounts when the tunnel is closed. -However, a maximum lease time of the service accounts of 2 hours is imposed by -Guacamole and afterthis time the Vault will automatically assume the account is -again available. -::: - -##### SSH Secret Engine - -The Vault SSH secret engine supports: - -1. **One-time passwords (OTP)**. -2. **Signed certificates**. - -Guacamole supports both methods, but **signed certificates are recommended** -for security and ease of use. - -###### Tokens for One-Time Passwords (OTP) - -Tokens for OTP take the form: - -``` -${VAULT:{MOUNT_PATH}/creds/{ROLE}/{username|password}} -``` - -Example: - -``` -${VAULT:ssh/otp/myaccount/username} -``` - -Here, `{ROLE}` is `myaccount`. - -:::{note} -OTP authentication requires **client-side configuration** (e.g., PAM and the -`verify_otp.sh` script, as described earlier). -::: - -###### Tokens for Signed Certificates - -Tokens for signed certificates take the form: - -``` -${VAULT:{MOUNT_PATH}/sign/{ROLE}/{public/private}} -``` - -Example: - -``` -${VAULT:ssh/sign/guacamole_cert/public} -``` - -This token returns a **signed SSH public certificate** that Guacamole can -use for authentication. - -:::{note} -The SSH secret engine **does not support** generating certificates directly. -Instead, it **signs** certificates using a CA stored in Vault. The creation -of the temporary certificates is performed by Guacamole itself. - -The username of the connection is a necessary field for the generated -certificate. It can be an explicit value or another token, that will be -resolved before inclusion in the ssh certificate. -::: - -##### Database Secret Engine - -The Vault database secret engine generates **dynamic database credentials** for -Guacamole. Tokens for database credentials take the form: - -``` -${VAULT:{MOUNT_PATH}/creds/{ROLE}/{username|password}} -``` - -Example: - -``` -${VAULT:db/creds/guacamole/username} -``` - -Here, `{ROLE}` is the name of the database role (e.g., `guacamole`). So that -Guacamole can use these values, they must be placed in the -`guacamole.properties.vlt` file. - -:::{note} -- The credentials are **rotated automatically** by Vault based on the - `default_ttl` and `max_ttl` settings of the role. -::: - -##### Key-Value (KV) Secret Engine - -The KV secret engine stores **static secrets** (e.g., passwords, certificates). -Tokens for KV secrets take the form: - -``` -${VAULT:{MOUNT_PATH}/{PATH}/{SECRET}} -``` - -Example: - -``` -${VAULT:kv2/guacamole/batman/password} -``` - -Here: - -- `{MOUNT_PATH}` is the mount path of the KV engine (e.g., `kv2`). -- `{PATH}` is the path to the secret record (e.g., `guacamole/batman`). -- `{SECRET}` is the key within the secret record (e.g., `password`). - -:::{note} -For **KV v2**, Guacamole automatically handles the `/data/` prefix in the API -path. You **do not** need to include `/data/` in your token paths. -::: - -##### sub-token replacement - -In addition, the following sub-tokens can be used with the **parameter token** -to modify its value when it is referenced. These sub-tokens are of the form -`{SUB_TOKEN}` within the **parameter token** itself. If `{SUB_TOKEN}` is valid -literal with the **parameter token**, you can use `$${SUB_TOKEN}` to ensure it -is interpreted as `{SUB_TOKEN}`. The allowed sub-token values are: - -`{GUAC_USERNAME}` -: The value of the token ${GUAC_USERNAME}, the current logged in user, is - substituted into the vault token before it is looked up. - -`{USERNAME}` -: The username field of the connection is substituted into the vault token - before it is looked up. The username of the connection must not be empty - or itself include a token. - -`{HOSTNAME}` -: The hostname field of the connection is substituted into the vault token - before it is looked up. The hostname of the connection must not be empty - or itself include a token. - -`{GATEWAY}` -: Identical to `HOSTNAME`, except that the value of the `gateway-hostname` - parameter is used. This is only applicable to RDP connections. The - `gateway-hostname` of the connection must not be empty or itself include a - token. - -`{GATEWAY_USER}` -: Identical to `USERNAME`, except that the value of the `gateway-username` - parameter is used. This is only applicable to RDP connections. The - `gateway-username` of the connection must not be empty or itself include a - token. - -For example {samp}`${vault://ldap/org/example/{SERVER}/{USERNAME}/password` is -a valid **paramater token**. - -### Manual Mapping of Secrets - -For cases where **automatic injection** is not suitable (e.g., load-balancing -connection groups), you can **manually map** secrets to connections using a -`vault-token-mapping.yml` file. - -#### Example `vault-token-mapping.yml` - -```yaml -WINDOWS_ADMIN_PASSWORD: vault://ldap/static/org/host/account/password -LINUX_SERVER_KEY: vault://kv/org/host/server_key.pem -``` - -(guacamole-properties-vlt)= - -Retrieving configuration properties from a vault ------------------------------------------------- - -Secrets for Guacamole configuration properties are provided through [an -additional file within `GUACAMOLE_HOME` called `guacamole.properties.vlt`](guacamole-properties-vlt). -This file is _identical_ to `guacamole.properties` except that the values of -properties are references to **parameter tokens** in the format as above. - -Secrets can be used for any Guacamole configuration property that isn't -required to configure the Vault support. - -For example, the following `guacamole.properties.vlt` defines both the -`postgresql-username` and `postgresql-password` properties using values from a -role in the Vault database secret engine. - -``` -postgresql-username: vault://db/guacamole_db/username -postgresql-password: vault://db/guacamole_db/password -``` - -The secret engine in this case is mounted on the path `db/` and this secret -engine defines a role `guacamole`. - From 41dbdff41dd77033c5c43b8dcc721420366fabbf Mon Sep 17 00:00:00 2001 From: David Bateman Date: Fri, 22 May 2026 23:41:01 +0200 Subject: [PATCH 15/16] GUACAMOLE-2196: Treat token parameter modifiers - Use TokenFilter pattern matcher directly to detect arbitrary token by matching explicitly the allowed pattern modifiers - Ignore pattern modifies in openbao extension --- .../openbao/secret/OpenBaoSecretService.java | 20 ++++++++------ .../apache/guacamole/token/TokenFilter.java | 27 +++---------------- 2 files changed, 15 insertions(+), 32 deletions(-) diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java index 707e1d2e7e..ae9374201b 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java @@ -78,7 +78,7 @@ public OpenBaoSecretService(Provider openBaoClientProvider) { * Get a Guice cached copy of the OpenBaoClient Singleton * * @return - * A singleton OpenBaoClient instance + * A singleton OpenBaoClient instance */ private OpenBaoClient client() { return openBaoClientProvider.get(); @@ -104,11 +104,11 @@ public String canonicalize(String nameComponent) { throw new UnsupportedOperationException("Unexpected lack of UTF-8 support.", e); } } - + /** * Before going further replace the arguments "{GUAC_USERNAME}","{USERNAME}", * "{HOSTNAME}", "{GATEWAY_USERNAME}" and "{GATEWAY_HOSTNAME}" in the token with - * values supplied in the parameters. + * values supplied in the parameters. * * @param token * A token that might or might not include sub-tokens to be replaced @@ -157,7 +157,7 @@ private String prepareToken(String token, UserContext userContext, GuacamoleConf token = token.replace("{GATEWAY_USER}", filter.filter(gatewayUsername)); } - + return token; } @@ -186,6 +186,7 @@ public Future getValue(String token) throws GuacamoleException { // This function is only called for connection less tokens defined in // guacamole.properties.vlt. Should probably refuse SSH and LDAP vault // secrets in that case, but the key can be generic without risk + token = token.replaceFirst(":(LOWER|UPPER|OPTIONAL)$", ""); String value = client().getValue(token, "", token.substring(0, token.lastIndexOf('/'))); return CompletableFuture.completedFuture(value); @@ -234,6 +235,7 @@ public Future getValue(UserContext userContext, Connectable connectable, String username = config.getParameter("username"); String guac_username = userContext.self().getIdentifier(); String key = guac_username + "-" + username + "-" + token.substring(0, token.lastIndexOf('/')); + token = token.replaceFirst(":(LOWER|UPPER|OPTIONAL)$", ""); String value = client().getValue(prepareToken(token, userContext, config, new TokenFilter()), username, key); return CompletableFuture.completedFuture(value); @@ -280,8 +282,10 @@ public Map> getTokens(UserContext userContext, Map> tokens = new HashMap<>(); Map parameters = config.getParameters(); - Pattern tokenPattern = Pattern.compile("\\$\\{(" + client().VAULT_TOKEN_PREFIX + ".+)\\}"); - + // Remove optional token parameter modifier + Pattern tokenPattern = Pattern.compile("\\$\\{(" + client().VAULT_TOKEN_PREFIX + + ".+?)(\\:(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 @@ -310,8 +314,8 @@ public Map> getTokens(UserContext userContext, // } catch (Exception e) { // logger.debug(" {} => ERROR: {}", k, e); // }}); - - // Simplier innoucous debugging message + + // Simpler, innocuous debugging message logger.debug("Returning {} Vault tokens: {}", tokens.size(), tokens.keySet()); return tokens; 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 e776954e8c..f36959b969 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,7 @@ 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))?\\})"); /** * The index of the capturing group within tokenPattern which matches @@ -78,7 +78,7 @@ public class TokenFilter { * string of the actual modifier for the token. */ private static final int TOKEN_MODIFIER = 6; - + /** * The values of all known tokens. */ @@ -133,27 +133,6 @@ public String getToken(String name) { return tokenValues.get(name); } - /** - * Returns the value of the token with the given name, or null if no such - * token has been set. - * - * @param name - * The name of the token to return. - * - * @param modifier - * A modifier that might or not be part of the token - * - * @return - * The value of the token with the given name, or null if no such - * token exists. - */ - public String getToken(String name, String modifier) { - if (modifier != null && tokenValues.containsKey(name + ":" + modifier)) - return tokenValues.get(name + ":" + modifier); - else - return tokenValues.get(name); - } - /** * Removes the value of the token with the given name. If no such token * exists, this function has no effect. @@ -247,7 +226,7 @@ private String filter(String input, boolean strict) // Pull token value String tokenName = tokenMatcher.group(TOKEN_NAME_GROUP); - String tokenValue = getToken(tokenName, modifier); + String tokenValue = getToken(tokenName); // If token is unknown, interpretation depends on whether // strict mode is enabled From ba0a1aed8b6ae102636f5ba65b6186f4df86fb42 Mon Sep 17 00:00:00 2001 From: David Bateman Date: Wed, 27 May 2026 14:10:08 +0200 Subject: [PATCH 16/16] GUACAMOLE-2196: Better TokenFilter allowing both nested sub-tokens and modifiers --- .../guacamole/vault/openbao/secret/OpenBaoSecretService.java | 5 +++-- .../main/java/org/apache/guacamole/token/TokenFilter.java | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java index ae9374201b..c17f433a88 100644 --- a/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java @@ -283,8 +283,9 @@ public Map> getTokens(UserContext userContext, Map parameters = config.getParameters(); // Remove optional token parameter modifier - Pattern tokenPattern = Pattern.compile("\\$\\{(" + client().VAULT_TOKEN_PREFIX + - ".+?)(\\:(LOWER|UPPER|OPTIONAL))?\\}"); + 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 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 f36959b969..f8e733e8e5 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,8 +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("(.*?)(^|.)(\\$\\{(.*?)(\\:(LOWER|UPPER|OPTIONAL))?\\})"); - + private final Pattern tokenPattern = Pattern.compile("(.*?)(^|.)(\\$\\{((?:[^{}:]|:(?!(?:LOWER|UPPER|OPTIONAL)" + + "(?=\\}))|\\{(?:[^{}]|\\{[^{}]*\\})*\\})+)(:(?:(LOWER|UPPER|OPTIONAL))(?=\\}))?\\})"); /** * The index of the capturing group within tokenPattern which matches * non-token text preceding a possible token.