diff --git a/.env.example b/.env.example index dbc18f9..401a93a 100644 --- a/.env.example +++ b/.env.example @@ -23,3 +23,21 @@ VAPID_SUBJECT=mailto:admin@example.com # Override where chat history and push subscriptions are stored. # CHAT_STATE_PATH=.chat-state # PUSH_STORE_PATH=.push-subscriptions + +# === Remote Access via Tailscale (optional) === +# Required only when running: docker compose -f docker-compose.yml -f docker-compose.remote.yml --profile remote up +# See docs/REMOTE-ACCESS.md for the full setup guide. +# +# TS_AUTHKEY: Create a reusable auth key at https://login.tailscale.com/admin/settings/keys +TS_AUTHKEY= +# +# TS_HOSTNAME: The Tailscale device name (default: copilot-unleashed) +TS_HOSTNAME=copilot-unleashed +# +# TS_DOMAIN: Full Tailscale FQDN — find yours at https://login.tailscale.com/admin/machines +# after the first start. Example: copilot-unleashed.tail1234.ts.net +TS_DOMAIN= +# +# BASE_URL: Must match your full Tailscale HTTPS URL when using the remote profile. +# Example: BASE_URL=https://copilot-unleashed.tail1234.ts.net +# BASE_URL=https://copilot-unleashed.your-tailnet.ts.net diff --git a/docker-compose.remote.yml b/docker-compose.remote.yml new file mode 100644 index 0000000..c44dd3f --- /dev/null +++ b/docker-compose.remote.yml @@ -0,0 +1,25 @@ +# Remote-access overrides for the app service. +# +# Apply together with docker-compose.yml when running the remote profile: +# +# docker compose -f docker-compose.yml -f docker-compose.remote.yml \ +# --profile remote up -d --build +# +# Switches the app from development mode to production mode, and enables +# reverse-proxy trust so the X-Forwarded-For / X-Forwarded-Proto headers +# sent by Tailscale Serve are honoured for rate-limiting and secure cookies. + +services: + app: + # Restrict the host port to localhost so external devices cannot reach the + # app directly and spoof X-Forwarded-For to bypass rate limiting. + # All external traffic must go through the Tailscale sidecar. + ports: + - "127.0.0.1:3000:3000" + environment: + - NODE_ENV=production + # BASE_URL must be the full HTTPS URL of your Tailscale hostname. + # Set this in your .env file — compose reads it from there. + # Example: BASE_URL=https://copilot-unleashed.tail1234.ts.net + - BASE_URL=${BASE_URL:-https://copilot-unleashed.example.ts.net} + - TRUST_PROXY=1 diff --git a/docker-compose.yml b/docker-compose.yml index 3c599e8..ccac95f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,5 +27,56 @@ services: - "991" restart: unless-stopped + # ────────────────────────────────────────────────────────────────────────── + # Remote-access profile: Tailscale sidecar + # + # Enables secure smartphone access from anywhere without port-forwarding. + # Tailscale handles WireGuard encryption, TLS termination, and Let's Encrypt + # certificate provisioning automatically — no extra proxy container needed. + # + # Quick start: + # docker compose -f docker-compose.yml -f docker-compose.remote.yml \ + # --profile remote up -d --build + # + # Prerequisites (one-time setup): + # 1. Create a reusable Tailscale auth key: + # https://login.tailscale.com/admin/settings/keys + # 2. Enable MagicDNS + HTTPS certificates in your tailnet: + # https://login.tailscale.com/admin/dns + # 3. Set TS_AUTHKEY, TS_HOSTNAME, and TS_DOMAIN in your .env file + # 4. Set BASE_URL=https:// in your .env file + # + # See docs/REMOTE-ACCESS.md for the full step-by-step guide. + # ────────────────────────────────────────────────────────────────────────── + + tailscale: + image: tailscale/tailscale:latest + profiles: [remote] + hostname: ${TS_HOSTNAME:-copilot-unleashed} + entrypoint: ["/bin/sh", "/entrypoint.sh"] + environment: + - TS_AUTHKEY=${TS_AUTHKEY:?Set TS_AUTHKEY in .env (see docs/REMOTE-ACCESS.md)} + - TS_HOSTNAME=${TS_HOSTNAME:-copilot-unleashed} + # Full Tailscale FQDN for this device, e.g. copilot-unleashed.tail1234.ts.net + # Find yours at https://login.tailscale.com/admin/machines after first start + # Leave empty on first start (bootstrap); set before enabling HTTPS serving + - TS_DOMAIN=${TS_DOMAIN:-} + - TS_STATE_DIR=/var/lib/tailscale + - TS_USERSPACE=false + volumes: + - tailscale-data:/var/lib/tailscale + - ./scripts/tailscale-entrypoint.sh:/entrypoint.sh:ro + - /dev/net/tun:/dev/net/tun + cap_add: + - NET_ADMIN + restart: unless-stopped + healthcheck: + test: ["CMD", "tailscale", "status"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + volumes: copilot-data: + tailscale-data: diff --git a/docs/REMOTE-ACCESS.md b/docs/REMOTE-ACCESS.md new file mode 100644 index 0000000..66ba1f1 --- /dev/null +++ b/docs/REMOTE-ACCESS.md @@ -0,0 +1,272 @@ +# Remote Access — Smartphone to Local Container + +> **TL;DR** — Complete the one-time Tailscale setup below, then run: +> ```bash +> docker compose -f docker-compose.yml -f docker-compose.remote.yml --profile remote up -d --build +> ``` + +This guide explains how to securely access your locally-running **Copilot Unleashed** +container from a smartphone (or any device) **anywhere in the world** — without opening +firewall ports, without a static IP, and without Azure. + +The approach uses **Tailscale** (a WireGuard-based VPN mesh) with its built-in +**HTTPS/TLS serving** capability. Tailscale handles WireGuard encryption, TLS +termination, and Let's Encrypt certificate provisioning automatically. + +--- + +## Architecture + +``` +Smartphone ──── WireGuard (Tailscale) ────► Tailscale sidecar + │ Tailscale Serve + │ TLS :443 (auto Let's Encrypt cert) + │ + App container (:3000, HTTP) +``` + +**Why this is safe:** + +| Layer | Protection | +|-------|------------| +| Transport | WireGuard (ChaCha20-Poly1305) end-to-end encryption | +| TLS | Let's Encrypt certificate provisioned automatically by Tailscale | +| ACLs | Only devices you approve join your tailnet | +| App | Full production security (CSRF, secure cookies, HSTS, CSP) | +| Cookies | `Secure` flag enforced because `NODE_ENV=production` | + +--- + +## One-Time Setup + +### 1. Create a Tailscale account and tailnet + +1. Go to and sign up (free for personal use). +2. Install the Tailscale app on your **smartphone** + ([iOS](https://apps.apple.com/app/tailscale/id1470499037) / + [Android](https://play.google.com/store/apps/details?id=com.tailscale.ipn.android)). +3. Log in on your phone — it will join your tailnet automatically. + +### 2. Enable MagicDNS and HTTPS certificates + +In the Tailscale admin console (): + +- **Enable MagicDNS** — gives every device a stable hostname like + `copilot-unleashed.your-tailnet.ts.net` +- **Enable HTTPS** — Tailscale provisions a free Let's Encrypt certificate for each device + +### 3. Create a Tailscale auth key + +Go to and create a new key: + +- **Reusable**: ✅ (so the container can re-authenticate after restarts) +- **Ephemeral**: ❌ (we want the device to persist) +- **Expiry**: Set to a long duration or disable expiry for a home server + +Copy the key — it starts with `tskey-auth-…`. + +### 4. Add initial `.env` entries + +Add the following to your `.env` file (copy from `.env.example`): + +```dotenv +# Tailscale auth key from step 3 +TS_AUTHKEY=tskey-auth-XXXXXXXXXXXXX-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX + +# Device hostname (becomes ..ts.net after registration) +TS_HOSTNAME=copilot-unleashed + +# Leave TS_DOMAIN blank for now — you'll fill it in after the first start (step 6) +TS_DOMAIN= +``` + +### 5. First start — register the device + +Run the remote stack to register the Tailscale device and discover your full hostname: + +```bash +docker compose -f docker-compose.yml -f docker-compose.remote.yml \ + --profile remote up -d --build +``` + +Wait ~15 seconds, then check the Tailscale admin console: + + +You will see a new device named `copilot-unleashed` with a full hostname like: +`copilot-unleashed.tail1234.ts.net` + +> **Note:** The `tailnet` part (e.g., `tail1234`) is unique to your account. +> The exact FQDN is shown in the **Machines** column of the admin console. + +### 6. Set `TS_DOMAIN` and `BASE_URL` + +Now that you know the full hostname, update your `.env`: + +```dotenv +# Full Tailscale FQDN from the admin machines page +TS_DOMAIN=copilot-unleashed.tail1234.ts.net + +# Full HTTPS URL — same as TS_DOMAIN with https:// prefix +BASE_URL=https://copilot-unleashed.tail1234.ts.net +``` + +### 7. Restart to apply TLS serving + +```bash +docker compose -f docker-compose.yml -f docker-compose.remote.yml \ + --profile remote up -d --force-recreate +``` + +Tailscale will now provision a Let's Encrypt TLS certificate and start serving HTTPS. +After ~30 seconds, your app is reachable at `https://copilot-unleashed.tail1234.ts.net`. + +### 8. Generate VAPID keys (for push notifications — optional but recommended) + +Push notifications on mobile require HTTPS, which Tailscale now provides. Generate keys: + +```bash +node scripts/generate-vapid-keys.mjs +``` + +Copy the output into your `.env` file: + +```dotenv +VAPID_PUBLIC_KEY=Bxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +VAPID_PRIVATE_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +VAPID_SUBJECT=mailto:your@email.com +``` + +Restart after updating VAPID keys: + +```bash +docker compose -f docker-compose.yml -f docker-compose.remote.yml \ + --profile remote up -d --force-recreate +``` + +--- + +## Access from Your Smartphone + +1. Make sure the **Tailscale app** is running on your phone +2. Open **Safari** (iOS) or **Chrome** (Android) and navigate to + `https://copilot-unleashed.tail1234.ts.net` (your actual TS_DOMAIN) +3. You should see the Copilot Unleashed login screen with a valid HTTPS padlock + +### Install as a PWA + +Because you now have HTTPS, the app qualifies as a Progressive Web App: + +**iOS (Safari):** +- Tap the **Share** button → **Add to Home Screen** +- The app opens full-screen, just like a native app + +**Android (Chrome):** +- Tap the **menu (⋮)** → **Add to Home screen** (or the install banner may appear automatically) + +### Enable Push Notifications + +After installing as a PWA: +1. Open the app → **Settings** → enable **Push Notifications** +2. Accept the permission prompt +3. You'll receive notifications when the AI responds while the app is in the background + +--- + +## Stop the Remote Stack + +```bash +docker compose -f docker-compose.yml -f docker-compose.remote.yml \ + --profile remote down +``` + +To also remove volumes (including the Tailscale machine registration): + +```bash +docker compose -f docker-compose.yml -f docker-compose.remote.yml \ + --profile remote down -v +``` + +--- + +## Restrict Access to Specific Devices + +By default all devices on your tailnet can reach the app. To lock it down further: + +1. Go to +2. Use the ACL editor to allow only specific users or device tags: + +```json +{ + "acls": [ + { + "action": "accept", + "src": ["your@email.com"], + "dst": ["tag:server:443"] + } + ] +} +``` + +You can also enable **user approval** to require manual approval before any new device joins. + +--- + +## Restrict Login to Specific GitHub Users + +In addition to Tailscale ACLs, the app has its own application-level allowlist: + +```dotenv +# Only these GitHub usernames can log in (comma-separated) +ALLOWED_GITHUB_USERS=yourusername,partnerusername +``` + +--- + +## Troubleshooting + +### Tailscale container doesn't authenticate + +- Check that `TS_AUTHKEY` is set correctly in `.env` +- Verify the key hasn't expired: +- Check logs: `docker compose --profile remote logs tailscale` + +### App returns 403 Forbidden (CSRF) + +- Verify `BASE_URL` in `.env` matches the exact HTTPS URL of your Tailscale hostname + (must include `https://` scheme and no trailing slash) +- Ensure `docker-compose.remote.yml` is included in the compose command +- Confirm `NODE_ENV=production` is active (set by `docker-compose.remote.yml`) + +### HTTPS cert not provisioning / connection fails + +- Ensure **HTTPS certificates** are enabled in the Tailscale admin DNS settings +- `TS_DOMAIN` must be set and must exactly match the device's FQDN (visible in the + Tailscale admin machines page) +- Check logs: `docker compose --profile remote logs tailscale` +- The entrypoint script (`scripts/tailscale-entrypoint.sh`) will print the domain + it's configuring — verify it matches your expected FQDN +- Certs typically take up to 60 seconds to provision on first start + +### WebSocket connection fails + +- Confirm `TRUST_PROXY=1` is active (it's set automatically in `docker-compose.remote.yml`) +- Tailscale Serve passes WebSocket connections through transparently +- Check the app container logs: `docker compose --profile remote logs app` + +### Local development still works? + +Yes — the default `docker compose up` (without the remote override or `--profile remote`) +starts only the `app` service in development mode (`NODE_ENV=development`) with HTTP on +port 3000. The Tailscale service is **only** started with `--profile remote`. + +--- + +## Security Notes + +- **Tailscale auth keys**: Treat `TS_AUTHKEY` like a password. Rotate it periodically. +- **No open firewall ports**: Tailscale Serve listens only on the Tailscale network + interface (not on a public host port), so it is unreachable from the public internet. +- **TRUST_PROXY=1**: Trusts only the first proxy hop (Tailscale Serve). If you add + additional proxies, set `XFF_DEPTH` accordingly. +- **Production cookies**: The `Secure` cookie flag is enforced in production mode, so + session cookies are only sent over HTTPS. diff --git a/scripts/tailscale-entrypoint.sh b/scripts/tailscale-entrypoint.sh new file mode 100755 index 0000000..fa9f12c --- /dev/null +++ b/scripts/tailscale-entrypoint.sh @@ -0,0 +1,53 @@ +#!/bin/sh +# Tailscale container entrypoint for the remote-access profile. +# +# When TS_DOMAIN is set: generates a tailscale serve config that routes all +# HTTPS traffic on port 443 to the app container at http://app:3000. +# Tailscale automatically provisions a Let's Encrypt TLS certificate for the +# served domain — no manual cert management required. +# +# When TS_DOMAIN is empty: starts Tailscale in plain VPN mode (no HTTPS +# serving). This is used on the first-start bootstrap run so the device can +# register and you can discover its FQDN from the Tailscale admin console. +# +# TS_DOMAIN must be the full Tailscale FQDN for this device, e.g.: +# copilot-unleashed.tail1234.ts.net +# It is set in your .env file and passed through docker-compose.yml. +# See docs/REMOTE-ACCESS.md for the step-by-step setup guide. + +set -e + +if [ -z "$TS_DOMAIN" ]; then + echo "[tailscale-entrypoint] TS_DOMAIN is not set." + echo " Starting in bootstrap mode (VPN only, no HTTPS serving)." + echo " After this container starts, find the full FQDN at:" + echo " https://login.tailscale.com/admin/machines" + echo " Then set TS_DOMAIN and BASE_URL in .env and restart." + exec /usr/local/bin/containerboot +fi + +# Generate the tailscale serve config that proxies HTTPS → http://app:3000. +# The hostname key must exactly match the device's Tailscale FQDN (TS_DOMAIN). +printf '{ + "TCP": { + "443": { + "HTTPS": true + } + }, + "Web": { + "%s:443": { + "Handlers": { + "/": { + "Proxy": "http://app:3000" + } + } + } + } +}\n' "$TS_DOMAIN" > /tmp/serve.json + +export TS_SERVE_CONFIG=/tmp/serve.json + +echo "[tailscale-entrypoint] Serve config written for domain: $TS_DOMAIN" + +# Replace this shell with containerboot (becomes PID 1, handles signals correctly) +exec /usr/local/bin/containerboot \ No newline at end of file diff --git a/server.js b/server.js index 312e92b..fbcb3b6 100644 --- a/server.js +++ b/server.js @@ -16,6 +16,15 @@ const tokenMaxAge = parseInt(process.env.TOKEN_MAX_AGE_MS || String(7 * 24 * 60 if (!process.env.ORIGIN) { process.env.ORIGIN = process.env.BASE_URL || `http://localhost:${port}`; } + +// Trust reverse-proxy headers (X-Forwarded-For, X-Forwarded-Proto) when TRUST_PROXY=1. +// Required when Tailscale Serve (or any reverse proxy) sits in front of the app. +// Set automatically by docker-compose.remote.yml for the remote-access profile. +if (process.env.TRUST_PROXY === '1') { + process.env.ADDRESS_HEADER = 'X-Forwarded-For'; + process.env.XFF_DEPTH = process.env.XFF_DEPTH || '1'; +} + const { handler } = await import('./build/handler.js'); if (!isDev && !process.env.SESSION_SECRET) {