diff --git a/.env.example b/.env.example index e46fc20..3962058 100644 --- a/.env.example +++ b/.env.example @@ -18,6 +18,12 @@ SSL_CERTIFICATE_DIR=./config/letsencrypt/live/broker.example.com SSL_CERTIFICATE_FILE=fullchain.pem SSL_CERTIFICATE_KEY_FILE=privkey.pem +# Optional public Apache Guacamole proxy. When enabled, this hostname is added +# to the Let's Encrypt certificate and proxied to the specified HTTP origin. +EXTERNAL_GUACAMOLE_ENABLED=false +EXTERNAL_GUACAMOLE_DOMAIN= +EXTERNAL_GUACAMOLE_UPSTREAM= + TAPM_DATABASE_DSN=file:/data/tapm.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL) TAPM_PUBLIC_URL=http://broker.example.com:8680 TAPM_GITEA_URL=http://git.example.com:8680 diff --git a/README.md b/README.md index 8272a9f..1a9f584 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,30 @@ deployment to Let's Encrypt: ./manage.sh start ``` +## Optional Guacamole proxy + +To expose a Guacamole service through the same Nginx and certificate, add these +settings to the deployment `.env` before running the TLS bootstrap: + +```sh +EXTERNAL_GUACAMOLE_ENABLED=true +EXTERNAL_GUACAMOLE_DOMAIN=pvelabs.example.com +EXTERNAL_GUACAMOLE_UPSTREAM=http://10.0.0.10:8080 +``` + +The external address is served at the domain root while Nginx preserves +Guacamole's native `/guacamole/` context path internally. `bootstrap` adds the +hostname to the existing certificate (or creates it) and configures the +required unbuffered WebSocket proxy: + +```sh +./manage.sh bootstrap +./manage.sh start +``` + +The backend origin must be reachable from the Nginx container. The proxy is +available only with TLS enabled. + ## Local checks ```sh diff --git a/compose.external-guacamole.bootstrap.yaml b/compose.external-guacamole.bootstrap.yaml new file mode 100644 index 0000000..647fd2e --- /dev/null +++ b/compose.external-guacamole.bootstrap.yaml @@ -0,0 +1,6 @@ +services: + nginx: + volumes: + - ./deploy/nginx/templates/external-guacamole-bootstrap.conf.template:/etc/nginx/templates/20-external-guacamole.conf.template:ro + environment: + EXTERNAL_GUACAMOLE_DOMAIN: ${EXTERNAL_GUACAMOLE_DOMAIN} diff --git a/compose.external-guacamole.tls.yaml b/compose.external-guacamole.tls.yaml new file mode 100644 index 0000000..960aa7e --- /dev/null +++ b/compose.external-guacamole.tls.yaml @@ -0,0 +1,7 @@ +services: + nginx: + volumes: + - ./deploy/nginx/templates/external-guacamole-tls.conf.template:/etc/nginx/templates/20-external-guacamole.conf.template:ro + environment: + EXTERNAL_GUACAMOLE_DOMAIN: ${EXTERNAL_GUACAMOLE_DOMAIN} + EXTERNAL_GUACAMOLE_UPSTREAM: ${EXTERNAL_GUACAMOLE_UPSTREAM} diff --git a/deploy/nginx/templates/external-guacamole-bootstrap.conf.template b/deploy/nginx/templates/external-guacamole-bootstrap.conf.template new file mode 100644 index 0000000..538391e --- /dev/null +++ b/deploy/nginx/templates/external-guacamole-bootstrap.conf.template @@ -0,0 +1,13 @@ +server { + listen 80; + server_name ${EXTERNAL_GUACAMOLE_DOMAIN}; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 503 "TLS setup is in progress.\n"; + add_header Content-Type text/plain; + } +} diff --git a/deploy/nginx/templates/external-guacamole-tls.conf.template b/deploy/nginx/templates/external-guacamole-tls.conf.template new file mode 100644 index 0000000..534808e --- /dev/null +++ b/deploy/nginx/templates/external-guacamole-tls.conf.template @@ -0,0 +1,57 @@ +map $http_upgrade $guacamole_connection_upgrade { + default upgrade; + '' close; +} + +server { + listen 80; + server_name ${EXTERNAL_GUACAMOLE_DOMAIN}; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +server { + listen 443 ssl; + http2 on; + server_name ${EXTERNAL_GUACAMOLE_DOMAIN}; + + ssl_certificate /etc/nginx/ssl/${SSL_CERTIFICATE_FILE}; + ssl_certificate_key /etc/nginx/ssl/${SSL_CERTIFICATE_KEY_FILE}; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + ssl_protocols TLSv1.2 TLSv1.3; + + client_max_body_size 1g; + + add_header Strict-Transport-Security "max-age=31536000" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + + location / { + # Serve Guacamole at the domain root while retaining its native + # /guacamole/ context path on the internal Tomcat service. + proxy_pass ${EXTERNAL_GUACAMOLE_UPSTREAM}/guacamole/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $guacamole_connection_upgrade; + proxy_connect_timeout 30s; + proxy_send_timeout 30m; + proxy_read_timeout 30m; + proxy_request_buffering off; + proxy_buffering off; + } +} diff --git a/install.sh b/install.sh index 9417553..54ca752 100755 --- a/install.sh +++ b/install.sh @@ -36,8 +36,12 @@ for required_file in \ compose.yaml \ compose.http.yaml \ compose.tls.yaml \ + compose.external-guacamole.bootstrap.yaml \ + compose.external-guacamole.tls.yaml \ deploy/nginx/templates/http.conf.template \ - deploy/nginx/templates/tls.conf.template; do + deploy/nginx/templates/tls.conf.template \ + deploy/nginx/templates/external-guacamole-bootstrap.conf.template \ + deploy/nginx/templates/external-guacamole-tls.conf.template; do [[ -f "$required_file" ]] || fail "repository file is missing or not a regular file: ${required_file}" done @@ -111,6 +115,9 @@ umask 077 printf 'SSL_CERTIFICATE_DIR=%s\n' "$certificate_dir" printf 'SSL_CERTIFICATE_FILE=fullchain.pem\n' printf 'SSL_CERTIFICATE_KEY_FILE=privkey.pem\n\n' + printf 'EXTERNAL_GUACAMOLE_ENABLED=false\n' + printf 'EXTERNAL_GUACAMOLE_DOMAIN=\n' + printf 'EXTERNAL_GUACAMOLE_UPSTREAM=\n\n' printf 'TAPM_DATABASE_DSN=file:/data/tapm.db?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)&_pragma=journal_mode(WAL)\n' printf 'TAPM_PUBLIC_URL=%s://%s%s\n' "$public_scheme" "$broker_domain" "$public_port_suffix" printf 'TAPM_GITEA_URL=%s://%s%s\n' "$public_scheme" "$gitea_domain" "$public_port_suffix" diff --git a/manage.sh b/manage.sh index 6a4bc20..44f8dce 100755 --- a/manage.sh +++ b/manage.sh @@ -59,6 +59,11 @@ select_compose_stack() { else COMPOSE_FILES+=(-f compose.tls.yaml) fi + if [[ "$EXTERNAL_GUACAMOLE_ENABLED" == "true" ]]; then + [[ "$SSL_MODE" != "none" ]] || + fail "EXTERNAL_GUACAMOLE_ENABLED requires TLS; run ./manage.sh set-ssl letsencrypt first" + COMPOSE_FILES+=(-f compose.external-guacamole.tls.yaml) + fi } compose_stack() { @@ -86,6 +91,12 @@ require_stack_files() { require_regular_file compose.tls.yaml require_regular_file deploy/nginx/templates/tls.conf.template fi + if [[ "$EXTERNAL_GUACAMOLE_ENABLED" == "true" ]]; then + require_regular_file compose.external-guacamole.bootstrap.yaml + require_regular_file compose.external-guacamole.tls.yaml + require_regular_file deploy/nginx/templates/external-guacamole-bootstrap.conf.template + require_regular_file deploy/nginx/templates/external-guacamole-tls.conf.template + fi } broker_config_ready() { @@ -123,6 +134,38 @@ require_tls_certificate() { fail "TLS certificate key was not found at ${SSL_CERTIFICATE_DIR}/${SSL_CERTIFICATE_KEY_FILE}; run ./manage.sh bootstrap" } +valid_hostname() { + [[ "$1" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$ && + "$1" != *..* ]] +} + +valid_http_origin() { + [[ "$1" =~ ^http://[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?(:[0-9]{1,5})?$ ]] +} + +bootstrap_stack() { + local -a bootstrap_files=(-f compose.yaml) + + if [[ "$EXTERNAL_GUACAMOLE_ENABLED" == "true" ]]; then + bootstrap_files+=(-f compose.external-guacamole.bootstrap.yaml) + fi + docker compose "${bootstrap_files[@]}" "$@" +} + +issue_letsencrypt_certificate() { + local -a domain_args=(-d "$BROKER_DOMAIN" -d "$GITEA_DOMAIN") + + if [[ "$EXTERNAL_GUACAMOLE_ENABLED" == "true" ]]; then + domain_args+=(-d "$EXTERNAL_GUACAMOLE_DOMAIN") + fi + docker compose --profile tools run --rm certbot certonly \ + --webroot --webroot-path /var/www/certbot \ + --non-interactive --agree-tos --expand \ + --email "$LETSENCRYPT_EMAIL" \ + --cert-name "$BROKER_DOMAIN" \ + "${domain_args[@]}" +} + require_env() { [[ -f .env ]] || fail "copy .env.example to .env and configure it first" BROKER_DOMAIN="$(read_env_setting BROKER_DOMAIN)" @@ -134,12 +177,26 @@ require_env() { SSL_CERTIFICATE_DIR="$(read_env_setting SSL_CERTIFICATE_DIR)" SSL_CERTIFICATE_FILE="$(read_env_setting SSL_CERTIFICATE_FILE)" SSL_CERTIFICATE_KEY_FILE="$(read_env_setting SSL_CERTIFICATE_KEY_FILE)" + EXTERNAL_GUACAMOLE_ENABLED="$(read_env_setting EXTERNAL_GUACAMOLE_ENABLED)" + EXTERNAL_GUACAMOLE_DOMAIN="$(read_env_setting EXTERNAL_GUACAMOLE_DOMAIN)" + EXTERNAL_GUACAMOLE_UPSTREAM="$(read_env_setting EXTERNAL_GUACAMOLE_UPSTREAM)" + EXTERNAL_GUACAMOLE_ENABLED="${EXTERNAL_GUACAMOLE_ENABLED:-false}" SSL_CERTIFICATE_FILE="${SSL_CERTIFICATE_FILE:-fullchain.pem}" SSL_CERTIFICATE_KEY_FILE="${SSL_CERTIFICATE_KEY_FILE:-privkey.pem}" : "${BROKER_DOMAIN:?BROKER_DOMAIN is required}" : "${GITEA_DOMAIN:?GITEA_DOMAIN is required}" [[ "$BROKER_DOMAIN" != "$GITEA_DOMAIN" ]] || fail "BROKER_DOMAIN and GITEA_DOMAIN must be different hostnames" + case "$EXTERNAL_GUACAMOLE_ENABLED" in + true) + valid_hostname "$EXTERNAL_GUACAMOLE_DOMAIN" || + fail "EXTERNAL_GUACAMOLE_DOMAIN must be a valid hostname" + valid_http_origin "$EXTERNAL_GUACAMOLE_UPSTREAM" || + fail "EXTERNAL_GUACAMOLE_UPSTREAM must be an HTTP origin such as http://10.241.2.219:8080" + ;; + false) ;; + *) fail "EXTERNAL_GUACAMOLE_ENABLED must be true or false" ;; + esac if [[ -z "$GITEA_PUBLIC_URL" ]]; then if [[ "$SSL_MODE" == "none" ]]; then http_port="$(read_env_setting HTTP_PORT)" @@ -186,6 +243,7 @@ require_env() { ;; esac export GITEA_PUBLIC_URL SSL_CERTIFICATE_DIR SSL_CERTIFICATE_FILE SSL_CERTIFICATE_KEY_FILE + export EXTERNAL_GUACAMOLE_ENABLED EXTERNAL_GUACAMOLE_DOMAIN EXTERNAL_GUACAMOLE_UPSTREAM select_compose_stack require_stack_files } @@ -219,13 +277,8 @@ bootstrap) if [[ "$SSL_MODE" == "none" ]]; then compose_stack up -d gitea nginx elif [[ "$SSL_MODE" == "letsencrypt" ]]; then - docker compose up -d gitea nginx - docker compose --profile tools run --rm certbot certonly \ - --webroot --webroot-path /var/www/certbot \ - --non-interactive --agree-tos \ - --email "$LETSENCRYPT_EMAIL" \ - --cert-name "$BROKER_DOMAIN" \ - -d "$BROKER_DOMAIN" -d "$GITEA_DOMAIN" + bootstrap_stack up -d gitea nginx + issue_letsencrypt_certificate require_tls_certificate compose_stack up -d gitea nginx else diff --git a/tests/test-deployment-scripts.sh b/tests/test-deployment-scripts.sh index 901e2a1..606286b 100755 --- a/tests/test-deployment-scripts.sh +++ b/tests/test-deployment-scripts.sh @@ -14,6 +14,8 @@ cp -R \ "${TEST_ROOT}/compose.yaml" \ "${TEST_ROOT}/compose.http.yaml" \ "${TEST_ROOT}/compose.tls.yaml" \ + "${TEST_ROOT}/compose.external-guacamole.bootstrap.yaml" \ + "${TEST_ROOT}/compose.external-guacamole.tls.yaml" \ "${TEST_ROOT}/compose.direct.yaml" \ "${TEST_ROOT}/deploy" \ "$deployment/" @@ -67,6 +69,19 @@ grep -F -- '/var/cache/nginx:size=256m,mode=0755' \ printf 'FAIL: Nginx cache tmpfs is too small for Git pack streaming\n' >&2 exit 1 } +for external_file in \ + "${TEST_ROOT}/deploy/nginx/templates/external-guacamole-bootstrap.conf.template" \ + "${TEST_ROOT}/deploy/nginx/templates/external-guacamole-tls.conf.template"; do + [[ -f "$external_file" ]] || { + printf 'FAIL: missing Guacamole Nginx template: %s\n' "$external_file" >&2 + exit 1 + } +done +grep -F -- 'proxy_pass ${EXTERNAL_GUACAMOLE_UPSTREAM}/guacamole/;' \ + "${TEST_ROOT}/deploy/nginx/templates/external-guacamole-tls.conf.template" >/dev/null || { + printf 'FAIL: Guacamole root proxy does not retain the internal context path\n' >&2 + exit 1 +} for template in \ "${TEST_ROOT}/deploy/nginx/templates/http.conf.template" \ "${TEST_ROOT}/deploy/nginx/templates/tls.conf.template"; do @@ -105,6 +120,7 @@ assert_env 'TAPM_GITEA_URL=http://git.test:8780' assert_env 'TAPM_GITEA_INTERNAL_URL=http://gitea:3000' assert_env 'GITEA_PUBLIC_URL=http://git.test:8780/' assert_env 'TAPM_DEFAULT_DURATION=7d' +assert_env 'EXTERNAL_GUACAMOLE_ENABLED=false' grep -F -- '-f compose.yaml -f compose.http.yaml up -d gitea nginx' \ "$MOCK_DOCKER_LOG" >/dev/null || { printf 'FAIL: HTTP bootstrap did not select compose.http.yaml\n' >&2 @@ -131,6 +147,20 @@ assert_env 'SSL_MODE=letsencrypt' assert_env 'TAPM_PUBLIC_URL=https://broker.test' assert_env 'GITEA_PUBLIC_URL=https://git.test/' +printf '%s\n' \ + 'EXTERNAL_GUACAMOLE_ENABLED=true' \ + 'EXTERNAL_GUACAMOLE_DOMAIN=guacamole.test' \ + 'EXTERNAL_GUACAMOLE_UPSTREAM=http://10.0.0.10:8080' \ + >>"${deployment}/.env" +"${deployment}/manage.sh" status >/dev/null +grep -F -- '-f compose.yaml -f compose.tls.yaml -f compose.external-guacamole.tls.yaml ps' \ + "$MOCK_DOCKER_LOG" >/dev/null || { + printf 'FAIL: enabled Guacamole proxy was not added to the TLS stack\n' >&2 + exit 1 +} + +printf '%s\n' 'EXTERNAL_GUACAMOLE_ENABLED=false' >>"${deployment}/.env" + "${deployment}/manage.sh" set-ssl none >/dev/null assert_env 'SSL_MODE=none' assert_env 'TAPM_PUBLIC_URL=http://broker.test:8780'