GUACAMOLE-2196: OpenBao Vault Integration Extension#1143
GUACAMOLE-2196: OpenBao Vault Integration Extension#1143subbareddyalamur wants to merge 2 commits intoapache:mainfrom
Conversation
24da733 to
c0449db
Compare
|
@subbareddyalamur Please:
|
c0449db to
ecca633
Compare
@necouchman Done. |
corentin-soriano
left a comment
There was a problem hiding this comment.
Thank you for this contribution.
I've added a few questions to the code.
| logger.warn("Password field not found in OpenBao response"); | ||
| return null; | ||
|
|
||
| } catch (Exception e) { |
There was a problem hiding this comment.
Could we catch a more specific exception?
| } else { | ||
| logger.warn("Password not found in OpenBao for user: {}", username); | ||
| } | ||
| } catch (Exception e) { |
There was a problem hiding this comment.
Could we catch a more specific exception?
|
|
||
| logger.info("Fetching secret from OpenBao: {}", fullUrl); | ||
|
|
||
| try (CloseableHttpClient httpClient = HttpClients.createDefault()) { |
There was a problem hiding this comment.
Is there a specific reason to re-instantiate an HTTP client for each request?
| String serverUrl = configService.getServerUrl(); | ||
| String token = configService.getToken(); | ||
| String mountPath = configService.getMountPath(); | ||
| String kvVersion = configService.getKvVersion(); |
There was a problem hiding this comment.
What will happen if the URL, path, or token is not defined or is empty?
adb014
left a comment
There was a problem hiding this comment.
I started looking at doing this myself, as it was the one thing missing from Guacamole that prevented integration with our KMS and using guacamole as a low cost bastion. So it was a good thing I looked at the existing PRs before getting too far.
I'm not part of the project, so take or leave my comments as you wish... In any case I'd be very happy to see this integrated with Guacamole, so you have my upvote ;-)
| public static final StringGuacamoleProperty OPENBAO_SERVER_URL = | ||
| new StringGuacamoleProperty() { |
There was a problem hiding this comment.
Shouldn't this return a URIGuacamoleProperty
| * @throws GuacamoleException | ||
| * If the property is not defined in guacamole.properties. | ||
| */ | ||
| public String getServerUrl() throws GuacamoleException { |
| 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; |
There was a problem hiding this comment.
Wouldn't it be better to use "org.springframework.vault" than writing all the code to communicate with openbao yourself ?
There was a problem hiding this comment.
Something like
package org.apache.guacamole.vault.openbao.secret;
import javax.inject.Inject;
import javax.inject.Singleton;
import java.net.URI;
import java.time.Duration;
import java.util.Map;
import org.apache.guacamole.GuacamoleException;
import org.apache.guacamole.GuacamoleServerException;
import org.apache.guacamole.vault.openbao.conf.OpenBaoConfigurationService;
import org.springframework.vault.VaultException;
import org.springframework.vault.authentication.TokenAuthentication;
import org.springframework.vault.client.VaultEndpoint;
import org.springframework.vault.core.VaultKeyValueOperations;
import org.springframework.vault.core.VaultTemplate;
import org.springframework.vault.support.VaultResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
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;
/**
* Vault Key/Value object to retrieve values based on their key
*/
private final VaultKeyValueOperations kvOperations;
/**
* KV.1 ou KV.2 template of teh mount mount
*/
private final VaultTemplate vaultTemplate;
/**
* Complete the instantiantion of the class after injection of confService
*/
@Inject
public init() {
VaultEndpoint endpoint = VaultEndpoint.from(configService.getServerUrl())
);
endpoint.setConnectionTimeout(
Duration.ofMillis(configService.getConnectionTimeout()));
endpoint.setReadTimeout(
Duration.ofMillis(configService.getRequestTimeout()));
this.vaultTemplate = new VaultTemplate(
endpoint,
new TokenAuthentication(configService.getToken())
);
String mountPath = configService.getMountPath();
String kvVersion = configService.getKvVersion();
if ("2".equals(kvVersion)) {
this.kvOperations = vaultTemplate.opsForKeyValue(
mountPath,
VaultKeyValueOperations.KeyValueBackend.KV_2);
}
else {
this.kvOperations = vaultTemplate.opsForKeyValue(
mountPath,
VaultKeyValueOperations.KeyValueBackend.KV_1);
}
}
/**
* Retrieves a secret from OpenBao by username.
*
* @param username
* The Guacamole username to look up in OpenBao.
*
* @return
* The JSON response from OpenBao in the form of a Map.
*
* @throws GuacamoleException
* If the secret cannot be retrieved from OpenBao.
*/
public Map<String, Object> getSecret(String username)
throws GuacamoleException {
try {
logger.info("Fetching secret from OpenBao: key={}", username);
VaultResponse response = kvOperations.get(username);
if (response == null || response.getData() == null) {
throw new GuacamoleServerException(
"Secret not found in OpenBao for username: " + username);
}
return response.getData();
} catch (VaultException e) {
logger.error("Failed to retrieve secret from OpenBao", e);
throw new GuacamoleServerException(
"Failed to retrieve secret from OpenBao", e);
}
}
/**
* Extracts the password field from an OpenBao KV v2 response.
*
* @param response
* The JSON response from OpenBao in the form of a Map<String,Object>.
*
* @return
* The password string, or null if not found.
*/
public String extractPassword(Map<String, Object> sresponse) {
if (response == null) {
return null;
}
Object password = response.get("password");
if (password instanceof String) {
return (String) password;
}
logger.warn("Password field not found in OpenBao secret");
return null;
}
}but completely untested could reimplement your getSecret and extractPassword methods using a Map rather than a JsonObject
There was a problem hiding this comment.
Thanks for the detailed proposal — the Spring Vault version is definitely cleaner for future feature work like AppRole or KV listing.
For this initial contribution I'd like to keep the footprint minimal: the current hand-rolled client only needs httpclient5 + gson (both already familiar dependencies in the Guacamole tree) and doesn't pull in the Spring runtime. Moving to org.springframework.vault is a larger decision that touches dependency policy, so I'd rather propose it as a follow-up PR where it can be discussed on its own merits.
I have, however, addressed the underlying concerns that motivated the suggestion: the HttpClient is now constructed once and reused, exceptions are caught more narrowly, and AppRole authentication is supported.
| /** | ||
| * Gson instance for JSON parsing. | ||
| */ | ||
| private final Gson gson = new Gson(); |
adb014
left a comment
There was a problem hiding this comment.
Propose OpenBaoClient based on org.springframework.vault
| * @throws GuacamoleException | ||
| * If the property is not defined in guacamole.properties. | ||
| */ | ||
| public String getToken() throws GuacamoleException { |
There was a problem hiding this comment.
Wouldn’t it be better to use an openbao roleID and secretID to obtain the token rather than supply it directly? The token typically has a shorter lifetime
There was a problem hiding this comment.
If you do then code in my proposed spring implementation is changed to.
this.vaultTemplate = new VaultTemplate(endpoint,
new AppRoleAuthentication(confService.getRoleID(),
new AppRoleAuthenticationOptions()
.setSecretId(confService.getSecretID())));|
Shouldn't This merge with the PR #1116 as Hashicorp and OpenBao use essentially the same API |
| // 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)) { |
There was a problem hiding this comment.
Only handles '${GUAC_USERNAME}" and "${OPENBAO_SECRET}" so there is only a single key/value pair allowed . This should do something like
static String user_password_subpath = "userpasswords/";
String key;
if (token.toLowerCase().startsWith("openbao:") || ){
key = token.subString(8);
}
else if token.equals("OPENBAO_SECRET") {
key = user_password_subpath + username;
}
if (key) {
...
JsonObject response = openBaoClient.getSecret(key);This would also allow passwords like "${openbao:/path/to/secret}" where the path is under the mount path used. "${OPENBAO_SECRET}" should probably be "${OPENBAO_PASSWORD}" for consistency with "${GAUC_PASSWORD}"
Also not sure we get here with " ${GUAC_USERNAME}" value, or if we do we should respond with an empty string and allow Guacamole itself to replace the value
There was a problem hiding this comment.
I've implemented that additively in this new commit, so arbitrary secret paths are now supported alongside the existing per-user lookup.
I'd like to avoid renaming ${OPENBAO_SECRET} in this PR to "${OPENBAO_PASSWORD}"
| // Add GUAC_USERNAME token (always available) | ||
| tokens.put("GUAC_USERNAME", CompletableFuture.completedFuture(username)); | ||
|
|
||
| // Add OPENBAO_SECRET token (fetch from OpenBao) |
There was a problem hiding this comment.
If you are using org.springramework.vault this should use the VaultKeyValueOperations.list function to first obtain a list of all of the keys under a specific mount point and then obtain a Map<String,String> of the keys. If you add a method
public List<String> listKeys(String path) {
return kvOperations.list(path);
}to my proposed replacement for OpenBaoClient, then you could do something like
like
List<Strings> keys = OpenBaoClient.listKeys("");
Map<String, String> secrets = new HashMap<>();
for (String key : keys) {
Map<String,Object> response = openBaoClient.getSecret(key);
String password = openBaoClient.extractPassword(response);
if (response != null && response.getData() != null) {
Object value = response.getData().get("value");
if (value != null) {
secrets.put(key, value.toString());
}
}
}where keys and the lists keys
adb014
left a comment
There was a problem hiding this comment.
I was a bit mean when I suggested AppRole authentication method.. The AppRole method usually has a third party broker. See the How (and why) to use AppRole correctly in Hashicorp Vault. The idea is that the SecretID is extremely short lived and very limited in the secrets it can access and generated for each request of Guacamole to OpenBao.
What you've implemented in fact assumes that the SecretID has a TTL of zero, has access to all of the secrets needed by Guacamole, and is used directly by Guacamole without a Broker. Essentially you've recreated the Username/Password authentication method with AppRole. On reflection that's not a bad thing, but in that case use username/password instead of AppRole.
Here is what I know think should be done done
- Guacamole should only support a Token and Username/Password authentication method
- The username/password authentication method you supplied can be used entirely within Guacamole, including leasing management on the token renewals
- The token authenication should allow either a token directly in guacamole.properties and this token must have a TTL of zero (The token from the sky in the above article), but also that the token is read from a file.
- This file should be written to be by a Vault Agent with the tokens based on the AppRole.. Guacamole doesn't need to known about the AppRole at all.
- The token read from the file should be possible to have a non zero TTL. Guacamole should be aware of the TTL and if a request would fail due to an expired TTL it should reread the file that will have been updated by the agent and reauthenticate. This could be done with a token lifecycle management in org.springframework.vault for example
I've got a lot of this code written, but completely untested at this point. I didn't want to send it till it was tested a bit, but as you are working on this at the same time as me, I should share what I have to spread the effort... I'll tidy my code up a bit and share it with you at least for discussion
|
I won't make a pull request of this yet, because it is very much a work in progress, but the "openbao" branch of my fork has what I think this module should look like.. At this point it compiles, but its completely untested (I haven't even run it once). But I wanted to get it out there for discussion with you. My version can use Guacamole tokens of the form
The type of secret engine is automatically detected by reading "sys/mounts" from the vault. So the role assigned to Guacamole needs read access in the vault to "sys/mounts". This also means it can support both KV_1 and KV_2 key-values stores intrinsically and is fully Hashicorp/OpenBao compatible via its use of org.springframework.vault Two authentication methods are supported
I've attempted to write the document for guacamole-manual included in the "doc/" sub-directory. This still needs lots of work and lots of testing. But I think its more complete than your code or the pull request #1116. I'll do some testing for my use cases, but I'd love to collaborate on getting this to work. |
OpenBao Vault Extension for Apache Guacamole
This extension integrates Apache Guacamole with OpenBao 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
${OPENBAO_SECRET}and${GUAC_USERNAME}tokens in connection parametersguacamole.propertiesHow It Works
${OPENBAO_SECRET}tokenConfiguration
OpenBao Server Setup
Before using this extension, you need:
guacamole-credentails)passwordfield in the dataExample secret structure:
{ "data": { "data": { "username": "user1", "password": "SecretPassword123" } } }Guacamole Configuration
Add the following properties to
guacamole.properties:AppRole Authentication (optional)
As an alternative to a static
openbao-token, the extension canauthenticate via the AppRole auth method, which typically issues
shorter-lived tokens:
When both
openbao-role-idandopenbao-secret-idare set, theextension performs an AppRole login at
/v1/auth/<openbao-approle-path>/loginand uses the returnedclient_tokenfor all subsequent requests. The token is cached andrefreshed automatically on a 403 response. If AppRole is configured,
openbao-tokenis ignored.Note: The extension uses hardcoded defaults for:
2(KV v2 secrets engine)5000ms(5 seconds)10000ms(10 seconds)Connection Configuration
When creating connections in Guacamole, use these token patterns:
${OPENBAO_SECRET}: Replaced with thepasswordfield of thesecret stored at
<mount>/data/<guac-username>.${GUAC_USERNAME}: Replaced with the logged-in Guacamole username.Example RDP connection:
${GUAC_USERNAME}${OPENBAO_SECRET}192.168.1.100Arbitrary 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):<path>is the path under the configured mount, without the/data/prefix (KV v2 is handled automatically).<field>selects which field to return from the secret's data;defaults to
passwordif omitted.Example
openbao-token-mapping.yml: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:
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:
cd extensions/guacamole-vault mvn clean packageThe built extension will be located at:
Installation
Copy the built JAR to the Guacamole extensions directory:
cp guacamole-vault-openbao-*.jar /etc/guacamole/extensions/Ensure
guacamole-vault-base-*.jaris also present in the extensions directory (it's a dependency)Configure
guacamole.propertiesas described aboveRestart Guacamole (e.g., restart Tomcat)
Security Considerations
Protect the OpenBao Token: Use a dedicated token with minimal permissions (read-only access to required secret paths)
Use TLS in Production: Always use HTTPS URLs for OpenBao in production:
Network Security: Restrict OpenBao access to the Guacamole server using firewall rules
Audit Logging: Enable OpenBao audit logging to track credential access
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:guacamole-vault-basedependencyguacamole.propertiesSecret Not Found
Error:
Secret not found in OpenBao for username: johnSolutions:
Permission Denied
Error:
Permission denied accessing OpenBao. Check token permissions.Solutions:
Connection Timeout
Error:
Failed to communicate with OpenBaoSolutions:
Example Deployment
Setup OpenBao:
Configure Guacamole:
Create Connection:
${GUAC_USERNAME}${OPENBAO_SECRET}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: