diff --git a/.env.example b/.env.example index cfc81d0..1e670a3 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,16 @@ RDP_USER=oauth -RDP_PASSWORD=replace-with-a-long-random-password +# 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 dc6a3f3..02ce1f0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,81 @@ -FROM debian:13-slim +FROM debian:12-slim AS guacd-builder + +ARG GUACAMOLE_VERSION=1.6.0 +ARG GUACAMOLE_SERVER_SHA256=8bc45675da96d7b6f39728160181e3d4ff3c08f460f6d26de5805b642bf13f2b + +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" \ + && 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 \ + && 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 \ - CHROMIUM_EXTRA_FLAGS="" + HATCH_HTTPS_PORT=443 \ + HATCH_START_URL=about:blank \ + 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 \ @@ -22,23 +84,33 @@ 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 /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-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 c6a048b..cb263c7 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,60 +37,39 @@ 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. 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 \ -e RDP_USER=oauth \ - -e RDP_PASSWORD="$RDP_PASSWORD" \ hatch:local docker ps --filter name=hatch -docker logs -f hatch +docker logs hatch ``` -Stop Hatch with: +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 stop hatch -docker rm hatch +docker exec hatch cat /var/log/hatch/rdp-credentials.log ``` -## Secure RDP access - -Do not expose TCP/3389 directly to the public Internet. - -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: +Stop Hatch with: ```bash -ssh -L 13389:127.0.0.1:3389 user@server +docker stop hatch +docker rm hatch ``` -Then point your RDP client at `127.0.0.1:13389`. - -## Complete an OAuth login +## OAuth Callback Mode Start the OAuth flow in your normal SSH shell: @@ -99,51 +77,120 @@ Start the OAuth flow in your normal SSH shell: my-mcp-server login ``` -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. +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. -The provider can redirect to a URL such as: +If the provider redirects to a URL such as: ```text http://127.0.0.1:8765/callback?code=... ``` -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. +run Hatch with host networking: -## Why bridge networking is not used +```bash +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 +``` -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. +Open `https://:8443/guacamole/`. Docker host networking ignores `-p`, so `HATCH_HTTPS_PORT` controls the host port in this mode. -## Chromium sandbox +## TLS -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`: +By default Hatch generates a self-signed certificate in `/etc/hatch/tls`. Set these variables to customize it: ```dotenv -CHROMIUM_EXTRA_FLAGS=--no-sandbox +HATCH_TLS_CN=hatch.local +HATCH_TLS_DAYS=365 ``` -Disabling the sandbox reduces browser security. +To provide your own certificate: -## Browser persistence +```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 +``` -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. +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. -## Troubleshooting +## Configuration -Check Hatch: +```dotenv +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 +``` + +Leave `RDP_PASSWORD` blank to generate a password at startup. Leave `GUAC_USER` and `GUAC_PASSWORD` blank to reuse the RDP credentials for Guacamole. + +## Docker Compose Option ```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 + +Check Hatch: + +```bash +docker ps --filter name=hatch +docker logs hatch docker exec hatch ps aux ``` -Verify host networking: +Verify the HTTPS endpoint: + +```bash +curl -kI https://127.0.0.1:8443/guacamole/ +``` + +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: @@ -151,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/ @@ -159,51 +206,49 @@ 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 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 +``` + +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 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 \ --name hatch \ - --network host \ + -p 8443:443 \ --restart unless-stopped \ --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. - -## Docker Compose option +Rebuild regularly so the image receives Chromium, Guacamole, and Debian security updates. -If you prefer Docker Compose: +## E2E Guacamole Test ```bash -cp .env.example .env +scripts/e2e-guacamole.sh ``` -Edit `.env` and set a long random `RDP_PASSWORD`, then start Hatch: - -```bash -docker compose up -d --build -docker compose ps -docker compose logs -f hatch -``` - -Stop Hatch with: - -```bash -docker compose down -``` +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 @@ -213,6 +258,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 6fbb396..d197287 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 +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 ``` -## 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 | -+-----------------------------------------+ -``` +The container generates a self-signed HTTPS certificate and a random desktop password at startup unless you provide your own values. -The MCP server or CLI runs normally on the Linux host. Only the GUI browser runs in Docker. - -## Quick start +## Quick Start ```bash git clone https://github.com/everydaydevopsio/hatch.git @@ -48,84 +24,119 @@ 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 \ + -p 8443:443 \ --restart unless-stopped \ --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: +Open: ```text -:3389 +https://:8443/guacamole/ ``` -Use the printed username and password. Stop Hatch with: +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: ```bash 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` and set a long random `RDP_PASSWORD`, 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 -f hatch +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 ``` -## Project layout +For host-network OAuth callback mode, prefer the `docker run --network host` command above because compose port mappings are not used with host networking. + +## E2E Guacamole Test + +Run the smoke test from the repository root: + +```bash +scripts/e2e-guacamole.sh +``` + +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: + +- Docker +- `nc` +- Node.js +- npm + +## Project Layout ```text . @@ -134,32 +145,29 @@ docker compose down ├── .env.example ├── config/ │ ├── chromium-launch.sh +│ ├── login-shell.sh │ ├── startwm.sh │ └── supervisord.conf ├── 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 +## Supported Host -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. +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. -### Browser persistence is off by default +## Security Notes -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. +Do not publish direct RDP access. Hatch exposes HTTPS for browser access, and xrdp listens on `127.0.0.1:3389` inside the container. -## Supported host +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. -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. +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. -## 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/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:-} "$@" 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..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 & -xterm -geometry 100x28+20+20 -title "Hatch" & +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/config/supervisord.conf b/config/supervisord.conf index 2922f96..1d37fda 100644 --- a/config/supervisord.conf +++ b/config/supervisord.conf @@ -4,8 +4,8 @@ logfile=/dev/null logfile_maxbytes=0 pidfile=/run/supervisord.pid -[program:dbus] -command=/usr/bin/dbus-daemon --system --nofork --nopidfile +[program:guacd] +command=/usr/local/sbin/guacd -b 127.0.0.1 -l 4822 -f priority=10 autostart=true autorestart=true @@ -33,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 9d0d432..5da8a1e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,11 +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:?Set RDP_PASSWORD in .env} + 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 new file mode 100755 index 0000000..85e6c8c --- /dev/null +++ b/scripts/e2e-guacamole.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -euo pipefail + +IMAGE="${HATCH_E2E_IMAGE:-hatch:e2e}" +PREFIX="${HATCH_E2E_NAME:-hatch-e2e}" +HTTPS_PORT="${HATCH_E2E_HTTPS_PORT:-18443}" +URL="${HATCH_E2E_URL:-https://www.google.com}" +RDP_USER="${HATCH_E2E_RDP_USER:-oauth}" +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}-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 build -t "$IMAGE" . + +docker run -d \ + --name "${PREFIX}-hatch" \ + -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 60); 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 + +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 ! 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 + +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 + +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 + +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 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(username); +await page.getByLabel(/password/i).fill(password); +await page.getByRole('button', { name: /login/i }).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="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: 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 +exit 1 diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh index b88208e..cc20b20 100644 --- a/scripts/entrypoint.sh +++ b/scripts/entrypoint.sh @@ -1,17 +1,55 @@ #!/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 + 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 + *:*|*' +'*) 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" 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 +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" + 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 +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 +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" -echo "Network requirement: Docker host networking for localhost OAuth callbacks" +if [ "$GENERATED_PASSWORD" -eq 1 ]; then + 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" +fi +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..e2e26b9 --- /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..1a4b323 100644 --- a/scripts/healthcheck.sh +++ b/scripts/healthcheck.sh @@ -1,5 +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 -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