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/apache-sshd/NOTICE b/doc/licenses/apache-sshd/NOTICE new file mode 100644 index 0000000000..c308d97bbd --- /dev/null +++ b/doc/licenses/apache-sshd/NOTICE @@ -0,0 +1,16 @@ +# Name: Apache Mina SSHD +# URL: https://mina.apache.org/sshd-project +# From: 'Apache Software Foundation' (https://www.apache.org/) +# License: Apache v2.0 +# Source: https://raw.githubusercontent.com/apache/mina-sshd/refs/tags/sshd-${VERSION}/NOTICE.txt +# +# --- BEGIN LICENSE FILE [2.17.1] --- +Apache MINA SSHD +Copyright 2008-2021 The Apache Software Foundation + +This product includes software developed at +The Apache Software Foundation (http://www.apache.org/). + +Please refer to each LICENSE..txt file for the +license terms of the components that Apache MINA depends on. +# --- END LICENSE FILE [2.17.1] --- diff --git a/doc/licenses/apache-sshd/dep-coordinates.txt b/doc/licenses/apache-sshd/dep-coordinates.txt new file mode 100644 index 0000000000..7a550d0123 --- /dev/null +++ b/doc/licenses/apache-sshd/dep-coordinates.txt @@ -0,0 +1,3 @@ +org.apache.sshd:sshd-common:jar:* +org.apache.sshd:sshd-core:jar:* + diff --git a/doc/licenses/net-i2p-crypto/LICENSE b/doc/licenses/net-i2p-crypto/LICENSE new file mode 100644 index 0000000000..9bd647914f --- /dev/null +++ b/doc/licenses/net-i2p-crypto/LICENSE @@ -0,0 +1,6 @@ +# Name: EdDSA Java Implementation +# Version: 0.3.0 +# From: str4d (original author) +# Project: https://github.com/str4d/ed25519-java +# Maven Coordinates: net.i2p.crypto:eddsa +# License: CC0 1.0 Universal (Public Domain Dedication) diff --git a/doc/licenses/net-i2p-crypto/dep-coordinates.txt b/doc/licenses/net-i2p-crypto/dep-coordinates.txt new file mode 100644 index 0000000000..506e28e461 --- /dev/null +++ b/doc/licenses/net-i2p-crypto/dep-coordinates.txt @@ -0,0 +1 @@ +net.i2p.crypto:eddsa:jar:* diff --git a/doc/licenses/spring-framework/dep-coordinates.txt b/doc/licenses/spring-framework/dep-coordinates.txt index 2e107e9902..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/LICENSE b/doc/licenses/spring-vault/LICENSE new file mode 100644 index 0000000000..8aa697fd2c --- /dev/null +++ b/doc/licenses/spring-vault/LICENSE @@ -0,0 +1,4 @@ +# Name: Spring Vault +# URL: https://spring.io/projects/spring-vault +# From: 'Spring' (https://spring.io/) +# License: Apache v2.0 diff --git a/doc/licenses/spring-vault/dep-coordinates.txt b/doc/licenses/spring-vault/dep-coordinates.txt new file mode 100644 index 0000000000..5828edd06d --- /dev/null +++ b/doc/licenses/spring-vault/dep-coordinates.txt @@ -0,0 +1 @@ +org.springframework.vault:spring-vault-core:jar:* diff --git a/extensions/guacamole-vault/modules/guacamole-vault-openbao/.ratignore b/extensions/guacamole-vault/modules/guacamole-vault-openbao/.ratignore new file mode 100644 index 0000000000..57d24ee9c9 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/.ratignore @@ -0,0 +1,2 @@ +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/docs/openbao.sh b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.sh new file mode 100644 index 0000000000..ac7567e472 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/docs/openbao.sh @@ -0,0 +1,408 @@ +#! /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 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 +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 "kv1/*" { + capabilities = ["read"] +} +path "kv2/*" { + capabilities = ["read"] +} +path "ssh/sign/guacamole_cert" { + capabilities = ["update"] +} +path "ssh/creds/guacamole_otp" { + capabilities = ["update"] +} +path "ldap/library/*" { + capabilities = ["read", "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" \ + --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 "# 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": "uid", + "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 '{ + "ttl": "1h", + "max_ttl": "24h", + "disable_checkin_enforcement": "true", + "service_account_names": "testuser2" + }' \ + 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"}' \ + 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"}' \ + 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-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/guacamole_otp +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --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" +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. Test Guacamole with a token with limited rights and an infinite TTL +# 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}' \ + 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 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} +# Test that connection kali server actually works +# 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 +# 6. 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 /usr/sbin/sshd + +# Use the username "kali" in SSH connection and add these tokens to +# the private and public ssh keys +# +# \${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 +# 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 +pkill -SIGHUP /usr/sbin/sshd + +# 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/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 + +cat << EOT >> /usr/local/sbin/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 +EOT +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 + +rm /usr/local/sbin/verify_otp.sh +sed -i '1d' /etc/pam.d/sshd + +# 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. Add the dynamic ldaptokens to the SSH connection +# \${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} +# 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 + +curl -s --header "X-Vault-Token: $VAULT_TOKEN" --header "Content-Type: application/json" \ + --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 + +# 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 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 +# 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. +# +# 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 +# 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/pom.xml b/extensions/guacamole-vault/modules/guacamole-vault-openbao/pom.xml new file mode 100644 index 0000000000..da96920d81 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/pom.xml @@ -0,0 +1,120 @@ + + + + + 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} + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + org.springframework.vault + spring-vault-core + 2.3.4 + + + + + org.apache.sshd + sshd-common + 2.17.1 + + + org.slf4j + jcl-over-slf4j + + + + + + + net.i2p.crypto + eddsa + 0.3.0 + + + + + org.checkerframework + checker-qual + 3.37.0 + + + + com.google.errorprone + error_prone_annotations + 2.21.1 + + + + + com.github.ben-manes.caffeine + caffeine + 2.9.3 + + + org.checkerframework + checker-qual + + + com.google.errorprone + error_prone_annotations + + + + + + diff --git a/extensions/guacamole-vault/modules/guacamole-vault-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..fb1a3ecd7b --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProvider.java @@ -0,0 +1,56 @@ +/* + * 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.Inject; +import org.apache.guacamole.GuacamoleException; +import org.apache.guacamole.vault.VaultAuthenticationProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * 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. + */ +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..239d093429 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/OpenBaoAuthenticationProviderModule.java @@ -0,0 +1,77 @@ +/* + * 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.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.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. + */ +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(OpenBaoConfigurationService.class); + bind(VaultConfigurationService.class).to(OpenBaoConfigurationService.class); + + // Bind secret service + bind(VaultSecretService.class).to(OpenBaoSecretService.class); + + // Bind attribute service + bind(VaultAttributeService.class).to(OpenBaoAttributeService.class); + + // Bind directory service + bind(VaultDirectoryService.class).to(OpenBaoDirectoryService.class); + + // 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 new file mode 100644 index 0000000000..87d35f593b --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/conf/OpenBaoConfigurationService.java @@ -0,0 +1,355 @@ +/* + * 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 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; +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 Hashicorp/OpenBao configuration from guacamole.properties. + */ +@Singleton +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 timeout in milliseconds. + */ + public static final int DEFAULT_CONNECTION_TIMEOUT = 10000; + + /** + * The default ssh connection tiemout in seconds. + */ + public static final int DEFAULT_SSH_CONNECTION_TIMEOUT = 1800; + + /** + * The default vault token renewal delay in milliseconds. Expiring + * tokens will be renewed at least this delay before expiration + */ + public static final int DEFAULT_TOKEN_RENEWAL_DELAY = 10000; + + /** + * The default ssh certificate type. + */ + public static final String DEFAULT_SSH_TYPE = "ed25519"; + + /** + * The 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 an ssh signed certificate is considered to be valid. + */ + private static final IntegerGuacamoleProperty VAULT_SSH_CONNECTION_TIMEOUT = + new IntegerGuacamoleProperty() { + + @Override + public String getName() { return "vault-ssh-connection-timeout"; } + }; + + /** + * The renewal delay for expiring Vault tokens in ms. Tokens will be renewed + * prior to expiration by this delay + */ + private static final IntegerGuacamoleProperty VAULT_TOKEN_RENEWAL_DELAY = + new IntegerGuacamoleProperty() { + + @Override + public String getName() { return "vault-token-renewal-delay"; } + }; + + /** + * The type of ssh certificates that will be generated + */ + private static final StringGuacamoleProperty VAULT_SSH_TYPE = + new StringGuacamoleProperty() { + + @Override + public String getName() { return "vault-ssh-type"; } + }; + + /** + * The Guacamole server environment. + */ + @Inject + private Environment environment; + + /** + * 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 OpenBaoConfigurationService() { + super(TOKEN_MAPPING_FILENAME, PROPERTIES_FILENAME); + } + + /** + * The URI of the hashicorp or OpenBao vault to use. + * + * @return + * The Hashicorp or OpenBao server URI (e.g., "http://localhost:8200"). + * + * @throws GuacamoleException + * If the property is not defined in guacamole.properties or + * guacamole.properties can not be parsed. + */ + public URI getVaultUri() throws GuacamoleException { + return environment.getRequiredProperty(VAULT_URI); + } + + /** + * The authentication token to use to access the vault. + * + * @return + * The Hashicorp or OpenBao authentication token. + * + * @throws GuacamoleException + * If the property is not defined in guacamole.properties or + * guacamole.properties can not be parsed. + */ + public String getVaultToken() throws GuacamoleException { + return environment.getProperty(VAULT_TOKEN); + } + + /** + * The authentication Username to use to access the vault. + * + * @return + * The Hashicorp or OpenBao authentication Username + * + * @throws GuacamoleException + * If the property is not defined in guacamole.properties or + * guacamole.properties can not be parsed. + */ + public String getVaultUsername() throws GuacamoleException { + return environment.getProperty(VAULT_USERNAME); + } + + /** + * The authentication Password to use to access the vault. + * + * @return String + * The Hashicorp or OpenBao authentication Password + * + * @throws GuacamoleException + * If the property is not defined in guacamole.properties or + * guacamole.properties can not be parsed. + */ + public String getVaultPassword() throws GuacamoleException { + return environment.getProperty(VAULT_PASSWORD); + } + /** + * The maximum time that the cached data is considered valid in + * milliseconds. + * + * @return + * The cache lifetime in milliseconds. + * + * @throws GuacamoleException + * If guacamole.properties can not be parsed. + */ + public int getVaultCacheLifetime() throws GuacamoleException { + return environment.getProperty(VAULT_CACHE_LIFETIME, DEFAULT_CACHE_LIFETIME); + } + + /** + * The maximum time that a request to the vault server can take in + * milliseconds. + * + * @return + * The request timeout in milliseconds. + * + * @throws GuacamoleException + * If guacamole.properties can not be parsed. + */ + public int getRequestTimeout() throws GuacamoleException { + return environment.getProperty(VAULT_REQUEST_TIMEOUT, DEFAULT_REQUEST_TIMEOUT); + } + + /** + * The maximum time that a connection to the vault server can take in + * milliseconds. + * + * @return + * The connection timeout in milliseconds. + * + * @throws GuacamoleException + * If guacamole.properties can not be parsed. + */ + public int getConnectionTimeout() throws GuacamoleException { + return environment.getProperty(VAULT_CONNECTION_TIMEOUT, DEFAULT_CONNECTION_TIMEOUT); + } + + /** + * The renewal delay, in milliseconds of expiring token. A token will be renewed + * prior to it expiration by this delay + * + * @return + * The renewal 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 + * 'rsa' for 4096-bit RSA keys or 'ed25519'. + * + * @return + * The ssh type to use. + * + * @throws GuacamoleException + * If guacamole.properties can not be parsed. + */ + public String getSshType() throws GuacamoleException { + String type = environment.getProperty(VAULT_SSH_TYPE, DEFAULT_SSH_TYPE); + if (! type.equals("rsa") & ! type.equals("ed25519")) { + throw new GuacamoleException("Only ssh certificate types 'rsa' (4096-bit) and 'ed25519' are supported"); + } + return type; + } + + /** + * The maximum time that a signed SSH certificate is considered valid in + * milliseconds. + * + * @return + * The ssh connection timeout in milliseconds. + * + * @throws GuacamoleException + * If guacamole.properties can not be parsed. + */ + public int getSshConnectionTimeout() throws GuacamoleException { + return environment.getProperty(VAULT_SSH_CONNECTION_TIMEOUT, DEFAULT_SSH_CONNECTION_TIMEOUT); + } + + @Override + public boolean getSplitWindowsUsernames() throws GuacamoleException { + // Not needed for Hashicorp/OpenBao - return false + return false; + } + + @Override + public boolean getMatchUserRecordsByDomain() throws GuacamoleException { + // Not needed for Hashicorp/OpenBao - return false + return false; + } +} 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..a3235fa6f5 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClient.java @@ -0,0 +1,616 @@ +/* + * 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.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.RemovalCause; +import com.google.inject.Inject; +import com.google.inject.Singleton; +import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Duration; +import java.time.Instant; +import java.util.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.event.TunnelCloseEvent; +import org.apache.guacamole.net.event.TunnelConnectEvent; +import org.apache.guacamole.net.GuacamoleTunnel; +import org.apache.guacamole.vault.openbao.conf.OpenBaoConfigurationService; +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.concurrent.ThreadPoolTaskScheduler; +import org.springframework.vault.authentication.ClientAuthentication; +import org.springframework.vault.authentication.TokenAuthentication; +import org.springframework.vault.client.VaultEndpoint; +import org.springframework.vault.core.VaultKeyValueOperations; +import org.springframework.vault.core.VaultTemplate; +import org.springframework.vault.VaultException; +import org.springframework.vault.support.VaultResponse; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.DefaultUriBuilderFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Singleton +public class OpenBaoClient { + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(OpenBaoClient.class); + + /** + * Service for retrieving OpenBao configuration. + */ + 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://"; + + /** + * 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 + */ + private Cache> cache; + + /** + * Vault template that will be used with all of the mount paths + */ + private VaultTemplate vaultTemplate; + + /* + * Ttl aware token manager for automatic token renewal + */ + private TtlAwareSessionManager sessionManager; + + /** + * Vault client authentication object + */ + private ClientAuthentication authentication; + + /** + * 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; + + 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())) { + 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, restTemplate); + } + else { + throw new GuacamoleException("Either a vault token or Username/Password must be supplied"); + } + + // Create a task scheduler for our token renewal + ThreadPoolTaskScheduler taskscheduler = new ThreadPoolTaskScheduler(); + taskscheduler.setPoolSize(1); + taskscheduler.setThreadNamePrefix("vault-renewal-"); + taskscheduler.initialize(); + + // Session manager to automatically renew tokens before expiration + this.sessionManager = new TtlAwareSessionManager(this.authentication, + restTemplate, taskscheduler, configService.getTokenRenewalDelay()); + + this.vaultTemplate = new VaultTemplate(endpoint, requestFactory, this.sessionManager); + } + catch (Exception e) { + logger.error("Error initializing Vault client: {}", e.getMessage()); + } + + // Initialize the cache with maximum size of 1MB, and cache expiry with + // forced cleanup + // FIXME I'd really like to do something like "Array.fill(v, '\0');" in + // the removal listener to ensure that passwords are no longer in memory. + // However, both spring-core-vault and Guacamole store these values + // elsewhere as immutable String values, So even if I stored them in the + // cache as char[] copies of the password would be elsewhere as String + // values in memory.. A VaultConverter function could deal with the + // spring-vault-core part of the problem, but not Gaucamole. + try { + cache = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofMillis(configService.getVaultCacheLifetime())) + .maximumSize(1_000_000) + .build(); + } + catch (GuacamoleException e) { + 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 token + * The string with the token returned from configService + * + * @return + * True is the token is a readable file + */ + private static boolean isTokenReadableFile(String token) { + try { + Path path = Paths.get(token); + return Files.isRegularFile(path) && Files.isReadable(path); + } + catch (Exception e) { + return false; + } + } + + /** + * Return the secret engine type and mount path using the internal Vault + * path-help functionality, no need for access to /sys/mounts which might + * be a security risk + * + * @param path + * The path to test for the secret engine type + * + * @return + * A Map with the secret engine type and its mount path + */ + public Map getSecretsEngine(String path) { + Map cacheResponse = cache.getIfPresent(VAULT_PATH_HELP + path); + if (cacheResponse != null) { + return cacheResponse; + } + + VaultResponse response = vaultTemplate.read(VAULT_PATH_HELP + path); + Map data = response.getData(); + String type = String.valueOf(data.get("type")); + + if (type.equals("kv")) { + // Need to detect if type 1 or type 2 Key/Value engine + if (data.get("options") instanceof Map) { + Map options = (Map) data.get("options"); + if ("2".equals(String.valueOf(options.get("version")))) { + type = "kv_2"; + } + else { + type = "kv_1"; + } + } + else { + // No options, assume kv_1 + type = "kv_1"; + } + } + Map map = Map.of("type", type, "path", String.valueOf(data.get("path"))); + cache.put(VAULT_PATH_HELP + path, map); + + return map; + } + + /** + * Contains information about the checked out LDAP sessions + */ + private static class LDAPSessionInfo { + final String checkInPath; + final String username; + final Boolean initialized; + final Instant created; + + public LDAPSessionInfo(String checkInPath, String username) { + this.checkInPath = checkInPath; + this.username = username; + this.initialized = false; + this.created = Instant.now(); + } + + public LDAPSessionInfo(String checkInPath, String username, Boolean initialized) { + this.checkInPath = checkInPath; + this.username = username; + this.initialized = initialized; + this.created = Instant.now(); + } + } + + /** + * The LDAP session interface checks out session that then can not be + * used till they are checked in. In getTokens we have the problem that + * we don't have access to the tunnel ID and so can't identify it. + * Guacamole is also not guarenteed to generate a TunnelCloseEvent. + * + * So this function is fragile and relies on the fact that the TunnelConnectEvent + * will be running a few tens of milliseconds after the getTokens command to + * limit the risk of confusing two connection. There is still a small risk + * of error here. + * + * @param event + * A TunnelConnectEvent or TunnelCloseEvent + */ + public void connectLdapSession(Object event) { + if (event instanceof TunnelConnectEvent) { + 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 { + 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(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))); + } + + /** + * 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. 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. + * + * @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 token. + * + * @throws GuacamoleException + * If the secret cannot be retrieved from the Vault. + */ + public String getValue(String token, String username, String key) throws GuacamoleException { + try { + if (! token.startsWith(VAULT_TOKEN_PREFIX)) { + throw new GuacamoleException("Invalid token Vault token: " + 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(), lastSlashIndex); + String secret = token.substring(lastSlashIndex + 1); + + Map cacheResponse = (Map) cache.getIfPresent(key); + + Object raw; + if (cacheResponse != null) { + 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); + 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 from the token '" + token + "'"); + } + 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("Failed to retrieve secret from the vault : {}", e.getMessage()); + throw new GuacamoleServerException("Failed to retrieve secret from Vault Server : " + e.getMessage()); + } + + } + + /** + * Retrieves a value from a key-value secret engine of a vault. + * + * @param mountPath + * The mountPath of the key-value secret engine on the vault server. + * + * @param path + * The path of the secret record + * + * @param type + * The type of key-value store. Either "kv_1" or "kv_2" + * + * @return + * The values associated with the path. + * + * @throws GuacamoleException + * If the secrets cannot be retrieved from the Vault. + */ + private 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 GuacamoleException("Value not found in Vault for path: " + path); + } + + return response.getData(); + } + + /** + * Retrieves a an ssh one-time password or signed SSH certificate + * + * @param mountPath + * The mountPath of the SSH secret engine on the vault server. + * + * @param path + * The path of the secret record representing the SSH role + * + * @param username + * The connection username that must be non null for SSH certificate + * generation. + * + * @return + * The values associated with the path. + * + * @throws GuacamoleException + * If the secrets cannot be retrieved from the Vault. + */ + private 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"); + } + + 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"); + } + Map retval = response.getData(); + retval.put("username", retval.get("key")); + + return retval; + } + 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()); + + VaultResponse vaultResponse = vaultTemplate.write(mountPath + path, request); + + if (vaultResponse == null || vaultResponse.getData() == null) { + throw new GuacamoleException("No response from Vault SSH engine"); + } + + String signedCert = (String) vaultResponse.getData().get("signed_key"); + + if (signedCert == null) { + throw new GuacamoleException("Vault did not return a signed SSH certificate"); + } + + return Map.of("private", sshKeys.privateSshPem, + "public", signedCert, + "unsigned", sshKeys.publicSsh); + } + else { + throw new GuacamoleException("Unknown SSH type on path: " + mountPath + path); + } + } + + /** + * Retrieves a username or password from an LDAP secret engine. The type of + * account supported might be static, dynamic or service accounts + * + * @param mountPath + * The mountPath of the LDAP secret engine on the vault server. + * + * @param path + * The path of the secret record representing the LDAP role + * + * @return + * The values associated with the path. + * + * @throws GuacamoleException + * If the secrets cannot be retrieved from the Vault. + */ + private Map getValueLDAP(String mountPath, String path) throws GuacamoleException { + VaultResponse response; + if (path.startsWith("static") || path.startsWith("creds/")) { + response = vaultTemplate.read(mountPath + path); + } + else if (path.startsWith("library/")) { + response = vaultTemplate.write(mountPath + path + "/check-out", Map.of("ttl", "2h")); + } + else { + throw new 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 Database secret engine. + * Before version 1.10 the data could only have username/password + * After there can be static preconfigured fields that the vault tokens + * might access. + * + * @param mountPath + * The mountPath of the database secret engine on the vault server. + * + * @param path + * The path of the secret record representing the LDAP role + * + * @return + * The values associated with the path. + * + * @throws GuacamoleException + * If the secrets cannot be retrieved from the Vault. + */ + private 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 Database secrets engine"); + } + + return response.getData(); + } + + /** + * Release the automatic renewal of the tokens on shutdown + */ + public void shutdown() { + 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..5de91ee104 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoClientProvider.java @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.guacamole.vault.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; + + /** + * Creates a new OpenBaoClientProvider. + */ + @Inject + public OpenBaoClientProvider(OpenBaoConfigurationService configService) { + this.configService = configService; + } + + @Override + public OpenBaoClient get() { + 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 new file mode 100644 index 0000000000..c17f433a88 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSecretService.java @@ -0,0 +1,324 @@ +/* + * 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.Singleton; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +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; +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; + +/** + * OpenBao implementation of VaultSecretService. + * Retrieves secrets from OpenBao based on parameters of the logged-in user. + */ +@Singleton +public class OpenBaoSecretService implements VaultSecretService { + + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(OpenBaoSecretService.class); + + /** + * Client for communicating with OpenBao. + */ + private final Provider openBaoClientProvider; + + /** + * 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.debug("OpenBaoSecretService initialized"); + } + + /** + * 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 { + return URLEncoder.encode(nameComponent, "UTF-8"); + + } + catch (UnsupportedEncodingException e) { + 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 + * having the given name. If no such secret exists, the Future will be + * completed with null. The secrets retrieved from this method are independent + * of the context of the particular connection being established, or any + * associated user context. + * + * @param token + * The name of the secret to retrieve. + * + * @return + * A Future which completes with value of the secret having the given + * name. If no such secret exists, the Future will be completed with + * null. If an error occurs asynchronously which prevents retrieval of + * the secret, that error will be exposed through an ExecutionException + * when an attempt is made to retrieve the value from the Future. + * + * @throws GuacamoleException + * If the secret cannot be retrieved due to an error. + */ + @Override + public Future getValue(String 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); + } + + /** + * 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 { + 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('/')); + token = token.replaceFirst(":(LOWER|UPPER|OPTIONAL)$", ""); + String value = client().getValue(prepareToken(token, userContext, config, new TokenFilter()), username, key); + + return CompletableFuture.completedFuture(value); + } + + /* + * Returns a map of token names to corresponding Futures which eventually + * complete with the value of that token, where each token is dynamically + * defined based on connection parameters. If a vault implementation allows + * for predictable secrets based on the parameters of a connection, this + * function should be implemented to provide automatic tokens for those + * secrets and remove the need for manual mapping via YAML. + * + * @param userContext + * The user context from which the connectable originated. + * + * @param connectable + * The connection or connection group for which the tokens are being replaced. + * + * @param config + * The configuration of the Guacamole connection for which tokens are + * being generated. This configuration may be empty or partial, + * depending on the underlying implementation. + * + * @param filter + * A TokenFilter instance that applies any tokens already available to + * be applied to the configuration of the Guacamole connection. These + * tokens will consist of tokens already supplied to connect(). + * + * @return + * A map of token names to their corresponding future values, where + * each token and value may be dynamically determined based on the + * connection configuration. + * + * @throws GuacamoleException + * If an error occurs producing the tokens and values required for the + * given configuration. + */ + @Override + public Map> getTokens(UserContext userContext, + Connectable connectable, GuacamoleConfiguration config, + TokenFilter filter) throws GuacamoleException { + + Map> tokens = new HashMap<>(); + Map parameters = config.getParameters(); + + // Remove optional token parameter modifier + Pattern tokenPattern = Pattern.compile("\\$\\{(" + client().VAULT_TOKEN_PREFIX + + "(?:[^{}:]|:(?!(?:LOWER|UPPER|OPTIONAL)(?=\\}))|\\{(?:[^{}]|\\{[^{}]*\\})*\\})+" + + ")(:(?:(LOWER|UPPER|OPTIONAL))(?=\\}))?\\}"); + + // To keep the tokens for the same connection associated with each other in the + // cache, for tokens that might create a confusion, we cache them with a shared + // key + String key = UUID.randomUUID().toString(); + + for (Map.Entry entry : parameters.entrySet()) { + Matcher tokenMatcher = tokenPattern.matcher(entry.getValue()); + while (tokenMatcher.find()) { + String token = tokenMatcher.group(1); + + // 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)); + } + } + + // 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); + // }}); + + // Simpler, innocuous 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/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..2e3691ce12 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/secret/OpenBaoSshKeys.java @@ -0,0 +1,126 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.guacamole.vault.openbao.secret; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +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; + +public class OpenBaoSshKeys { + /** + * Logger for this class. + */ + private static final Logger logger = LoggerFactory.getLogger(OpenBaoSshKeys.class); + + /** + * The PEM encoded private SSH key + */ + public final String privateSshPem; + + /** + * The OpenSSH encoded public SSH key + */ + public final String publicSsh; + + /** + * Class instantiation to return generated SSH keys. Default type + */ + public 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; + + if ("rsa".equals(type)) { + keyPair = generateRsa(); + } + else if ("ed25519".equals(type)) { + keyPair = generateEd25519WithFallback(); + } + else { + throw new IllegalArgumentException("Unrecognized SSH encryption : "+ type); + } + + try { + this.publicSsh = PublicKeyEntry.toString(keyPair.getPublic()); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + OpenSSHKeyPairResourceWriter writer = + new OpenSSHKeyPairResourceWriter(); + writer.writePrivateKey(keyPair, null, null, baos); + this.privateSshPem = + baos.toString(StandardCharsets.UTF_8); + } + catch (Exception e) { + throw new IllegalStateException("Failed to serialize SSH keypair: " + e.getMessage(), e); + } + } + + /** + * Generate a ed25519 key-pair + * + * @return + * A java.security.KeyPair containing the ed25519 key pair or RSA if failure + */ + private KeyPair generateEd25519WithFallback() { + try { + KeyPairGenerator keyPairGenerator = + SecurityUtils.getKeyPairGenerator("EdDSA"); + return keyPairGenerator.generateKeyPair(); + } + catch (Exception e) { + logger.warn("Ed25519 not available via SSHD EdDSA. Falling back to RSA : {}", e.getMessage()); + return generateRsa(); + } + } + + /** + * Generate a 4096-bit RSA key-pair + * + * @return + * A java.security.KeyPair containing the RSA key pair + */ + private KeyPair generateRsa() { + try { + KeyPairGenerator keyPairGenerator = + SecurityUtils.getKeyPairGenerator("RSA"); + keyPairGenerator.initialize(4096); + return keyPairGenerator.generateKeyPair(); + } + catch (Exception e) { + throw new IllegalStateException("Failed to generate RSA SSH keypair", e); + } + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-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/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..2836eca969 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/user/OpenBaoAttributeService.java @@ -0,0 +1,59 @@ +/* + * 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 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 + 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..72e21e4b05 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/user/OpenBaoDirectoryService.java @@ -0,0 +1,82 @@ +/* + * 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 com.google.inject.Singleton; +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. + */ + @Singleton +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/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 new file mode 100644 index 0000000000..d7c1c8315b --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/FileTokenAuthentication.java @@ -0,0 +1,81 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.guacamole.vault.openbao.vault; + +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.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.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; + +public final class FileTokenAuthentication implements ClientAuthentication { + + /** + * The path to the file containing the token + */ + private final Path tokenPath; + + /** + * An instantiator for a Token Authentication class where the token + * is reread from a file on renewal requests. This allows integration + * with a VaultAgent for complex authentication methods + * + * @param String tokenPath + * A path to a readable file containing the token + */ + public FileTokenAuthentication(String tokenPath) { + this.tokenPath = Path.of(tokenPath); + } + + /* + * Returns the current token + * + * @return VaultToken + * The current vault token + */ + @Override + public VaultToken login() { + String token; + try { + token = Files.readString(tokenPath).trim(); + } + catch (IOException e) { + // This might be recoverable. So throw a VautException + throw new VaultException( + "Cannot read Vault token sink: " + tokenPath, e); + } + + return VaultToken.of(token); + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-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..dddb4eb133 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/TtlAwareSessionManager.java @@ -0,0 +1,495 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.apache.guacamole.vault.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 LifecycleAwareSessionManager and delegate + * most of the work to it. Expired or non renewable tokens would still + * need to be treated here + * + * @param clientAuthentication + * The authentication method to use for obtaining tokens. + * + * @param restOperations + * A RestOperations configured for the Vault endpoint. + * + * @param scheduler + * Spring TaskScheduler used for renewal scheduling. + * + * @param renewalDelayMillis + * The delay (in milliseconds) before token expiration when renewal + * should occur. + */ + public TtlAwareSessionManager( + ClientAuthentication clientAuthentication, + RestOperations restOperations, + TaskScheduler scheduler, + long renewalDelayMillis) { + + this.clientAuthentication = clientAuthentication; + this.restOperations = restOperations; + this.scheduler = scheduler; + this.renewalDelayMillis = renewalDelayMillis; + + // Obtain the initial token and schedule its renewal + try { + VaultToken token = clientAuthentication.login(); + setSessionToken(token); + if (token != null && !token.getToken().isEmpty()) { + logger.info("TtlAwareSessionManager initialized with renewal delay of {} ms", renewalDelayMillis); + } + } + catch (RuntimeException e) { + logger.error("Non recoverable error initializing TtlAwareSessionManager : {}", e.getMessage(), e); + } + catch (Exception e) { + // Recoverable error: reschedule authentication + scheduleRenewal(null); + } + } + + /** + * Closes the session token manager and cleans up resources. + * Cancels any pending renewal tasks. + * + * Note: TaskScheduler is container-managed and must not be shut down here. + */ + public void close() { + logger.debug("Closing TtlAwareSessionManager"); + stopScheduling(); + } + + /** + * Small helper function to stop scheduling before new scheduled task + * or in case of unrecoverable errors or non-expiring tokens. + */ + private void stopScheduling() { + ScheduledFuture future = scheduledRenewal.getAndSet(null); + if (future != null) { + future.cancel(false); + } + } + + /** + * Schedules a renewal task for the given token if it has a TTL > 0. + * For renewable tokens, this will attempt to renew the token. For + * non-renewable tokens, this will re-authenticate to get a new token. + * + * @param token + * The token to schedule renewal for. + */ + private void scheduleRenewal(VaultToken token) { + + // Cancel any existing scheduled renewal + stopScheduling(); + + if (token == null || token.getToken().isEmpty()) { + // Vault not ready or available ? Try again in 10 seconds + logger.debug("Null token detected. Vault not ready ? Rescheduling authentication"); + scheduledRenewal.set(scheduler.schedule(this::renewTokenAsync, Instant.now().plusMillis(10000))); + return; + } + + try { + TokenInfo tokenInfo = getTokenInfo(token); + + // Skip scheduling if TTL is 0 (non-expiring token) + if (tokenInfo.ttlSeconds == 0) { + logger.info("Skipping renewal scheduling for non-expiring token"); + return; + } + + long delay = calculateDelayUntilRenewal(tokenInfo.ttlSeconds); + + if (delay <= 0) { + // Token is already past renewal threshold; renew immediately + logger.debug("Token is past renewal threshold, renewing immediately"); + renewTokenAsync(); + } + else { + logger.debug("Scheduling token renewal in {} ms", delay); + scheduledRenewal.set(scheduler.schedule(this::renewTokenAsync, Instant.now().plusMillis(delay))); + } + } + catch (VaultException e) { + logger.warn("Failed to get token information, attempting immediate renewal: {}", e.getMessage()); + renewTokenAsync(); + } + } + + /** + * Contains token information extracted from Vault responses. + */ + private static class TokenInfo { + final long ttlSeconds; + final boolean renewable; + + TokenInfo(long ttlSeconds, boolean renewable) { + this.ttlSeconds = ttlSeconds; + this.renewable = renewable; + } + } + + /** + * Gets token information (TTL and renewable status) from the given VaultToken. + * If the token is already a LoginToken, extracts info from it. + * Otherwise, looks up the token to get its information. + * + * @param token The VaultToken to get information for. + * + * @return TokenInfo containing TTL and renewable status. + * + * @throws VaultException If the token lookup fails. + */ + private TokenInfo getTokenInfo(VaultToken token) { + + if (token instanceof LoginToken) { + LoginToken loginToken = (LoginToken) token; + Duration leaseDuration = loginToken.getLeaseDuration(); + long ttlSeconds = leaseDuration != null ? leaseDuration.getSeconds() : 0; + boolean renewable = loginToken.isRenewable(); + + logger.debug("Token is already a LoginToken. TTL: {}, Renewable: {}", ttlSeconds, renewable); + return new TokenInfo(ttlSeconds, renewable); + } + + ResponseEntity response; + + try { + logger.debug("Looking up token information"); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-Vault-Token", token.getToken()); + HttpEntity requestEntity = new HttpEntity<>(headers); + + response = restOperations.exchange("/auth/token/lookup-self", + HttpMethod.GET, requestEntity, Map.class); + } + catch (RestClientException e) { + throw new VaultException("Vault request failed: " + e.getMessage()); + } + + if (response == null || response.getBody() == null + || !(response.getBody().get("data") instanceof Map)) { + throw new VaultException("Failed to lookup token: no response data"); + } + + @SuppressWarnings("unchecked") + Map data = (Map) response.getBody().get("data"); + Number ttl = (Number) data.get("ttl"); + Boolean renewable = (Boolean) data.get("renewable"); + + long ttlSeconds = ttl != null ? ttl.longValue() : 0; + boolean isRenewable = renewable != null && renewable; + + logger.debug("Token lookup successful. TTL: {}, Renewable: {}", ttlSeconds, isRenewable); + + return new TokenInfo(ttlSeconds, isRenewable); + } + + /** + * Calculates the delay until the token should be renewed. + * + * @param ttlSeconds The token's TTL in seconds. + * + * @return The delay in milliseconds until renewal should occur. + */ + private long calculateDelayUntilRenewal(long ttlSeconds) { + long expiryTime = System.currentTimeMillis() + (ttlSeconds * 1000); + long delay = (expiryTime - renewalDelayMillis) - System.currentTimeMillis(); + + logger.debug("Calculated renewal delay: {} ms for token with TTL: {} seconds", delay, ttlSeconds); + + return delay; + } + + /** + * Asynchronously renews the current token. For renewable tokens, + * attempts to renew via the Vault API. For non-renewable tokens, + * re-authenticates to get a new token. + */ + private void renewTokenAsync() { + + try { + VaultToken currentToken = getSessionToken(); + + if (currentToken == null || currentToken.getToken().isEmpty()) { + logger.debug("No current token to renew, attempting re-authentication"); + attemptLogin(); + return; + } + + TokenInfo tokenInfo; + + try { + tokenInfo = getTokenInfo(currentToken); + } + catch (VaultException e) { + logger.debug("Token lookup fail, attempting re-authentication"); + attemptLogin(); + return; + } + catch (Exception e) { + logger.error("Non-recoverable error during token lookup: {}", e.getMessage(), e); + stopScheduling(); + return; + } + + if (tokenInfo.ttlSeconds == 0) { + logger.info("Token TTL is zero. Stop renewal for non-expiring tokens"); + stopScheduling(); + return; + } + + VaultToken newToken; + + if (tokenInfo.renewable) { + try { + logger.debug("Renewing token"); + newToken = renewToken(currentToken); + } + catch (VaultException e) { + logger.warn("Token renewal failed, re-authenticating: {}", e.getMessage()); + attemptLogin(); + return; + } + catch (Exception e) { + logger.error("Non-recoverable error during token renewal: {}", e.getMessage(), e); + stopScheduling(); + return; + } + } + else { + logger.debug("Re-authenticating for non-renewable token"); + attemptLogin(); + return; + } + + try { + tokenInfo = getTokenInfo(newToken); + } + catch (VaultException e) { + logger.debug("Token lookup fail, attempting re-authentication"); + attemptLogin(); + return; + } + + long delay = calculateDelayUntilRenewal(tokenInfo.ttlSeconds); + if (delay <= 0) { + // Token is already past renewal threshold; renew immediately + logger.info("Token is past renewal threshold during renewal, re-authenticating"); + attemptLogin(); + return; + } + + logger.debug("Successfully renewed the token"); + setSessionToken(newToken); + } + catch (Exception e) { + logger.error("Unexplained failure to renew token : {}", e.getMessage(), e); + stopScheduling(); + } + } + + /** + * Renews a renewable token via the Vault API. + * + * @param token + * The token to renew. + * + * @return + * A new LoginToken with updated TTL. + * + * @throws VaultException + * If the renewal fails, but the error is recoverable. Non + * recoverable exceptions are bubbled up. + */ + private LoginToken renewToken(VaultToken token) throws VaultException { + + VaultResponse response; + + try { + response = restOperations.postForObject("/auth/token/renew-self", + new HttpEntity<>(VaultHttpHeaders.from(token)), VaultResponse.class); + } + catch (RestClientException e) { + throw new VaultException("Vault request failed: " + e.getMessage()); + } + + if (response == null || response.getAuth() == null) { + throw new VaultException("Token renewal failed: no response data"); + } + + String renewedToken = (String) response.getAuth().get("client_token"); + + if (renewedToken == null) { + throw new VaultException("Token renewal failed: missing token in response"); + } + + Number ttl = (Number) response.getAuth().get("lease_duration"); + Boolean renewable = (Boolean) response.getAuth().get("renewable"); + + long ttlSeconds = ttl != null ? ttl.longValue() : 0; + boolean isRenewable = renewable != null && renewable; + + LoginToken newToken = LoginToken.builder() + .token(renewedToken) + .leaseDuration(Duration.ofSeconds(ttlSeconds)) + .renewable(isRenewable) + .build(); + + logger.info("Vault token renewed successfully. New TTL: {}, Renewable: {}", ttlSeconds, isRenewable); + + return newToken; + } + + /* + * Attempt a login via clientAuthentication class, and reschedule + * in case of a recoverable failure. + */ + private void attemptLogin() { + + try { + VaultToken newToken = clientAuthentication.login(); + + // If we have a VaultToken here the above might not throw a + // VaultException for an invalid token. We use getTokenInfo + // to force a VaultExpception for an invalid token, and avoid + // an infinite loop. Since we have the token info, might as + // well promote it to a LoginToken directly + TokenInfo tokenInfo = getTokenInfo(newToken); + if (!(newToken instanceof LoginToken)) { + newToken = LoginToken.builder() + .token(newToken.getToken()) + .leaseDuration(Duration.ofSeconds(tokenInfo.ttlSeconds)) + .renewable(tokenInfo.renewable) + .build(); + } + + logger.info("Vault login successful. New TTL: {}, Renewable: {}", + tokenInfo.ttlSeconds, tokenInfo.renewable); + setSessionToken(newToken); + } + catch (VaultException e) { + logger.warn("Recoverable authentication failure, rescheduling renewal : {}", e.getMessage()); + stopScheduling(); + scheduledRenewal.set(scheduler.schedule(this::renewTokenAsync, + Instant.now().plusMillis(10000))); + } + catch (Exception e) { + logger.error("Non-recoverable authentication failure: {}", e.getMessage(), e); + stopScheduling(); + } + } + + /** + * Returns the current session token. + */ + @Override + public VaultToken getSessionToken() { + return this.token.get(); + } + + /** + * Sets the current token and schedules its renewal if applicable. + * + * @param token + * Can be either a Null, VaultToken or a LoginToken + */ + public void setSessionToken(VaultToken token) { + if (token != null && !token.getToken().isEmpty()) { + this.token.set(token); + } + scheduleRenewal(token); + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-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 new file mode 100644 index 0000000000..e705af7788 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/UsernamePasswordAuthentication.java @@ -0,0 +1,130 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// This is a minimal backport of this class to version 2.3.4 of spring-vault-core +// It is compatible with lease renewal using a life cycle aware session manager + +package org.apache.guacamole.vault.openbao.vault; + +import java.time.Duration; +import java.util.Collections; +import java.util.Map; +import org.springframework.http.ResponseEntity; +import org.springframework.util.Assert; +import org.springframework.vault.authentication.ClientAuthentication; +import org.springframework.vault.authentication.LoginToken; +import org.springframework.vault.VaultException; +import org.springframework.vault.client.VaultEndpoint; +import org.springframework.vault.support.VaultResponse; +import org.springframework.vault.support.VaultToken; +import org.springframework.web.client.RestTemplate; + +public class UsernamePasswordAuthentication implements ClientAuthentication { + /** + * A class containing all of the configuration options for username/password authentication + */ + private final UsernamePasswordAuthenticationOptions options; + + /** + * A spring RestTemplate used to communicate with the vault server + */ + private final RestTemplate restTemplate; + + /** + * The Vault endpoint to communicate to + */ + private final VaultEndpoint endpoint; + + /** + * Contructor for the Username/Password clientAuthentication + * + * @param options + * The configuration of the username/password to use for authentication + * + * @param endpoint + * The endpoint of teh VAult to use + * + * @param restTemplate + * The spring-framework RestTemplate used to communicate with the Vault + */ + public UsernamePasswordAuthentication( + UsernamePasswordAuthenticationOptions options, + VaultEndpoint endpoint, + RestTemplate restTemplate) { + + Assert.notNull(options, "Options must not be null"); + Assert.notNull(endpoint, "VaultEndpoint must not be null"); + Assert.notNull(restTemplate, "RestTemplate must not be null"); + + this.options = options; + this.endpoint = endpoint; + this.restTemplate = restTemplate; + } + + /** + * A login method that attempts a username/password login to the Vault + * + * @return + * A LoginToken for the authenticated used for use with future operations with + * the vault + * + * @throws VaultException + * In case of a recoverable login issue, throws a VaultExecption, so tha the + * SessionManager knows to try the authtication again + */ + @Override + public VaultToken login() throws VaultException { + + String loginPath = String.format( + "%s://%s:%d/v1/auth/%s/login/%s", + endpoint.getScheme(), + endpoint.getHost(), + endpoint.getPort(), + options.getMountPath(), + options.getUsername()); + + Map body = + Collections.singletonMap("password", options.getPassword()); + + ResponseEntity response = + restTemplate.postForEntity(loginPath, body, VaultResponse.class); + + VaultResponse vaultResponse = response.getBody(); + + if (vaultResponse == null || vaultResponse.getAuth() == null) { + throw new VaultException("No auth section returned from Vault"); + } + + String token = (String) vaultResponse.getAuth().get("client_token"); + Number lease = (Number) vaultResponse.getAuth().get("lease_duration"); + Boolean renewable = (Boolean) vaultResponse.getAuth().get("renewable"); + + long leaseDuration = lease != null ? lease.longValue() : 0; + boolean isRenewable = renewable != null && renewable; + + if (token == null) { + throw new VaultException("No client_token in Vault auth response"); + } + + return LoginToken.builder().token(token) + .leaseDuration(Duration.ofSeconds(leaseDuration)) + .renewable(isRenewable) + .build(); + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-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 new file mode 100644 index 0000000000..a9c4d2b185 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/java/org/apache/guacamole/vault/openbao/vault/UsernamePasswordAuthenticationOptions.java @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + + // This is a minimal backport of this class to version 2.3.4 of spring-vault-core + +package org.apache.guacamole.vault.openbao.vault; + +import org.springframework.util.Assert; + +public final class UsernamePasswordAuthenticationOptions { + /** + * Path of the userpass authentication method mount. + */ + private final String mountPath; + + /** + * Username of the userpass authetication method mount. + */ + private final String username; + + /** + * Password of the userpass authetication method mount. + */ + private final String password; + + + private UsernamePasswordAuthenticationOptions(Builder builder) { + this.username = builder.username; + this.password = builder.password; + this.mountPath = builder.mountPath; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + public String getMountPath() { + return mountPath; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private String username; + private String password; + private String mountPath = "userpass"; + + public Builder username(String username) { + this.username = username; + return this; + } + + public Builder password(String password) { + this.password = password; + return this; + } + + /** Optional – defaults to "userpass" */ + public Builder mountPath(String mountPath) { + this.mountPath = mountPath; + return this; + } + + public UsernamePasswordAuthenticationOptions build() { + + Assert.hasText(username, "Username must not be empty"); + Assert.hasText(password, "Password must not be empty"); + Assert.hasText(mountPath, "Mount path must not be empty"); + + return new UsernamePasswordAuthenticationOptions(this); + } + } +} diff --git a/extensions/guacamole-vault/modules/guacamole-vault-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..7bfd7444a6 --- /dev/null +++ b/extensions/guacamole-vault/modules/guacamole-vault-openbao/src/main/resource-templates/guac-manifest.json @@ -0,0 +1,16 @@ +{ + + "guacamoleVersion" : "${project.version}", + + "name" : "OpenBao Vault", + "namespace" : "openbao", + + "authProviders" : [ + "org.apache.guacamole.vault.openbao.OpenBaoAuthenticationProvider" + ], + + "listeners" : [ + "org.apache.guacamole.vault.openbao.secret.OpenBaoTunnelEventListener" + ] + +} 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 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..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("(.*?)(^|.)(\\$\\{([A-Za-z0-9_]*)(\\:(.*))?\\})"); - + private final Pattern tokenPattern = Pattern.compile("(.*?)(^|.)(\\$\\{((?:[^{}:]|:(?!(?:LOWER|UPPER|OPTIONAL)" + + "(?=\\}))|\\{(?:[^{}]|\\{[^{}]*\\})*\\})+)(:(?:(LOWER|UPPER|OPTIONAL))(?=\\}))?\\})"); /** * The index of the capturing group within tokenPattern which matches * non-token text preceding a possible token. @@ -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. */