From 8d8f413c3474cd9f30b797fbb3ef4bfd52c7745b Mon Sep 17 00:00:00 2001 From: Mark C Allen Date: Wed, 26 Aug 2026 00:47:38 +0000 Subject: [PATCH 01/10] Generate RDP password at startup --- .env.example | 3 ++- Dockerfile | 3 ++- INSTALL.md | 41 +++++++++++++++-------------------------- README.md | 22 ++++++++-------------- config/login-shell.sh | 21 +++++++++++++++++++++ config/startwm.sh | 2 +- docker-compose.yml | 2 +- scripts/entrypoint.sh | 21 ++++++++++++++++++++- 8 files changed, 70 insertions(+), 45 deletions(-) create mode 100644 config/login-shell.sh diff --git a/.env.example b/.env.example index cfc81d0..6ea6e05 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ RDP_USER=oauth -RDP_PASSWORD=replace-with-a-long-random-password +# Leave blank to generate a password at container startup. +RDP_PASSWORD= # Normally leave this blank. Only use --no-sandbox as a last-resort workaround. CHROMIUM_EXTRA_FLAGS= diff --git a/Dockerfile b/Dockerfile index dc6a3f3..6baaad7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,10 +30,11 @@ RUN adduser xrdp ssl-cert || true COPY config/supervisord.conf /etc/supervisor/conf.d/hatch.conf COPY config/startwm.sh /usr/local/bin/hatch-startwm COPY config/chromium-launch.sh /usr/local/bin/hatch-chromium +COPY config/login-shell.sh /usr/local/bin/hatch-login-shell COPY scripts/entrypoint.sh /usr/local/bin/hatch-entrypoint COPY scripts/healthcheck.sh /usr/local/bin/hatch-healthcheck -RUN chmod +x /usr/local/bin/hatch-entrypoint /usr/local/bin/hatch-startwm /usr/local/bin/hatch-chromium /usr/local/bin/hatch-healthcheck \ +RUN chmod +x /usr/local/bin/hatch-entrypoint /usr/local/bin/hatch-startwm /usr/local/bin/hatch-chromium /usr/local/bin/hatch-login-shell /usr/local/bin/hatch-healthcheck \ && cp /etc/xrdp/startwm.sh /etc/xrdp/startwm.sh.dist \ && printf '#!/bin/sh\nexec /usr/local/bin/hatch-startwm\n' > /etc/xrdp/startwm.sh \ && chmod +x /etc/xrdp/startwm.sh diff --git a/INSTALL.md b/INSTALL.md index c6a048b..1f74dae 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -38,21 +38,7 @@ cd hatch docker build -t hatch:local . ``` -Generate an RDP password before starting the container: - -```bash -if command -v openssl >/dev/null 2>&1; then - RDP_PASSWORD="$(openssl rand -hex 24)" -elif command -v python3 >/dev/null 2>&1; then - RDP_PASSWORD="$(python3 -c 'import secrets; print(secrets.token_hex(24))')" -else - echo "Install openssl or python3 to generate an RDP password." >&2 - exit 1 -fi -printf 'RDP user: oauth\nRDP password: %s\n' "$RDP_PASSWORD" -``` - -Treat the password as a secret. +Hatch generates an RDP password at container startup when `RDP_PASSWORD` is not set. Treat the generated password as a secret. ## Start and stop @@ -64,12 +50,13 @@ docker run -d \ --shm-size=1g \ --security-opt no-new-privileges:true \ -e RDP_USER=oauth \ - -e RDP_PASSWORD="$RDP_PASSWORD" \ hatch:local docker ps --filter name=hatch -docker logs -f hatch +docker logs hatch ``` +Use the username and generated password printed by `docker logs hatch`. The credentials are also written inside the container at `/var/log/hatch/rdp-credentials.log`. + Stop Hatch with: ```bash @@ -132,11 +119,17 @@ Hatch is disposable by default. Browser cookies and sessions disappear when the Check Hatch: ```bash -docker compose ps -docker compose logs hatch +docker ps --filter name=hatch +docker logs hatch docker exec hatch ps aux ``` +Show the generated credentials again: + +```bash +docker exec hatch cat /var/log/hatch/rdp-credentials.log +``` + Verify host networking: ```bash @@ -164,10 +157,6 @@ A protocol-specific `400` or `404` can still prove connectivity. `Connection ref ```bash git pull docker build --pull -t hatch:local . -if [ -z "${RDP_PASSWORD:-}" ]; then - echo "Set RDP_PASSWORD before restarting Hatch." >&2 - exit 1 -fi docker stop hatch docker rm hatch docker run -d \ @@ -177,8 +166,8 @@ docker run -d \ --shm-size=1g \ --security-opt no-new-privileges:true \ -e RDP_USER=oauth \ - -e RDP_PASSWORD="$RDP_PASSWORD" \ hatch:local +docker logs hatch ``` Rebuild regularly so the image receives Chromium and Debian security updates. @@ -191,12 +180,12 @@ If you prefer Docker Compose: cp .env.example .env ``` -Edit `.env` and set a long random `RDP_PASSWORD`, then start Hatch: +Edit `.env` only if you want to override defaults, then start Hatch: ```bash docker compose up -d --build docker compose ps -docker compose logs -f hatch +docker compose logs hatch ``` Stop Hatch with: diff --git a/README.md b/README.md index 6fbb396..8d9b85d 100644 --- a/README.md +++ b/README.md @@ -48,16 +48,6 @@ cd hatch docker build -t hatch:local . -if command -v openssl >/dev/null 2>&1; then - RDP_PASSWORD="$(openssl rand -hex 24)" -elif command -v python3 >/dev/null 2>&1; then - RDP_PASSWORD="$(python3 -c 'import secrets; print(secrets.token_hex(24))')" -else - echo "Install openssl or python3 to generate an RDP password." >&2 - exit 1 -fi -printf 'RDP user: oauth\nRDP password: %s\n' "$RDP_PASSWORD" - docker run -d \ --name hatch \ --network host \ @@ -65,8 +55,9 @@ docker run -d \ --shm-size=1g \ --security-opt no-new-privileges:true \ -e RDP_USER=oauth \ - -e RDP_PASSWORD="$RDP_PASSWORD" \ hatch:local + +docker logs hatch ``` Connect an RDP client to: @@ -75,7 +66,9 @@ Connect an RDP client to: :3389 ``` -Use the printed username and password. Stop Hatch with: +Use the username and generated password printed by `docker logs hatch`. The credentials are also written inside the container at `/var/log/hatch/rdp-credentials.log`. + +Stop Hatch with: ```bash docker stop hatch @@ -111,12 +104,12 @@ The provider redirects Chromium to `127.0.0.1:8765`. Because the container uses cp .env.example .env ``` -Edit `.env` and set a long random `RDP_PASSWORD`, then start Hatch: +Edit `.env` only if you want to override defaults, then start Hatch: ```bash docker compose up -d --build docker compose ps -docker compose logs -f hatch +docker compose logs hatch ``` Stop Hatch with: @@ -134,6 +127,7 @@ docker compose down ├── .env.example ├── config/ │ ├── chromium-launch.sh +│ ├── login-shell.sh │ ├── startwm.sh │ └── supervisord.conf ├── scripts/ diff --git a/config/login-shell.sh b/config/login-shell.sh new file mode 100644 index 0000000..3520aaf --- /dev/null +++ b/config/login-shell.sh @@ -0,0 +1,21 @@ +#!/bin/sh +set -eu +clear 2>/dev/null || true +cat <<'EOF' + HATCH + + _________ + __/ /| + _/__/_______/ | + / / | | + /___/________| / + \ \ | / + \___\_______|/ + \ open / + \______/ + +OAuth browser desktop is ready. +Chromium should open automatically. +EOF +echo +exec /bin/bash -l diff --git a/config/startwm.sh b/config/startwm.sh index b6ae268..1f73af1 100644 --- a/config/startwm.sh +++ b/config/startwm.sh @@ -12,5 +12,5 @@ openbox-session & OPENBOX_PID=$! sleep 1 /usr/local/bin/hatch-chromium & -xterm -geometry 100x28+20+20 -title "Hatch" & +xterm -geometry 100x28+20+20 -title "Hatch" -e /usr/local/bin/hatch-login-shell & wait "$OPENBOX_PID" diff --git a/docker-compose.yml b/docker-compose.yml index 9d0d432..288c11a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,7 +9,7 @@ services: shm_size: "1gb" environment: RDP_USER: ${RDP_USER:-oauth} - RDP_PASSWORD: ${RDP_PASSWORD:?Set RDP_PASSWORD in .env} + RDP_PASSWORD: ${RDP_PASSWORD:-} CHROMIUM_EXTRA_FLAGS: ${CHROMIUM_EXTRA_FLAGS:-} security_opt: - no-new-privileges:true diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index b88208e..b7aaae9 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -1,17 +1,36 @@ #!/bin/sh set -eu RDP_USER="${RDP_USER:-oauth}" -if [ -z "${RDP_PASSWORD:-}" ]; then echo >&2 "ERROR: RDP_PASSWORD is required."; exit 64; fi case "$RDP_USER" in *[!a-zA-Z0-9_-]*|'') echo >&2 "ERROR: invalid RDP_USER."; exit 64;; esac +GENERATED_PASSWORD=0 +if [ -z "${RDP_PASSWORD:-}" ]; then + RDP_PASSWORD="$(openssl rand -hex 24)" + GENERATED_PASSWORD=1 +fi if ! id "$RDP_USER" >/dev/null 2>&1; then useradd --create-home --shell /bin/bash "$RDP_USER"; fi printf '%s:%s\n' "$RDP_USER" "$RDP_PASSWORD" | chpasswd install -d -o "$RDP_USER" -g "$RDP_USER" -m 0700 "/home/$RDP_USER/.config" install -d -o "$RDP_USER" -g "$RDP_USER" -m 0700 "/home/$RDP_USER/.cache" +install -d -m 0700 /var/log/hatch +{ + echo "Hatch RDP credentials" + echo "Generated: $(date -u '+%Y-%m-%dT%H:%M:%SZ')" + echo "RDP user: $RDP_USER" + echo "RDP password: $RDP_PASSWORD" +} > /var/log/hatch/rdp-credentials.log +chmod 0600 /var/log/hatch/rdp-credentials.log if [ ! -s /etc/xrdp/key.pem ] || [ ! -s /etc/xrdp/cert.pem ]; then xrdp-keygen xrdp auto >/dev/null 2>&1 || true; fi mkdir -p /run/xrdp /run/dbus chmod 0755 /run/xrdp rm -f /run/xrdp/xrdp.pid /run/xrdp/xrdp-sesman.pid /run/dbus/pid echo "Hatch starting" echo "RDP user: $RDP_USER" +if [ "$GENERATED_PASSWORD" -eq 1 ]; then + echo "Generated RDP password: $RDP_PASSWORD" + echo "Generated RDP credentials were also written to /var/log/hatch/rdp-credentials.log" +else + echo "Using RDP password from RDP_PASSWORD" + echo "RDP credentials were also written to /var/log/hatch/rdp-credentials.log" +fi echo "Network requirement: Docker host networking for localhost OAuth callbacks" exec "$@" From 1df8de0f7ff33860550a0244e3fe2986f1615f47 Mon Sep 17 00:00:00 2001 From: Mark C Allen Date: Wed, 26 Aug 2026 01:43:56 +0000 Subject: [PATCH 02/10] Remove unused system DBus supervisor service --- Dockerfile | 2 +- config/supervisord.conf | 10 ---------- scripts/entrypoint.sh | 4 ++-- 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/Dockerfile b/Dockerfile index 6baaad7..81caefd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,7 +22,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ xrdp \ xterm \ && rm -rf /var/lib/apt/lists/* \ - && mkdir -p /run/xrdp /run/dbus /var/log/supervisor \ + && mkdir -p /run/xrdp /var/log/supervisor \ && chmod 0755 /run/xrdp RUN adduser xrdp ssl-cert || true diff --git a/config/supervisord.conf b/config/supervisord.conf index 2922f96..05c48ae 100644 --- a/config/supervisord.conf +++ b/config/supervisord.conf @@ -4,16 +4,6 @@ logfile=/dev/null logfile_maxbytes=0 pidfile=/run/supervisord.pid -[program:dbus] -command=/usr/bin/dbus-daemon --system --nofork --nopidfile -priority=10 -autostart=true -autorestart=true -stdout_logfile=/dev/stdout -stdout_logfile_maxbytes=0 -stderr_logfile=/dev/stderr -stderr_logfile_maxbytes=0 - [program:xrdp-sesman] command=/usr/sbin/xrdp-sesman --nodaemon priority=20 diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index b7aaae9..5728208 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -20,9 +20,9 @@ install -d -m 0700 /var/log/hatch } > /var/log/hatch/rdp-credentials.log chmod 0600 /var/log/hatch/rdp-credentials.log if [ ! -s /etc/xrdp/key.pem ] || [ ! -s /etc/xrdp/cert.pem ]; then xrdp-keygen xrdp auto >/dev/null 2>&1 || true; fi -mkdir -p /run/xrdp /run/dbus +mkdir -p /run/xrdp chmod 0755 /run/xrdp -rm -f /run/xrdp/xrdp.pid /run/xrdp/xrdp-sesman.pid /run/dbus/pid +rm -f /run/xrdp/xrdp.pid /run/xrdp/xrdp-sesman.pid echo "Hatch starting" echo "RDP user: $RDP_USER" if [ "$GENERATED_PASSWORD" -eq 1 ]; then From cdd134edc3d27aca9f6442617bb6bae239d21d7e Mon Sep 17 00:00:00 2001 From: Mark C Allen Date: Wed, 26 Aug 2026 04:15:05 +0000 Subject: [PATCH 03/10] Add Guacamole browser E2E test --- .env.example | 1 + Dockerfile | 1 + INSTALL.md | 25 +++++ README.md | 19 ++++ config/startwm.sh | 3 +- docker-compose.yml | 1 + scripts/e2e-guacamole.sh | 209 +++++++++++++++++++++++++++++++++++++++ scripts/entrypoint.sh | 3 + 8 files changed, 261 insertions(+), 1 deletion(-) create mode 100755 scripts/e2e-guacamole.sh diff --git a/.env.example b/.env.example index 6ea6e05..affe77a 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ RDP_USER=oauth # Leave blank to generate a password at container startup. RDP_PASSWORD= +HATCH_START_URL=about:blank # Normally leave this blank. Only use --no-sandbox as a last-resort workaround. CHROMIUM_EXTRA_FLAGS= diff --git a/Dockerfile b/Dockerfile index 81caefd..1709ad2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,7 @@ ENV DEBIAN_FRONTEND=noninteractive \ LANG=C.UTF-8 \ LC_ALL=C.UTF-8 \ RDP_USER=oauth \ + HATCH_START_URL=about:blank \ CHROMIUM_EXTRA_FLAGS="" RUN apt-get update && apt-get install -y --no-install-recommends \ diff --git a/INSTALL.md b/INSTALL.md index 1f74dae..d5beba3 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -78,6 +78,8 @@ ssh -L 13389:127.0.0.1:3389 user@server Then point your RDP client at `127.0.0.1:13389`. +Use an RDP client, not a web browser. `http://:3389/` will not work because TCP/3389 speaks the RDP protocol, not HTTP. + ## Complete an OAuth login Start the OAuth flow in your normal SSH shell: @@ -194,6 +196,29 @@ Stop Hatch with: docker compose down ``` +## Browser access with Guacamole + +RDP does not run directly over HTTPS. To use Hatch from a browser, run Apache Guacamole in front of the Hatch RDP service and expose Guacamole through HTTPS with your normal reverse proxy or load balancer. + +Use the Guacamole E2E test below as the reference wiring: Hatch, `guacd`, Guacamole, and Postgres run on the same Docker network, and Guacamole connects to Hatch on TCP/3389 with the generated RDP password from the Hatch logs. + +## E2E Guacamole test + +Run the smoke test from the repository root: + +```bash +scripts/e2e-guacamole.sh +``` + +It builds Hatch, starts temporary Hatch/Postgres/guacd/Guacamole containers, extracts the generated RDP password from Hatch logs, seeds Guacamole with a Hatch RDP connection, logs into the Guacamole web UI with Playwright, opens the RDP session, and verifies Chromium opens `https://www.google.com`. + +Required host tools: + +- Docker +- `nc` +- Node.js +- npm + ## GitHub Container Registry The included `.github/workflows/docker.yml` builds the image on pull requests and publishes it to GHCR on pushes to `main` and version tags. The published image name is: diff --git a/README.md b/README.md index 8d9b85d..52a4c6b 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,8 @@ Connect an RDP client to: :3389 ``` +Use an RDP client, not a web browser. `http://:3389/` will not work because TCP/3389 speaks the RDP protocol, not HTTP. + Use the username and generated password printed by `docker logs hatch`. The credentials are also written inside the container at `/var/log/hatch/rdp-credentials.log`. Stop Hatch with: @@ -118,6 +120,22 @@ Stop Hatch with: docker compose down ``` +## Browser access with Guacamole + +RDP does not run directly over HTTPS. To use Hatch from a browser, run Apache Guacamole in front of the Hatch RDP service and expose Guacamole through HTTPS with your normal reverse proxy or load balancer. + +Use the Guacamole E2E test below as the reference wiring: Hatch, `guacd`, Guacamole, and Postgres run on the same Docker network, and Guacamole connects to Hatch on TCP/3389 with the generated RDP password from the Hatch logs. + +## E2E Guacamole test + +The Guacamole smoke test builds Hatch, starts temporary Hatch/Postgres/guacd/Guacamole containers, opens Guacamole in Playwright, logs into the Guacamole web UI, opens the Hatch RDP connection, and verifies Chromium starts at Google: + +```bash +scripts/e2e-guacamole.sh +``` + +The test requires Docker, `nc`, Node.js, npm, and network access to pull the official Guacamole images and Playwright package when they are not already cached. + ## Project layout ```text @@ -131,6 +149,7 @@ docker compose down │ ├── startwm.sh │ └── supervisord.conf ├── scripts/ +│ ├── e2e-guacamole.sh │ ├── entrypoint.sh │ └── healthcheck.sh └── .github/workflows/docker.yml diff --git a/config/startwm.sh b/config/startwm.sh index 1f73af1..9dd7ec9 100644 --- a/config/startwm.sh +++ b/config/startwm.sh @@ -11,6 +11,7 @@ chmod 700 "$XDG_RUNTIME_DIR" openbox-session & OPENBOX_PID=$! sleep 1 -/usr/local/bin/hatch-chromium & +START_URL="$(cat /etc/hatch/start-url 2>/dev/null || printf '%s\n' about:blank)" +/usr/local/bin/hatch-chromium "$START_URL" & xterm -geometry 100x28+20+20 -title "Hatch" -e /usr/local/bin/hatch-login-shell & wait "$OPENBOX_PID" diff --git a/docker-compose.yml b/docker-compose.yml index 288c11a..11157a9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,7 @@ services: environment: RDP_USER: ${RDP_USER:-oauth} RDP_PASSWORD: ${RDP_PASSWORD:-} + HATCH_START_URL: ${HATCH_START_URL:-about:blank} CHROMIUM_EXTRA_FLAGS: ${CHROMIUM_EXTRA_FLAGS:-} security_opt: - no-new-privileges:true diff --git a/scripts/e2e-guacamole.sh b/scripts/e2e-guacamole.sh new file mode 100755 index 0000000..3901d71 --- /dev/null +++ b/scripts/e2e-guacamole.sh @@ -0,0 +1,209 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE="${HATCH_E2E_IMAGE:-hatch:e2e}" +PREFIX="${HATCH_E2E_NAME:-hatch-e2e}" +NETWORK="${PREFIX}-net" +GUAC_PORT="${HATCH_E2E_GUAC_PORT:-18080}" +URL="${HATCH_E2E_URL:-https://www.google.com}" +RDP_USER="${HATCH_E2E_RDP_USER:-oauth}" +GUAC_VERSION="${HATCH_E2E_GUAC_VERSION:-1.6.0}" +POSTGRES_IMAGE="${HATCH_E2E_POSTGRES_IMAGE:-postgres:16-alpine}" +DB_NAME="guacamole_db" +DB_USER="guacamole_user" +DB_PASSWORD="guacamole_pass" +TMP_DIR="$(mktemp -d)" + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "ERROR: $1 is required." >&2 + exit 2 + fi +} + +cleanup() { + docker rm -f "${PREFIX}-guacamole" "${PREFIX}-guacd" "${PREFIX}-postgres" "${PREFIX}-hatch" >/dev/null 2>&1 || true + docker network rm "$NETWORK" >/dev/null 2>&1 || true + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +require_command docker +require_command nc +require_command node +require_command npm + +cleanup +mkdir -p "$TMP_DIR" +docker network create "$NETWORK" >/dev/null + +docker build -t "$IMAGE" . + +docker run -d \ + --name "${PREFIX}-hatch" \ + --network "$NETWORK" \ + --shm-size=1g \ + --security-opt no-new-privileges:true \ + -e RDP_USER="$RDP_USER" \ + -e HATCH_START_URL="$URL" \ + "$IMAGE" >/dev/null + +for _ in $(seq 1 30); do + if [ "$(docker inspect "${PREFIX}-hatch" --format '{{.State.Health.Status}}' 2>/dev/null || true)" = "healthy" ]; then + break + fi + sleep 1 +done + +if [ "$(docker inspect "${PREFIX}-hatch" --format '{{.State.Health.Status}}')" != "healthy" ]; then + docker logs "${PREFIX}-hatch" >&2 || true + echo "ERROR: Hatch container did not become healthy." >&2 + exit 1 +fi + +RDP_PASSWORD="$(docker logs "${PREFIX}-hatch" 2>&1 | sed -n 's/^Generated RDP password: //p' | tail -n 1)" +if [ -z "$RDP_PASSWORD" ]; then + docker logs "${PREFIX}-hatch" >&2 || true + echo "ERROR: Could not extract generated RDP password from Hatch logs." >&2 + exit 1 +fi + +docker run -d \ + --name "${PREFIX}-postgres" \ + --network "$NETWORK" \ + -e POSTGRES_DB="$DB_NAME" \ + -e POSTGRES_USER="$DB_USER" \ + -e POSTGRES_PASSWORD="$DB_PASSWORD" \ + "$POSTGRES_IMAGE" >/dev/null + +for _ in $(seq 1 45); do + if docker exec "${PREFIX}-postgres" pg_isready -U "$DB_USER" -d "$DB_NAME" >/dev/null 2>&1; then + break + fi + sleep 1 +done + +if ! docker exec "${PREFIX}-postgres" pg_isready -U "$DB_USER" -d "$DB_NAME" >/dev/null 2>&1; then + docker logs "${PREFIX}-postgres" >&2 || true + echo "ERROR: Postgres did not become ready." >&2 + exit 1 +fi + +docker run --rm "guacamole/guacamole:${GUAC_VERSION}" /opt/guacamole/bin/initdb.sh --postgresql > "$TMP_DIR/initdb.sql" +docker exec -i "${PREFIX}-postgres" psql -U "$DB_USER" -d "$DB_NAME" < "$TMP_DIR/initdb.sql" >/dev/null + +cat > "$TMP_DIR/connection.sql" </dev/null + +docker run -d \ + --name "${PREFIX}-guacd" \ + --network "$NETWORK" \ + "guacamole/guacd:${GUAC_VERSION}" >/dev/null + +docker run -d \ + --name "${PREFIX}-guacamole" \ + --network "$NETWORK" \ + -p "127.0.0.1:${GUAC_PORT}:8080" \ + -e GUACD_HOSTNAME="${PREFIX}-guacd" \ + -e POSTGRESQL_HOSTNAME="${PREFIX}-postgres" \ + -e POSTGRESQL_DATABASE="$DB_NAME" \ + -e POSTGRESQL_USER="$DB_USER" \ + -e POSTGRESQL_PASSWORD="$DB_PASSWORD" \ + "guacamole/guacamole:${GUAC_VERSION}" >/dev/null + +for _ in $(seq 1 60); do + if nc -z 127.0.0.1 "$GUAC_PORT" >/dev/null 2>&1; then + break + fi + sleep 1 +done + +if ! nc -z 127.0.0.1 "$GUAC_PORT" >/dev/null 2>&1; then + docker logs "${PREFIX}-guacamole" >&2 || true + echo "ERROR: Guacamole did not open port $GUAC_PORT." >&2 + exit 1 +fi + +cat > "$TMP_DIR/guac-smoke.mjs" <<'JS' +import { chromium } from 'playwright'; + +const baseUrl = process.env.GUAC_URL; + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); + +await page.goto(`${baseUrl}/guacamole/`, { waitUntil: 'domcontentloaded' }); +await page.getByLabel(/username/i).fill('guacadmin'); +await page.getByLabel(/password/i).fill('guacadmin'); +await page.getByRole('button', { name: /login/i }).click(); + +await page.getByText('Hatch', { exact: true }).waitFor({ timeout: 30000 }); +await page.getByText('Hatch', { exact: true }).click(); +await page.locator('canvas').first().waitFor({ timeout: 60000 }); + +await page.waitForTimeout(5000); + +await browser.close(); +JS + +GUAC_URL="http://127.0.0.1:${GUAC_PORT}" \ + sh -c 'cd "$1" && npm init -y >/dev/null && npm install playwright@1.57.0 >/dev/null && npx playwright install chromium >/dev/null && node guac-smoke.mjs' sh "$TMP_DIR" + +for _ in $(seq 1 45); do + if docker exec "${PREFIX}-hatch" sh -lc "pgrep -af 'chromium.*${URL}' >/dev/null"; then + echo "Guacamole E2E succeeded: browser login reached Hatch RDP and Chromium opened $URL" + exit 0 + fi + sleep 1 +done + +echo "ERROR: Chromium process for $URL was not observed in Hatch." >&2 +echo "---- Hatch processes ----" >&2 +docker exec "${PREFIX}-hatch" ps aux >&2 || true +echo "---- Hatch logs ----" >&2 +docker logs "${PREFIX}-hatch" >&2 || true +echo "---- guacd logs ----" >&2 +docker logs "${PREFIX}-guacd" >&2 || true +echo "---- Guacamole logs ----" >&2 +docker logs "${PREFIX}-guacamole" >&2 || true +exit 1 diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 5728208..a6ebcc4 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -11,6 +11,9 @@ if ! id "$RDP_USER" >/dev/null 2>&1; then useradd --create-home --shell /bin/bas printf '%s:%s\n' "$RDP_USER" "$RDP_PASSWORD" | chpasswd install -d -o "$RDP_USER" -g "$RDP_USER" -m 0700 "/home/$RDP_USER/.config" install -d -o "$RDP_USER" -g "$RDP_USER" -m 0700 "/home/$RDP_USER/.cache" +install -d -m 0755 /etc/hatch +printf '%s\n' "${HATCH_START_URL:-about:blank}" > /etc/hatch/start-url +chmod 0644 /etc/hatch/start-url install -d -m 0700 /var/log/hatch { echo "Hatch RDP credentials" From 25262dc35cef8f5020569e59a4cbffb527c8e0ed Mon Sep 17 00:00:00 2001 From: Mark C Allen Date: Wed, 26 Aug 2026 04:17:04 +0000 Subject: [PATCH 04/10] Validate RDP password input --- scripts/entrypoint.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index a6ebcc4..015ecdb 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -7,6 +7,10 @@ if [ -z "${RDP_PASSWORD:-}" ]; then RDP_PASSWORD="$(openssl rand -hex 24)" GENERATED_PASSWORD=1 fi +case "$RDP_PASSWORD" in + *:*|*' +'*) echo >&2 "ERROR: RDP_PASSWORD must not contain ':' or newlines."; exit 64;; +esac if ! id "$RDP_USER" >/dev/null 2>&1; then useradd --create-home --shell /bin/bash "$RDP_USER"; fi printf '%s:%s\n' "$RDP_USER" "$RDP_PASSWORD" | chpasswd install -d -o "$RDP_USER" -g "$RDP_USER" -m 0700 "/home/$RDP_USER/.config" @@ -17,7 +21,7 @@ chmod 0644 /etc/hatch/start-url install -d -m 0700 /var/log/hatch { echo "Hatch RDP credentials" - echo "Generated: $(date -u '+%Y-%m-%dT%H:%M:%SZ')" + echo "Written: $(date -u '+%Y-%m-%dT%H:%M:%SZ')" echo "RDP user: $RDP_USER" echo "RDP password: $RDP_PASSWORD" } > /var/log/hatch/rdp-credentials.log From 99bb2e62233b6b1693680b9d9cfd9e979e46b808 Mon Sep 17 00:00:00 2001 From: Mark C Allen Date: Wed, 26 Aug 2026 13:56:22 +0000 Subject: [PATCH 05/10] Document Guacamole browser access --- README.md | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 52a4c6b..ea9778f 100644 --- a/README.md +++ b/README.md @@ -122,9 +122,34 @@ docker compose down ## Browser access with Guacamole -RDP does not run directly over HTTPS. To use Hatch from a browser, run Apache Guacamole in front of the Hatch RDP service and expose Guacamole through HTTPS with your normal reverse proxy or load balancer. +RDP does not run directly over HTTPS, so opening `http://:3389/` in a browser will not work. To use Hatch from a browser, run Apache Guacamole in front of the Hatch RDP service and expose Guacamole through HTTPS with your normal reverse proxy or load balancer. -Use the Guacamole E2E test below as the reference wiring: Hatch, `guacd`, Guacamole, and Postgres run on the same Docker network, and Guacamole connects to Hatch on TCP/3389 with the generated RDP password from the Hatch logs. +The production shape is: + +```text +Browser + | + | HTTPS + v +Reverse proxy / load balancer + | + | HTTP to Guacamole + v +Guacamole web app -> guacd -> Hatch RDP on TCP/3389 +``` + +Configure the Guacamole connection with: + +- Protocol: `RDP` +- Hostname: the Linux host address that exposes Hatch on TCP/3389 +- Port: `3389` +- Username: the `RDP_USER` value, `oauth` by default +- Password: the generated password from `docker logs hatch` or `/var/log/hatch/rdp-credentials.log` +- Ignore server certificate: enabled + +Keep TCP/3389 private to the host, VPN, or container network. Only the Guacamole HTTPS endpoint should be exposed to browser users. + +Use the Guacamole E2E test below as the reference wiring for automation: Hatch, `guacd`, Guacamole, and Postgres run on the same Docker network, and Guacamole connects to Hatch on TCP/3389 with the generated RDP password from the Hatch logs. ## E2E Guacamole test From 9dcadf71a7225ed0fd8f1a751a768f7e5eaf24ae Mon Sep 17 00:00:00 2001 From: Mark C Allen Date: Wed, 26 Aug 2026 15:17:56 +0000 Subject: [PATCH 06/10] Add HTTPS Guacamole container access --- .env.example | 10 ++ Dockerfile | 80 ++++++++++++- INSTALL.md | 217 ++++++++++++++++++++---------------- PRD.md | 21 ++++ README.md | 180 ++++++++++++------------------ config/supervisord.conf | 30 +++++ docker-compose.yml | 10 +- scripts/e2e-guacamole.sh | 161 ++++++++------------------ scripts/entrypoint.sh | 5 +- scripts/guacamole-config.sh | 106 ++++++++++++++++++ scripts/healthcheck.sh | 3 + 11 files changed, 499 insertions(+), 324 deletions(-) create mode 100644 PRD.md create mode 100755 scripts/guacamole-config.sh diff --git a/.env.example b/.env.example index affe77a..1e670a3 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,16 @@ RDP_USER=oauth # Leave blank to generate a password at container startup. RDP_PASSWORD= +# Leave blank to reuse the RDP credentials for Guacamole. +GUAC_USER= +GUAC_PASSWORD= +HATCH_HTTPS_PORT=443 +HATCH_HTTPS_HOST_PORT=8443 HATCH_START_URL=about:blank # Normally leave this blank. Only use --no-sandbox as a last-resort workaround. CHROMIUM_EXTRA_FLAGS= +# Defaults generate a self-signed certificate inside the container. +HATCH_TLS_CERT=/etc/hatch/tls/hatch.crt +HATCH_TLS_KEY=/etc/hatch/tls/hatch.key +HATCH_TLS_CN=hatch.local +HATCH_TLS_DAYS=365 diff --git a/Dockerfile b/Dockerfile index 1709ad2..c59ce1d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,20 +1,79 @@ -FROM debian:13-slim +FROM debian:12-slim AS guacd-builder + +ARG GUACAMOLE_VERSION=1.6.0 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + ca-certificates \ + freerdp2-dev \ + libcairo2-dev \ + libjpeg62-turbo-dev \ + libossp-uuid-dev \ + libpango1.0-dev \ + libpng-dev \ + libssh2-1-dev \ + libssl-dev \ + libswscale-dev \ + libtelnet-dev \ + libtool-bin \ + libvncserver-dev \ + libwebsockets-dev \ + uuid-dev \ + wget \ + && wget -O /tmp/guacamole-server.tar.gz "https://archive.apache.org/dist/guacamole/${GUACAMOLE_VERSION}/source/guacamole-server-${GUACAMOLE_VERSION}.tar.gz" \ + && mkdir -p /tmp/guacamole-server \ + && tar -xzf /tmp/guacamole-server.tar.gz -C /tmp/guacamole-server --strip-components=1 \ + && cd /tmp/guacamole-server \ + && CFLAGS="-Wno-error=deprecated-declarations" ./configure --prefix=/usr/local \ + && make -j"$(nproc)" \ + && make install DESTDIR=/opt/guacd-root + +FROM guacamole/guacamole:1.6.0 AS guacamole-web + +FROM debian:12-slim ENV DEBIAN_FRONTEND=noninteractive \ LANG=C.UTF-8 \ LC_ALL=C.UTF-8 \ RDP_USER=oauth \ + HATCH_HTTPS_PORT=443 \ HATCH_START_URL=about:blank \ - CHROMIUM_EXTRA_FLAGS="" + CHROMIUM_EXTRA_FLAGS="" \ + CATALINA_HOME=/usr/local/tomcat \ + GUACAMOLE_HOME=/etc/guacamole \ + GUACD_HOSTNAME=127.0.0.1 \ + GUACD_PORT=4822 \ + WEBAPP_CONTEXT=guacamole \ + PATH=/usr/local/tomcat/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + LD_LIBRARY_PATH=/usr/local/lib RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates \ chromium \ chromium-sandbox \ dbus-x11 \ + default-jre-headless \ fonts-liberation \ + libcairo2 \ + libfreerdp-client2-2 \ + libfreerdp2-2 \ + libjpeg62-turbo \ + libossp-uuid16 \ + libpango-1.0-0 \ + libpangocairo-1.0-0 \ + libpng16-16 \ + libssh2-1 \ + libswscale6 \ + libtelnet2 \ + libvncclient1 \ + libwebsockets17 \ + libwinpr2-2 \ locales \ + nginx-light \ openbox \ + openssl \ procps \ supervisor \ x11-xserver-utils \ @@ -23,24 +82,33 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ xrdp \ xterm \ && rm -rf /var/lib/apt/lists/* \ - && mkdir -p /run/xrdp /var/log/supervisor \ + && mkdir -p /run/xrdp /var/log/supervisor /etc/guacamole /etc/hatch/tls \ && chmod 0755 /run/xrdp RUN adduser xrdp ssl-cert || true +COPY --from=guacamole-web /opt/guacamole /opt/guacamole +COPY --from=guacamole-web /usr/local/tomcat /usr/local/tomcat +COPY --from=guacd-builder /opt/guacd-root/usr/local /usr/local +COPY --from=guacd-builder /opt/guacd-root/usr/lib/x86_64-linux-gnu/freerdp2 /usr/lib/x86_64-linux-gnu/freerdp2 + COPY config/supervisord.conf /etc/supervisor/conf.d/hatch.conf COPY config/startwm.sh /usr/local/bin/hatch-startwm COPY config/chromium-launch.sh /usr/local/bin/hatch-chromium COPY config/login-shell.sh /usr/local/bin/hatch-login-shell +COPY scripts/guacamole-config.sh /usr/local/bin/hatch-guacamole-config COPY scripts/entrypoint.sh /usr/local/bin/hatch-entrypoint COPY scripts/healthcheck.sh /usr/local/bin/hatch-healthcheck -RUN chmod +x /usr/local/bin/hatch-entrypoint /usr/local/bin/hatch-startwm /usr/local/bin/hatch-chromium /usr/local/bin/hatch-login-shell /usr/local/bin/hatch-healthcheck \ +RUN ldconfig \ + && rm -f /etc/nginx/sites-enabled/default /etc/nginx/conf.d/default.conf \ + && chmod +x /usr/local/bin/hatch-entrypoint /usr/local/bin/hatch-startwm /usr/local/bin/hatch-chromium /usr/local/bin/hatch-login-shell /usr/local/bin/hatch-guacamole-config /usr/local/bin/hatch-healthcheck \ && cp /etc/xrdp/startwm.sh /etc/xrdp/startwm.sh.dist \ && printf '#!/bin/sh\nexec /usr/local/bin/hatch-startwm\n' > /etc/xrdp/startwm.sh \ - && chmod +x /etc/xrdp/startwm.sh + && chmod +x /etc/xrdp/startwm.sh \ + && sed -i 's/^port=3389$/port=tcp:\/\/127.0.0.1:3389/' /etc/xrdp/xrdp.ini -EXPOSE 3389 +EXPOSE 443 HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD ["/usr/local/bin/hatch-healthcheck"] diff --git a/INSTALL.md b/INSTALL.md index d5beba3..14f58d1 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -1,34 +1,33 @@ # Hatch: Installation and Operations Guide -Hatch gives a headless Linux server a small Chromium desktop over RDP. It is designed for interactive OAuth flows where a CLI, MCP server, or agent starts a callback listener such as `http://127.0.0.1:8765/callback`. +Hatch gives a headless Linux server a small Chromium desktop in the browser. It is designed for interactive OAuth flows where a CLI, MCP server, or agent starts a callback listener such as `http://127.0.0.1:8765/callback`. ## Architecture ```text -Mac / PC / iPad - | - | RDP over Tailscale, VPN, or SSH - v -Linux server +Browser | - +-- MCP/CLI --> 127.0.0.1: + | HTTPS + v +Docker: hatch | - +-- Docker --network=host - | - +-- xrdp -> Xorg -> Openbox -> Chromium - | - +--> 127.0.0.1: + +-- nginx :443 + | + +-- Guacamole :8080 + | + +-- guacd :4822 + | + +-- xrdp 127.0.0.1:3389 -> Xorg -> Openbox -> Chromium ``` -Host networking is essential. Chromium shares the Linux host network namespace, so a redirect to loopback reaches the OAuth callback process running directly on the server. +For OAuth callback mode, run the container with Docker host networking so Chromium's `127.0.0.1` is the Linux host's loopback interface. ## Requirements - Linux host with Docker Engine -- Docker Compose v2 - About 1 GB free memory while Chromium runs - Private connectivity through Tailscale, another VPN, or SSH -- An RDP client +- A browser that can accept a self-signed certificate, unless you provide your own TLS certificate ## Install @@ -38,14 +37,14 @@ cd hatch docker build -t hatch:local . ``` -Hatch generates an RDP password at container startup when `RDP_PASSWORD` is not set. Treat the generated password as a secret. +Hatch generates an RDP password at container startup when `RDP_PASSWORD` is not set. Guacamole uses the same credentials by default. -## Start and stop +## Start and Stop ```bash docker run -d \ --name hatch \ - --network host \ + -p 8443:443 \ --restart unless-stopped \ --shm-size=1g \ --security-opt no-new-privileges:true \ @@ -55,7 +54,13 @@ docker ps --filter name=hatch docker logs hatch ``` -Use the username and generated password printed by `docker logs hatch`. The credentials are also written inside the container at `/var/log/hatch/rdp-credentials.log`. +Open `https://:8443/guacamole/` and sign in with the `Guacamole user` and `Guacamole password` printed by `docker logs hatch`. + +The generated RDP credentials are also written inside the container: + +```bash +docker exec hatch cat /var/log/hatch/rdp-credentials.log +``` Stop Hatch with: @@ -64,57 +69,97 @@ docker stop hatch docker rm hatch ``` -## Secure RDP access - -Do not expose TCP/3389 directly to the public Internet. +## OAuth Callback Mode -With Tailscale, connect your RDP client to the server's Tailscale address on port 3389. Restrict the port with Tailscale grants/ACLs and the host firewall. - -An SSH tunnel is another option: +Start the OAuth flow in your normal SSH shell: ```bash -ssh -L 13389:127.0.0.1:3389 user@server +my-mcp-server login ``` -Then point your RDP client at `127.0.0.1:13389`. +When it prints an authorization URL and waits for its local callback, open Hatch in your browser. Paste the authorization URL into Chromium and complete authentication. -Use an RDP client, not a web browser. `http://:3389/` will not work because TCP/3389 speaks the RDP protocol, not HTTP. +If the provider redirects to a URL such as: -## Complete an OAuth login +```text +http://127.0.0.1:8765/callback?code=... +``` -Start the OAuth flow in your normal SSH shell: +run Hatch with host networking: ```bash -my-mcp-server login +docker run -d \ + --name hatch \ + --network host \ + --restart unless-stopped \ + --shm-size=1g \ + --security-opt no-new-privileges:true \ + -e RDP_USER=oauth \ + -e HATCH_HTTPS_PORT=8443 \ + hatch:local ``` -When it prints an authorization URL and waits for its local callback, connect to Hatch using RDP. Chromium starts automatically. Paste the authorization URL into Chromium and complete authentication. +Open `https://:8443/guacamole/`. Docker host networking ignores `-p`, so `HATCH_HTTPS_PORT` controls the host port in this mode. -The provider can redirect to a URL such as: +## TLS -```text -http://127.0.0.1:8765/callback?code=... -``` +By default Hatch generates a self-signed certificate in `/etc/hatch/tls`. Set these variables to customize it: -Because Hatch uses `network_mode: host`, that request reaches the listener on the Linux host. The callback port does not need to be exposed publicly. +```dotenv +HATCH_TLS_CN=hatch.local +HATCH_TLS_DAYS=365 +``` -## Why bridge networking is not used +To provide your own certificate: -With normal Docker bridge networking, container `127.0.0.1` and host `127.0.0.1` are different network namespaces. That breaks OAuth clients which require a loopback redirect. Do not replace `network_mode: host` with a simple `ports:` mapping for this use case. +```bash +docker run -d \ + --name hatch \ + -p 8443:443 \ + -v /srv/hatch/tls:/tls:ro \ + -e HATCH_TLS_CERT=/tls/fullchain.pem \ + -e HATCH_TLS_KEY=/tls/privkey.pem \ + hatch:local +``` -## Chromium sandbox +Certbot HTTP-01 validation requires public port 80. Use DNS-01 validation if you need certificate issuance without opening port 80, or terminate TLS in a reverse proxy that already owns certificate renewal. -Hatch keeps Chromium's sandbox enabled. If an unusually restrictive host prevents Chromium from starting, diagnose the host first. As a last resort, set this in `.env`: +## Configuration ```dotenv -CHROMIUM_EXTRA_FLAGS=--no-sandbox +RDP_USER=oauth +RDP_PASSWORD= +GUAC_USER= +GUAC_PASSWORD= +HATCH_HTTPS_PORT=443 +HATCH_START_URL=about:blank +CHROMIUM_EXTRA_FLAGS= +HATCH_TLS_CERT=/etc/hatch/tls/hatch.crt +HATCH_TLS_KEY=/etc/hatch/tls/hatch.key +HATCH_TLS_CN=hatch.local +HATCH_TLS_DAYS=365 ``` -Disabling the sandbox reduces browser security. +Leave `RDP_PASSWORD` blank to generate a password at startup. Leave `GUAC_USER` and `GUAC_PASSWORD` blank to reuse the RDP credentials for Guacamole. -## Browser persistence +## Docker Compose Option -Hatch is disposable by default. Browser cookies and sessions disappear when the container is replaced. If persistent sessions are required, mount `/home/oauth/.config/chromium` as a Docker volume. Persistent browser profiles contain sensitive authentication material and must be protected accordingly. +```bash +cp .env.example .env +docker compose up -d --build +docker compose ps +docker compose logs hatch +``` + +The compose file maps host port `${HATCH_HTTPS_HOST_PORT:-8443}` to container port `${HATCH_HTTPS_PORT:-443}`. + +Stop Hatch with: + +```bash +docker compose down +``` + +Use the explicit `docker run --network host` command for OAuth callback mode because compose port mappings are ignored when host networking is enabled. ## Troubleshooting @@ -126,19 +171,26 @@ docker logs hatch docker exec hatch ps aux ``` -Show the generated credentials again: +Verify the HTTPS endpoint: ```bash -docker exec hatch cat /var/log/hatch/rdp-credentials.log +curl -kI https://127.0.0.1:8443/guacamole/ ``` -Verify host networking: +Verify listeners inside the container: ```bash -docker inspect hatch --format '{{.HostConfig.NetworkMode}}' +docker exec hatch ss -ltnp ``` -The result should be `host`. +Expected internal ports: + +```text +0.0.0.0:443 nginx HTTPS +127.0.0.1:4822 guacd +127.0.0.1:3389 xrdp +*:8080 Guacamole Tomcat +``` Verify the OAuth listener from the Linux host: @@ -146,7 +198,7 @@ Verify the OAuth listener from the Linux host: ss -lntp | grep 8765 ``` -From the xterm inside Hatch, test it with: +From an xterm inside Hatch, test it with: ```bash curl -v http://127.0.0.1:8765/ @@ -154,6 +206,20 @@ curl -v http://127.0.0.1:8765/ A protocol-specific `400` or `404` can still prove connectivity. `Connection refused` means nothing is listening on that address and port. +## Chromium Sandbox + +Hatch keeps Chromium's sandbox enabled. If an unusually restrictive host prevents Chromium from starting, diagnose the host first. As a last resort, set: + +```dotenv +CHROMIUM_EXTRA_FLAGS=--no-sandbox +``` + +Disabling the sandbox reduces browser security. + +## Browser Persistence + +Hatch is disposable by default. Browser cookies and sessions disappear when the container is replaced. If persistent sessions are required, mount `/home/oauth/.config/chromium` as a Docker volume. Persistent browser profiles contain sensitive authentication material and must be protected accordingly. + ## Updating ```bash @@ -163,7 +229,7 @@ docker stop hatch docker rm hatch docker run -d \ --name hatch \ - --network host \ + -p 8443:443 \ --restart unless-stopped \ --shm-size=1g \ --security-opt no-new-privileges:true \ @@ -172,52 +238,15 @@ docker run -d \ docker logs hatch ``` -Rebuild regularly so the image receives Chromium and Debian security updates. - -## Docker Compose option - -If you prefer Docker Compose: - -```bash -cp .env.example .env -``` - -Edit `.env` only if you want to override defaults, then start Hatch: - -```bash -docker compose up -d --build -docker compose ps -docker compose logs hatch -``` +Rebuild regularly so the image receives Chromium, Guacamole, and Debian security updates. -Stop Hatch with: - -```bash -docker compose down -``` - -## Browser access with Guacamole - -RDP does not run directly over HTTPS. To use Hatch from a browser, run Apache Guacamole in front of the Hatch RDP service and expose Guacamole through HTTPS with your normal reverse proxy or load balancer. - -Use the Guacamole E2E test below as the reference wiring: Hatch, `guacd`, Guacamole, and Postgres run on the same Docker network, and Guacamole connects to Hatch on TCP/3389 with the generated RDP password from the Hatch logs. - -## E2E Guacamole test - -Run the smoke test from the repository root: +## E2E Guacamole Test ```bash scripts/e2e-guacamole.sh ``` -It builds Hatch, starts temporary Hatch/Postgres/guacd/Guacamole containers, extracts the generated RDP password from Hatch logs, seeds Guacamole with a Hatch RDP connection, logs into the Guacamole web UI with Playwright, opens the RDP session, and verifies Chromium opens `https://www.google.com`. - -Required host tools: - -- Docker -- `nc` -- Node.js -- npm +It builds Hatch, starts one temporary HTTPS container, logs into Guacamole with Playwright, opens the Hatch RDP connection, and verifies Chromium opens `https://www.google.com`. ## GitHub Container Registry @@ -227,6 +256,6 @@ The included `.github/workflows/docker.yml` builds the image on pull requests an ghcr.io/everydaydevopsio/hatch ``` -## Recommended operating model +## Recommended Operating Model -Use Hatch only over a private network or SSH tunnel. Keep its Chromium profile ephemeral. Use a long random RDP password. Start Hatch when interactive authentication is needed and stop it afterward. OAuth access and refresh tokens should remain in the MCP application's normal credential store rather than in Hatch. +Expose only HTTPS. Keep direct RDP private inside the container. Use host networking only when the browser must reach host loopback OAuth callbacks. Keep the Chromium profile ephemeral unless persistence is required and the profile volume is protected as sensitive authentication material. diff --git a/PRD.md b/PRD.md new file mode 100644 index 0000000..46b0757 --- /dev/null +++ b/PRD.md @@ -0,0 +1,21 @@ +# PRD + +## Integrated HTTPS Guacamole Access + +### Requirement + +Hatch must provide browser-based desktop access through HTTPS from the container without requiring users to connect an RDP client directly. + +### Acceptance Criteria + +- The default container listens on HTTPS port `443`. +- The HTTPS server proxies `/guacamole/` to Guacamole on `127.0.0.1:8080`. +- Guacamole connects through `guacd` on `127.0.0.1:4822`. +- `guacd` connects to xrdp on `127.0.0.1:3389`. +- xrdp is not published as a direct external service by the default image. +- A self-signed TLS certificate is generated automatically when no certificate is mounted. +- The generated Guacamole credentials are printed to container logs and usable when the container is started detached. +- Docker users can map any host port to container port `443`, for example `-p 8443:443`. +- Host-network OAuth callback mode remains documented for cases where Chromium must reach a callback listener on host loopback. +- The README presents the HTTPS Guacamole flow as the primary quickstart and keeps Docker Compose as a lower-priority option. +- An E2E smoke test validates the HTTPS Guacamole login path and confirms the browser desktop starts. diff --git a/README.md b/README.md index ea9778f..dd79c6d 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,22 @@ # Hatch -A small Dockerized RDP desktop for completing interactive OAuth flows on a headless Linux server. +A small Dockerized browser desktop for completing interactive OAuth flows on a headless Linux server. -It runs: - -- Chromium -- Openbox -- xrdp + xorgxrdp -- a minimal X11 environment - -The container is intentionally run with **Docker host networking**. That makes Chromium's `127.0.0.1` the Linux host's loopback interface, which is the important part for CLI and MCP OAuth flows that start a callback listener such as: +Hatch runs Chromium behind Apache Guacamole and serves the desktop over HTTPS from the container. The internal path is: ```text -http://127.0.0.1:8765/oauth/callback -``` - -## How it works - -```text -Mac / iPad / workstation - | - | RDP over Tailscale, VPN, or SSH - v -+-----------------------------------------+ -| Headless Linux server | -| | -| +-----------------------------------+ | -| | Docker: hatch | | -| | xrdp -> Openbox -> Chromium | | -| +----------------+------------------+ | -| | host networking | -| v | -| 127.0.0.1 | -| | | -| v | -| MCP/CLI OAuth callback | -+-----------------------------------------+ +Browser + -> HTTPS on container port 443 + -> nginx + -> Guacamole on 127.0.0.1:8080 + -> guacd on 127.0.0.1:4822 + -> xrdp on 127.0.0.1:3389 + -> Openbox + Chromium ``` -The MCP server or CLI runs normally on the Linux host. Only the GUI browser runs in Docker. +The container generates a self-signed HTTPS certificate and a random desktop password at startup unless you provide your own values. -## Quick start +## Quick Start ```bash git clone https://github.com/everydaydevopsio/hatch.git @@ -50,7 +26,7 @@ docker build -t hatch:local . docker run -d \ --name hatch \ - --network host \ + -p 8443:443 \ --restart unless-stopped \ --shm-size=1g \ --security-opt no-new-privileges:true \ @@ -60,15 +36,13 @@ docker run -d \ docker logs hatch ``` -Connect an RDP client to: +Open: ```text -:3389 +https://:8443/guacamole/ ``` -Use an RDP client, not a web browser. `http://:3389/` will not work because TCP/3389 speaks the RDP protocol, not HTTP. - -Use the username and generated password printed by `docker logs hatch`. The credentials are also written inside the container at `/var/log/hatch/rdp-credentials.log`. +Accept the self-signed certificate warning, then sign in with the `Guacamole user` and `Guacamole password` printed by `docker logs hatch`. The generated RDP credentials are also written inside the container at `/var/log/hatch/rdp-credentials.log`. Stop Hatch with: @@ -77,91 +51,92 @@ docker stop hatch docker rm hatch ``` -Use a Tailscale/VPN address or an SSH tunnel. **Do not expose TCP/3389 directly to the public Internet.** +## OAuth Callback Mode -See [INSTALL.md](INSTALL.md) for the full installation, security, OAuth workflow, troubleshooting, and production recommendations. +Some OAuth tools start a local callback listener such as: -## Typical OAuth flow +```text +http://127.0.0.1:8765/oauth/callback +``` -SSH to the server and start the application that needs authorization: +If Chromium inside Hatch must reach a listener running on the Docker host at `127.0.0.1`, run Hatch with Docker host networking. With host networking, Docker ignores `-p`, so choose the HTTPS listener port inside the container: ```bash -ssh dev-server -my-mcp-server login +docker run -d \ + --name hatch \ + --network host \ + --restart unless-stopped \ + --shm-size=1g \ + --security-opt no-new-privileges:true \ + -e RDP_USER=oauth \ + -e HATCH_HTTPS_PORT=8443 \ + hatch:local ``` -It may print an authorization URL and then wait on a callback: +Open: ```text -Waiting for OAuth callback at http://127.0.0.1:8765/callback +https://:8443/guacamole/ ``` -Connect to Hatch over RDP, open Chromium, paste the authorization URL, and sign in. +The RDP service is bound to loopback inside the container. Use the HTTPS Guacamole endpoint instead of connecting an RDP client directly. -The provider redirects Chromium to `127.0.0.1:8765`. Because the container uses host networking, that request reaches the callback listener running on the Linux host. +## Configuration -## Docker Compose option +Common environment variables: -```bash -cp .env.example .env +```text +RDP_USER=oauth +RDP_PASSWORD= +GUAC_USER= +GUAC_PASSWORD= +HATCH_HTTPS_PORT=443 +HATCH_START_URL=about:blank +CHROMIUM_EXTRA_FLAGS= +HATCH_TLS_CERT=/etc/hatch/tls/hatch.crt +HATCH_TLS_KEY=/etc/hatch/tls/hatch.key +HATCH_TLS_CN=hatch.local +HATCH_TLS_DAYS=365 ``` -Edit `.env` only if you want to override defaults, then start Hatch: +Leave `RDP_PASSWORD` blank to generate a random password. Leave `GUAC_USER` and `GUAC_PASSWORD` blank to reuse the RDP credentials for the Guacamole login. + +To use your own certificate, mount the certificate and key into the container and set `HATCH_TLS_CERT` and `HATCH_TLS_KEY`. + +## Docker Compose Option ```bash +cp .env.example .env docker compose up -d --build -docker compose ps docker compose logs hatch ``` -Stop Hatch with: +The compose file maps host port `${HATCH_HTTPS_HOST_PORT:-8443}` to container port `${HATCH_HTTPS_PORT:-443}`. Stop it with: ```bash docker compose down ``` -## Browser access with Guacamole - -RDP does not run directly over HTTPS, so opening `http://:3389/` in a browser will not work. To use Hatch from a browser, run Apache Guacamole in front of the Hatch RDP service and expose Guacamole through HTTPS with your normal reverse proxy or load balancer. - -The production shape is: - -```text -Browser - | - | HTTPS - v -Reverse proxy / load balancer - | - | HTTP to Guacamole - v -Guacamole web app -> guacd -> Hatch RDP on TCP/3389 -``` - -Configure the Guacamole connection with: - -- Protocol: `RDP` -- Hostname: the Linux host address that exposes Hatch on TCP/3389 -- Port: `3389` -- Username: the `RDP_USER` value, `oauth` by default -- Password: the generated password from `docker logs hatch` or `/var/log/hatch/rdp-credentials.log` -- Ignore server certificate: enabled +For host-network OAuth callback mode, prefer the `docker run --network host` command above because compose port mappings are not used with host networking. -Keep TCP/3389 private to the host, VPN, or container network. Only the Guacamole HTTPS endpoint should be exposed to browser users. +## E2E Guacamole Test -Use the Guacamole E2E test below as the reference wiring for automation: Hatch, `guacd`, Guacamole, and Postgres run on the same Docker network, and Guacamole connects to Hatch on TCP/3389 with the generated RDP password from the Hatch logs. - -## E2E Guacamole test - -The Guacamole smoke test builds Hatch, starts temporary Hatch/Postgres/guacd/Guacamole containers, opens Guacamole in Playwright, logs into the Guacamole web UI, opens the Hatch RDP connection, and verifies Chromium starts at Google: +Run the smoke test from the repository root: ```bash scripts/e2e-guacamole.sh ``` -The test requires Docker, `nc`, Node.js, npm, and network access to pull the official Guacamole images and Playwright package when they are not already cached. +The test builds Hatch, starts one temporary container, waits for HTTPS health, logs into Guacamole with Playwright, opens the Hatch connection, and verifies Chromium starts at `https://www.google.com`. + +Required host tools: -## Project layout +- Docker +- `nc` +- Node.js +- npm + +## Project Layout ```text . @@ -176,28 +151,21 @@ The test requires Docker, `nc`, Node.js, npm, and network access to pull the off ├── scripts/ │ ├── e2e-guacamole.sh │ ├── entrypoint.sh +│ ├── guacamole-config.sh │ └── healthcheck.sh └── .github/workflows/docker.yml ``` -## Design constraints - -### Host networking is deliberate - -Do not replace `network_mode: host` with `ports: - 3389:3389` if the OAuth callback uses loopback addresses. Under bridge networking, Chromium's `127.0.0.1` belongs to the container and no longer points at the MCP service on the host. - -### The MCP server stays outside this image - -This keeps the browser utility generic. Any host process can use it, regardless of whether the OAuth client is an MCP server, Codex tool, CLI, Python process, Node application, or another agent. +## Supported Host -### Browser persistence is off by default +This project targets Linux hosts running Docker Engine. Docker Desktop behaves differently because host networking is virtualized and is not the intended deployment environment for OAuth callback mode. -The default container does not persist Chromium's profile. This reduces the amount of session/cookie material left behind. If persistent browser sessions are required, mount `/home/oauth/.config/chromium` as a Docker volume and protect it as sensitive authentication material. +## Security Notes -## Supported host +Do not publish direct RDP access. Hatch exposes HTTPS for browser access, and xrdp listens on `127.0.0.1:3389` inside the container. -This project targets Linux hosts running Docker Engine. Docker Desktop behaves differently because host networking is virtualized and is not the intended deployment environment for this solution. +The default certificate is self-signed. Use a reverse proxy, load balancer, or mounted certificate files if you need a publicly trusted certificate. Certbot HTTP-01 validation requires public port 80, while DNS-01 can issue certificates without opening port 80. -## Base image +Browser persistence is off by default. If persistent browser sessions are required, mount `/home/oauth/.config/chromium` as a Docker volume and protect it as sensitive authentication material. -The image currently uses Debian 13 and Debian packages for Chromium, xrdp, xorgxrdp, Openbox, and Xorg. +See [INSTALL.md](INSTALL.md) for operational details and troubleshooting. diff --git a/config/supervisord.conf b/config/supervisord.conf index 05c48ae..1d37fda 100644 --- a/config/supervisord.conf +++ b/config/supervisord.conf @@ -4,6 +4,16 @@ logfile=/dev/null logfile_maxbytes=0 pidfile=/run/supervisord.pid +[program:guacd] +command=/usr/local/sbin/guacd -b 127.0.0.1 -l 4822 -f +priority=10 +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + [program:xrdp-sesman] command=/usr/sbin/xrdp-sesman --nodaemon priority=20 @@ -23,3 +33,23 @@ stdout_logfile=/dev/stdout stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 + +[program:guacamole] +command=/opt/guacamole/bin/entrypoint.sh +priority=40 +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +[program:nginx] +command=/usr/sbin/nginx -g "daemon off;" +priority=50 +autostart=true +autorestart=true +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 diff --git a/docker-compose.yml b/docker-compose.yml index 11157a9..5da8a1e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,12 +5,20 @@ services: image: hatch:local container_name: hatch restart: unless-stopped - network_mode: host + ports: + - "${HATCH_HTTPS_HOST_PORT:-8443}:${HATCH_HTTPS_PORT:-443}" shm_size: "1gb" environment: RDP_USER: ${RDP_USER:-oauth} RDP_PASSWORD: ${RDP_PASSWORD:-} + GUAC_USER: ${GUAC_USER:-} + GUAC_PASSWORD: ${GUAC_PASSWORD:-} + HATCH_HTTPS_PORT: ${HATCH_HTTPS_PORT:-443} HATCH_START_URL: ${HATCH_START_URL:-about:blank} CHROMIUM_EXTRA_FLAGS: ${CHROMIUM_EXTRA_FLAGS:-} + HATCH_TLS_CERT: ${HATCH_TLS_CERT:-/etc/hatch/tls/hatch.crt} + HATCH_TLS_KEY: ${HATCH_TLS_KEY:-/etc/hatch/tls/hatch.key} + HATCH_TLS_CN: ${HATCH_TLS_CN:-hatch.local} + HATCH_TLS_DAYS: ${HATCH_TLS_DAYS:-365} security_opt: - no-new-privileges:true diff --git a/scripts/e2e-guacamole.sh b/scripts/e2e-guacamole.sh index 3901d71..85e6c8c 100755 --- a/scripts/e2e-guacamole.sh +++ b/scripts/e2e-guacamole.sh @@ -3,15 +3,9 @@ set -euo pipefail IMAGE="${HATCH_E2E_IMAGE:-hatch:e2e}" PREFIX="${HATCH_E2E_NAME:-hatch-e2e}" -NETWORK="${PREFIX}-net" -GUAC_PORT="${HATCH_E2E_GUAC_PORT:-18080}" +HTTPS_PORT="${HATCH_E2E_HTTPS_PORT:-18443}" URL="${HATCH_E2E_URL:-https://www.google.com}" RDP_USER="${HATCH_E2E_RDP_USER:-oauth}" -GUAC_VERSION="${HATCH_E2E_GUAC_VERSION:-1.6.0}" -POSTGRES_IMAGE="${HATCH_E2E_POSTGRES_IMAGE:-postgres:16-alpine}" -DB_NAME="guacamole_db" -DB_USER="guacamole_user" -DB_PASSWORD="guacamole_pass" TMP_DIR="$(mktemp -d)" require_command() { @@ -22,33 +16,32 @@ require_command() { } cleanup() { - docker rm -f "${PREFIX}-guacamole" "${PREFIX}-guacd" "${PREFIX}-postgres" "${PREFIX}-hatch" >/dev/null 2>&1 || true - docker network rm "$NETWORK" >/dev/null 2>&1 || true + docker rm -f "${PREFIX}-hatch" >/dev/null 2>&1 || true rm -rf "$TMP_DIR" } trap cleanup EXIT require_command docker +require_command curl require_command nc require_command node require_command npm cleanup mkdir -p "$TMP_DIR" -docker network create "$NETWORK" >/dev/null docker build -t "$IMAGE" . docker run -d \ --name "${PREFIX}-hatch" \ - --network "$NETWORK" \ + -p "127.0.0.1:${HTTPS_PORT}:443" \ --shm-size=1g \ --security-opt no-new-privileges:true \ -e RDP_USER="$RDP_USER" \ -e HATCH_START_URL="$URL" \ "$IMAGE" >/dev/null -for _ in $(seq 1 30); do +for _ in $(seq 1 60); do if [ "$(docker inspect "${PREFIX}-hatch" --format '{{.State.Health.Status}}' 2>/dev/null || true)" = "healthy" ]; then break fi @@ -61,106 +54,30 @@ if [ "$(docker inspect "${PREFIX}-hatch" --format '{{.State.Health.Status}}')" ! exit 1 fi -RDP_PASSWORD="$(docker logs "${PREFIX}-hatch" 2>&1 | sed -n 's/^Generated RDP password: //p' | tail -n 1)" -if [ -z "$RDP_PASSWORD" ]; then - docker logs "${PREFIX}-hatch" >&2 || true - echo "ERROR: Could not extract generated RDP password from Hatch logs." >&2 - exit 1 -fi - -docker run -d \ - --name "${PREFIX}-postgres" \ - --network "$NETWORK" \ - -e POSTGRES_DB="$DB_NAME" \ - -e POSTGRES_USER="$DB_USER" \ - -e POSTGRES_PASSWORD="$DB_PASSWORD" \ - "$POSTGRES_IMAGE" >/dev/null - -for _ in $(seq 1 45); do - if docker exec "${PREFIX}-postgres" pg_isready -U "$DB_USER" -d "$DB_NAME" >/dev/null 2>&1; then +for _ in $(seq 1 30); do + if nc -z 127.0.0.1 "$HTTPS_PORT" >/dev/null 2>&1; then break fi sleep 1 done -if ! docker exec "${PREFIX}-postgres" pg_isready -U "$DB_USER" -d "$DB_NAME" >/dev/null 2>&1; then - docker logs "${PREFIX}-postgres" >&2 || true - echo "ERROR: Postgres did not become ready." >&2 +if ! nc -z 127.0.0.1 "$HTTPS_PORT" >/dev/null 2>&1; then + docker logs "${PREFIX}-hatch" >&2 || true + echo "ERROR: Hatch did not open HTTPS port $HTTPS_PORT." >&2 exit 1 fi -docker run --rm "guacamole/guacamole:${GUAC_VERSION}" /opt/guacamole/bin/initdb.sh --postgresql > "$TMP_DIR/initdb.sql" -docker exec -i "${PREFIX}-postgres" psql -U "$DB_USER" -d "$DB_NAME" < "$TMP_DIR/initdb.sql" >/dev/null - -cat > "$TMP_DIR/connection.sql" </dev/null - -docker run -d \ - --name "${PREFIX}-guacd" \ - --network "$NETWORK" \ - "guacamole/guacd:${GUAC_VERSION}" >/dev/null - -docker run -d \ - --name "${PREFIX}-guacamole" \ - --network "$NETWORK" \ - -p "127.0.0.1:${GUAC_PORT}:8080" \ - -e GUACD_HOSTNAME="${PREFIX}-guacd" \ - -e POSTGRESQL_HOSTNAME="${PREFIX}-postgres" \ - -e POSTGRESQL_DATABASE="$DB_NAME" \ - -e POSTGRESQL_USER="$DB_USER" \ - -e POSTGRESQL_PASSWORD="$DB_PASSWORD" \ - "guacamole/guacamole:${GUAC_VERSION}" >/dev/null - -for _ in $(seq 1 60); do - if nc -z 127.0.0.1 "$GUAC_PORT" >/dev/null 2>&1; then - break - fi - sleep 1 -done +if ! curl -kfsS "https://127.0.0.1:${HTTPS_PORT}/guacamole/" >/dev/null; then + docker logs "${PREFIX}-hatch" >&2 || true + echo "ERROR: Guacamole did not respond through HTTPS." >&2 + exit 1 +fi -if ! nc -z 127.0.0.1 "$GUAC_PORT" >/dev/null 2>&1; then - docker logs "${PREFIX}-guacamole" >&2 || true - echo "ERROR: Guacamole did not open port $GUAC_PORT." >&2 +GUAC_USER="$(docker logs "${PREFIX}-hatch" 2>&1 | sed -n 's/^Guacamole user: //p' | tail -n 1)" +GUAC_PASSWORD="$(docker logs "${PREFIX}-hatch" 2>&1 | sed -n 's/^Guacamole password: //p' | tail -n 1)" +if [ -z "$GUAC_USER" ] || [ -z "$GUAC_PASSWORD" ]; then + docker logs "${PREFIX}-hatch" >&2 || true + echo "ERROR: Could not extract generated Guacamole credentials from Hatch logs." >&2 exit 1 fi @@ -168,42 +85,54 @@ cat > "$TMP_DIR/guac-smoke.mjs" <<'JS' import { chromium } from 'playwright'; const baseUrl = process.env.GUAC_URL; +const username = process.env.GUAC_USER; +const password = process.env.GUAC_PASSWORD; const browser = await chromium.launch({ headless: true }); -const page = await browser.newPage({ viewport: { width: 1280, height: 900 } }); +const context = await browser.newContext({ + ignoreHTTPSErrors: true, + viewport: { width: 1280, height: 900 }, +}); +const page = await context.newPage(); await page.goto(`${baseUrl}/guacamole/`, { waitUntil: 'domcontentloaded' }); -await page.getByLabel(/username/i).fill('guacadmin'); -await page.getByLabel(/password/i).fill('guacadmin'); +await page.getByLabel(/username/i).fill(username); +await page.getByLabel(/password/i).fill(password); await page.getByRole('button', { name: /login/i }).click(); -await page.getByText('Hatch', { exact: true }).waitFor({ timeout: 30000 }); -await page.getByText('Hatch', { exact: true }).click(); +const clientId = Buffer.from('Hatch\0c\0default').toString('base64').replace(/=/g, ''); +await page.goto(`${baseUrl}/guacamole/#/client/${clientId}`, { waitUntil: 'domcontentloaded' }); await page.locator('canvas').first().waitFor({ timeout: 60000 }); - await page.waitForTimeout(5000); await browser.close(); JS -GUAC_URL="http://127.0.0.1:${GUAC_PORT}" \ - sh -c 'cd "$1" && npm init -y >/dev/null && npm install playwright@1.57.0 >/dev/null && npx playwright install chromium >/dev/null && node guac-smoke.mjs' sh "$TMP_DIR" +GUAC_URL="https://127.0.0.1:${HTTPS_PORT}" \ +GUAC_USER="$GUAC_USER" \ +GUAC_PASSWORD="$GUAC_PASSWORD" \ + sh -c 'cd "$1" && npm init -y >/dev/null && npm install playwright@1.57.0 >/dev/null && npx playwright install chromium >/dev/null && node guac-smoke.mjs' sh "$TMP_DIR" || { + echo "ERROR: Playwright could not complete the Guacamole desktop flow." >&2 + echo "---- Hatch listeners ----" >&2 + docker exec "${PREFIX}-hatch" sh -lc "ss -ltnp | grep -E ':(443|8080|4822|3389)' || true" >&2 || true + echo "---- Hatch logs ----" >&2 + docker logs "${PREFIX}-hatch" >&2 || true + exit 1 + } for _ in $(seq 1 45); do if docker exec "${PREFIX}-hatch" sh -lc "pgrep -af 'chromium.*${URL}' >/dev/null"; then - echo "Guacamole E2E succeeded: browser login reached Hatch RDP and Chromium opened $URL" + echo "Guacamole E2E succeeded: HTTPS login reached Hatch RDP and Chromium opened $URL" exit 0 fi sleep 1 done echo "ERROR: Chromium process for $URL was not observed in Hatch." >&2 +echo "---- Hatch listeners ----" >&2 +docker exec "${PREFIX}-hatch" sh -lc "ss -ltnp | grep -E ':(443|8080|4822|3389)' || true" >&2 || true echo "---- Hatch processes ----" >&2 docker exec "${PREFIX}-hatch" ps aux >&2 || true echo "---- Hatch logs ----" >&2 docker logs "${PREFIX}-hatch" >&2 || true -echo "---- guacd logs ----" >&2 -docker logs "${PREFIX}-guacd" >&2 || true -echo "---- Guacamole logs ----" >&2 -docker logs "${PREFIX}-guacamole" >&2 || true exit 1 diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 015ecdb..e0ad068 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -30,6 +30,8 @@ if [ ! -s /etc/xrdp/key.pem ] || [ ! -s /etc/xrdp/cert.pem ]; then xrdp-keygen x mkdir -p /run/xrdp chmod 0755 /run/xrdp rm -f /run/xrdp/xrdp.pid /run/xrdp/xrdp-sesman.pid +export RDP_USER RDP_PASSWORD HATCH_HTTPS_PORT HATCH_START_URL CHROMIUM_EXTRA_FLAGS GUACAMOLE_HOME GUACD_HOSTNAME GUACD_PORT WEBAPP_CONTEXT +/usr/local/bin/hatch-guacamole-config echo "Hatch starting" echo "RDP user: $RDP_USER" if [ "$GENERATED_PASSWORD" -eq 1 ]; then @@ -39,5 +41,6 @@ else echo "Using RDP password from RDP_PASSWORD" echo "RDP credentials were also written to /var/log/hatch/rdp-credentials.log" fi -echo "Network requirement: Docker host networking for localhost OAuth callbacks" +echo "HTTPS access: publish container port ${HATCH_HTTPS_PORT:-443}, for example -p 8443:${HATCH_HTTPS_PORT:-443}" +echo "OAuth callback note: use Docker host networking only when Chromium must reach services on host loopback" exec "$@" diff --git a/scripts/guacamole-config.sh b/scripts/guacamole-config.sh new file mode 100755 index 0000000..1fd7fd7 --- /dev/null +++ b/scripts/guacamole-config.sh @@ -0,0 +1,106 @@ +#!/bin/sh +set -eu + +xml_escape() { + printf '%s' "$1" \ + | sed \ + -e 's/&/\&/g' \ + -e 's//\>/g' \ + -e 's/"/\"/g' \ + -e "s/'/\'/g" +} + +install -d -m 0755 /etc/guacamole /etc/hatch/tls /etc/nginx/conf.d + +TLS_CERT="${HATCH_TLS_CERT:-/etc/hatch/tls/hatch.crt}" +TLS_KEY="${HATCH_TLS_KEY:-/etc/hatch/tls/hatch.key}" +TLS_CN="${HATCH_TLS_CN:-hatch.local}" + +if [ ! -s "$TLS_CERT" ] || [ ! -s "$TLS_KEY" ]; then + openssl req \ + -x509 \ + -newkey rsa:2048 \ + -sha256 \ + -days "${HATCH_TLS_DAYS:-365}" \ + -nodes \ + -subj "/CN=$TLS_CN" \ + -keyout "$TLS_KEY" \ + -out "$TLS_CERT" >/dev/null 2>&1 + chmod 0600 "$TLS_KEY" + chmod 0644 "$TLS_CERT" +fi + +cat > /etc/nginx/conf.d/hatch.conf < /etc/nginx/conf.d/connection_upgrade.conf <<'EOF' +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} +EOF + +GUAC_USER="${GUAC_USER:-$RDP_USER}" +GUAC_PASSWORD="${GUAC_PASSWORD:-$RDP_PASSWORD}" + +cat > /etc/guacamole/guacamole.properties < /etc/guacamole/user-mapping.xml < + + + rdp + 127.0.0.1 + 3389 + $(xml_escape "$RDP_USER") + $(xml_escape "$RDP_PASSWORD") + any + true + display-update + + + +EOF + +chmod 0600 /etc/guacamole/user-mapping.xml +chmod 0644 /etc/guacamole/guacamole.properties + +echo "Hatch HTTPS endpoint: container port ${HATCH_HTTPS_PORT:-443}" +echo "Guacamole URL path: /guacamole/" +echo "Guacamole user: $GUAC_USER" +echo "Guacamole password: $GUAC_PASSWORD" diff --git a/scripts/healthcheck.sh b/scripts/healthcheck.sh index a7c93a8..41b7258 100644 --- a/scripts/healthcheck.sh +++ b/scripts/healthcheck.sh @@ -1,5 +1,8 @@ #!/bin/sh set -eu +pgrep -x nginx >/dev/null +pgrep -x guacd >/dev/null +pgrep -f 'org.apache.catalina.startup.Bootstrap' >/dev/null pgrep -x xrdp >/dev/null pgrep -x xrdp-sesman >/dev/null grep -qi ':0D3D ' /proc/net/tcp /proc/net/tcp6 2>/dev/null From e9fc1308ae5f08fecd971ad0281d06b8ce9a2b82 Mon Sep 17 00:00:00 2001 From: Mark C Allen Date: Wed, 26 Aug 2026 15:27:11 +0000 Subject: [PATCH 07/10] Handle Chromium under no-new-privileges --- INSTALL.md | 4 +++- README.md | 2 ++ config/chromium-launch.sh | 3 +++ 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/INSTALL.md b/INSTALL.md index 14f58d1..cb263c7 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -208,7 +208,9 @@ A protocol-specific `400` or `404` can still prove connectivity. `Connection ref ## Chromium Sandbox -Hatch keeps Chromium's sandbox enabled. If an unusually restrictive host prevents Chromium from starting, diagnose the host first. As a last resort, set: +Hatch keeps Chromium's sandbox enabled when the container runtime allows it. If the container is started with `--security-opt no-new-privileges:true`, Chromium cannot use its setuid sandbox, so Hatch automatically adds `--no-sandbox` for that session. + +If an unusually restrictive host still prevents Chromium from starting, diagnose the host first. As a last resort, set: ```dotenv CHROMIUM_EXTRA_FLAGS=--no-sandbox diff --git a/README.md b/README.md index dd79c6d..d197287 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,8 @@ Do not publish direct RDP access. Hatch exposes HTTPS for browser access, and xr The default certificate is self-signed. Use a reverse proxy, load balancer, or mounted certificate files if you need a publicly trusted certificate. Certbot HTTP-01 validation requires public port 80, while DNS-01 can issue certificates without opening port 80. +When the container is started with `--security-opt no-new-privileges:true`, Hatch automatically adds Chromium's `--no-sandbox` flag because the setuid sandbox cannot run under that kernel setting. + Browser persistence is off by default. If persistent browser sessions are required, mount `/home/oauth/.config/chromium` as a Docker volume and protect it as sensitive authentication material. See [INSTALL.md](INSTALL.md) for operational details and troubleshooting. diff --git a/config/chromium-launch.sh b/config/chromium-launch.sh index d3432bf..4d42961 100644 --- a/config/chromium-launch.sh +++ b/config/chromium-launch.sh @@ -1,6 +1,9 @@ #!/bin/sh set -eu FLAGS="--disable-dev-shm-usage --no-first-run --no-default-browser-check --disable-session-crashed-bubble" +if grep -q '^NoNewPrivs:[[:space:]]*1$' /proc/self/status 2>/dev/null; then + FLAGS="$FLAGS --no-sandbox" +fi if [ "$#" -eq 0 ]; then set -- about:blank; fi # shellcheck disable=SC2086 exec /usr/bin/chromium $FLAGS ${CHROMIUM_EXTRA_FLAGS:-} "$@" From be142b6f3ba1f46f4e0ad10f2c67b404ef259691 Mon Sep 17 00:00:00 2001 From: Mark C Allen Date: Wed, 26 Aug 2026 15:36:42 +0000 Subject: [PATCH 08/10] Address Copilot entrypoint feedback --- scripts/entrypoint.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index e0ad068..6fdc942 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -4,7 +4,11 @@ RDP_USER="${RDP_USER:-oauth}" case "$RDP_USER" in *[!a-zA-Z0-9_-]*|'') echo >&2 "ERROR: invalid RDP_USER."; exit 64;; esac GENERATED_PASSWORD=0 if [ -z "${RDP_PASSWORD:-}" ]; then - RDP_PASSWORD="$(openssl rand -hex 24)" + if command -v openssl >/dev/null 2>&1; then + RDP_PASSWORD="$(openssl rand -hex 24)" + else + RDP_PASSWORD="$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n')" + fi GENERATED_PASSWORD=1 fi case "$RDP_PASSWORD" in @@ -22,6 +26,11 @@ install -d -m 0700 /var/log/hatch { echo "Hatch RDP credentials" echo "Written: $(date -u '+%Y-%m-%dT%H:%M:%SZ')" + if [ "$GENERATED_PASSWORD" -eq 1 ]; then + echo "RDP password source: generated" + else + echo "RDP password source: RDP_PASSWORD" + fi echo "RDP user: $RDP_USER" echo "RDP password: $RDP_PASSWORD" } > /var/log/hatch/rdp-credentials.log From 66b2a5dcf04f55144ebd46af0788b66c356a79e3 Mon Sep 17 00:00:00 2001 From: Mark C Allen Date: Wed, 26 Aug 2026 15:39:39 +0000 Subject: [PATCH 09/10] Harden Guacamole container startup --- Dockerfile | 2 ++ scripts/entrypoint.sh | 6 +++--- scripts/guacamole-config.sh | 2 +- scripts/healthcheck.sh | 5 ++++- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/Dockerfile b/Dockerfile index c59ce1d..02ce1f0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,7 @@ FROM debian:12-slim AS guacd-builder ARG GUACAMOLE_VERSION=1.6.0 +ARG GUACAMOLE_SERVER_SHA256=8bc45675da96d7b6f39728160181e3d4ff3c08f460f6d26de5805b642bf13f2b ENV DEBIAN_FRONTEND=noninteractive @@ -23,6 +24,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ uuid-dev \ wget \ && wget -O /tmp/guacamole-server.tar.gz "https://archive.apache.org/dist/guacamole/${GUACAMOLE_VERSION}/source/guacamole-server-${GUACAMOLE_VERSION}.tar.gz" \ + && echo "${GUACAMOLE_SERVER_SHA256} /tmp/guacamole-server.tar.gz" | sha256sum -c - \ && mkdir -p /tmp/guacamole-server \ && tar -xzf /tmp/guacamole-server.tar.gz -C /tmp/guacamole-server --strip-components=1 \ && cd /tmp/guacamole-server \ diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index 6fdc942..cc20b20 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -21,7 +21,8 @@ install -d -o "$RDP_USER" -g "$RDP_USER" -m 0700 "/home/$RDP_USER/.config" install -d -o "$RDP_USER" -g "$RDP_USER" -m 0700 "/home/$RDP_USER/.cache" install -d -m 0755 /etc/hatch printf '%s\n' "${HATCH_START_URL:-about:blank}" > /etc/hatch/start-url -chmod 0644 /etc/hatch/start-url +chown "$RDP_USER:$RDP_USER" /etc/hatch/start-url +chmod 0600 /etc/hatch/start-url install -d -m 0700 /var/log/hatch { echo "Hatch RDP credentials" @@ -44,8 +45,7 @@ export RDP_USER RDP_PASSWORD HATCH_HTTPS_PORT HATCH_START_URL CHROMIUM_EXTRA_FLA echo "Hatch starting" echo "RDP user: $RDP_USER" if [ "$GENERATED_PASSWORD" -eq 1 ]; then - echo "Generated RDP password: $RDP_PASSWORD" - echo "Generated RDP credentials were also written to /var/log/hatch/rdp-credentials.log" + echo "Generated RDP credentials were printed as Guacamole credentials and written to /var/log/hatch/rdp-credentials.log" else echo "Using RDP password from RDP_PASSWORD" echo "RDP credentials were also written to /var/log/hatch/rdp-credentials.log" diff --git a/scripts/guacamole-config.sh b/scripts/guacamole-config.sh index 1fd7fd7..e2e26b9 100755 --- a/scripts/guacamole-config.sh +++ b/scripts/guacamole-config.sh @@ -45,7 +45,7 @@ server { error_log /dev/stderr warn; location = / { - return 302 https://\$http_host/guacamole/; + return 302 /guacamole/; } location /guacamole/ { diff --git a/scripts/healthcheck.sh b/scripts/healthcheck.sh index 41b7258..07b75a4 100644 --- a/scripts/healthcheck.sh +++ b/scripts/healthcheck.sh @@ -2,7 +2,10 @@ set -eu pgrep -x nginx >/dev/null pgrep -x guacd >/dev/null -pgrep -f 'org.apache.catalina.startup.Bootstrap' >/dev/null pgrep -x xrdp >/dev/null pgrep -x xrdp-sesman >/dev/null +HTTPS_PORT_HEX="$(printf '%04X' "${HATCH_HTTPS_PORT:-443}")" +grep -qi ":$HTTPS_PORT_HEX " /proc/net/tcp /proc/net/tcp6 2>/dev/null +grep -qi ':1F90 ' /proc/net/tcp /proc/net/tcp6 2>/dev/null +grep -qi ':12D6 ' /proc/net/tcp /proc/net/tcp6 2>/dev/null grep -qi ':0D3D ' /proc/net/tcp /proc/net/tcp6 2>/dev/null From 211131b5013160110e3b6bb5b3c0d025a419c9dd Mon Sep 17 00:00:00 2001 From: Mark C Allen Date: Wed, 26 Aug 2026 16:13:41 +0000 Subject: [PATCH 10/10] Tighten listener health checks --- scripts/healthcheck.sh | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/scripts/healthcheck.sh b/scripts/healthcheck.sh index 07b75a4..1a4b323 100644 --- a/scripts/healthcheck.sh +++ b/scripts/healthcheck.sh @@ -1,11 +1,26 @@ #!/bin/sh set -eu + +check_listen_port() { + port_hex="$(printf '%04X' "$1")" + awk -v port="$port_hex" ' + NR > 1 { + split($2, local, ":") + if (toupper(local[2]) == port && $4 == "0A") { + found = 1 + } + } + END { + exit found ? 0 : 1 + } + ' /proc/net/tcp /proc/net/tcp6 +} + pgrep -x nginx >/dev/null pgrep -x guacd >/dev/null pgrep -x xrdp >/dev/null pgrep -x xrdp-sesman >/dev/null -HTTPS_PORT_HEX="$(printf '%04X' "${HATCH_HTTPS_PORT:-443}")" -grep -qi ":$HTTPS_PORT_HEX " /proc/net/tcp /proc/net/tcp6 2>/dev/null -grep -qi ':1F90 ' /proc/net/tcp /proc/net/tcp6 2>/dev/null -grep -qi ':12D6 ' /proc/net/tcp /proc/net/tcp6 2>/dev/null -grep -qi ':0D3D ' /proc/net/tcp /proc/net/tcp6 2>/dev/null +check_listen_port "${HATCH_HTTPS_PORT:-443}" +check_listen_port 8080 +check_listen_port 4822 +check_listen_port 3389