# Remote Access (Tailscale) Monitor and control Claude Code sessions from your phone, tablet, or any device on your Tailnet. ## Overview Jacques supports secure remote access via Tailscale Serve. Three layers of protection: 1. **Server binds to `127.0.0.1`** — unreachable from the network directly 2. **Tailscale Serve** provides HTTPS termination and injects identity headers (unforgeable from the client side) 3. **6-digit setup PIN** required for first-time device approval No ports are exposed to the internet. All traffic stays within your private Tailnet over WireGuard. ## Why Tailscale? Jacques uses Tailscale Serve for remote access because: - **Private network**: Traffic stays within your Tailnet — never touches the public internet - **WireGuard encryption**: All traffic encrypted end-to-end with modern cryptography - **Identity headers**: Tailscale injects unforgeable identity headers (user login, name) into proxied requests - **No port forwarding**: Server binds to `127.0.0.1` — no ports exposed to your LAN or internet - **Zero-config HTTPS**: Tailscale Serve provides automatic HTTPS with valid certificates ## Prerequisites - **Tailscale** installed and running on your host machine (see platform-specific install below) - **Tailscale Serve** enabled on your Tailnet (one-time admin console step — the launch script guides you) - **Same Tailnet** on your machine and your phone/tablet (install Tailscale on your phone) ## Quick Start ```bash npm run start:remote ``` This single command (cross-platform Node.js script, works on macOS/Linux/Windows): 1. Detects Tailscale binary (system PATH + Windows fallback paths) 2. Checks Tailscale is running and Serve is enabled 3. Builds server + GUI if needed 4. Stops any existing Jacques server 5. Starts Jacques with `JACQUES_TAILSCALE_AUTH=true` (binds to `127.0.0.1`) 6. Runs `tailscale serve --bg 4243` to expose HTTPS on your Tailnet 7. Generates a 6-digit setup code and prints the access URL + PIN 8. Prints a QR code in the terminal (scan with phone camera) 9. On Ctrl+C: stops server and tears down Tailscale Serve Open the printed URL on your phone (or scan the QR code), enter the 6-digit code, and you're in. ## QR Code The launch script prints a scannable QR code in the terminal after startup. The QR encodes the Tailscale HTTPS URL. Scan with your phone camera → opens in browser → enter PIN. The GUI `RemoteAccessModal` also displays a QR code (as SVG) when remote access is active — accessible from Settings or the top bar. ## Architecture ``` Phone / Tablet (Tailscale app) │ │ HTTPS over WireGuard tunnel ▼ Tailscale Serve (on your machine) │ Injects identity headers: │ Tailscale-User-Login: user@example.com │ Tailscale-User-Name: Alice │ Tailscale-User-Profile-Pic: https://... │ │ HTTPS → HTTP proxy ▼ ┌──────────────────────────────────────────┐ │ Jacques Server (127.0.0.1:4243) │ │ HTTP + WebSocket (unified port) │ │ │ │ Auth Middleware │ │ ├─ Local (no headers)? → allow │ │ ├─ Tailscale user approved? → allow │ │ ├─ Valid setup code? → approve & allow │ │ └─ Else → pendingApproval (PIN gate) │ │ │ │ /api/* routes (gated) │ │ Static GUI files (always served) │ │ WebSocket upgrade (auth verified) │ └──────────────────────────────────────────┘ ``` ## Development Mode (HMR over Tailscale) For GUI development with Vite hot module replacement remotely: ### How It Differs from Production | Mode | Command | Port exposed | GUI changes | |------|---------|--------------|-------------| | **Production** | `npm run start:remote` | 4243 (Jacques server) | Rebuild + restart required | | **Development** | `npm run dev:remote` | 5173 (Vite dev server) | Instant hot reload | In dev mode, Vite proxies `/api` and `/ws` (including `/ws/app` and `/ws/terminal`) to the Jacques server on port 4243. Tailscale Serve exposes Vite (port 5173) instead of the Jacques server directly. ### Setup 1. Start the Jacques server with auth: ```bash JACQUES_TAILSCALE_AUTH=true npm run start:server ``` 2. Start Vite with remote config: ```bash npm run dev:remote ``` 3. Expose Vite (not the Jacques server) via Tailscale: ```bash tailscale serve --bg 5173 ``` 4. Open `https://` on your remote device ### Architecture (Dev Mode) ``` iPad (Safari) | HTTPS (port 443) v Tailscale Serve | HTTP proxy to localhost:5173 v Vite Dev Server (localhost:5173) |-- /* (pages + HMR) -> Vite handles directly (hot reload) |-- /api/* -> proxy to localhost:4243 (Jacques HTTP API) |-- /ws/* -> proxy to ws://localhost:4243 (Jacques WebSocket — /ws/app and /ws/terminal) Jacques Server (localhost:4243) ``` ### Environment Variable | Variable | Default | Purpose | |----------|---------|---------| | `JACQUES_REMOTE` | `false` | Set to `true` to configure Vite for remote access (sets `hmr.clientPort: 443` for Tailscale HTTPS) | ### Notes - **Tailscale Serve target**: Point to port 5173 (Vite), NOT 4243 (Jacques server) - **Auth headers**: Tailscale identity headers are forwarded through the Vite proxy automatically - **Binary WebSocket**: Terminal I/O binary frames pass through the proxy transparently - **Switching modes**: To switch from dev to production remote, change Tailscale Serve target: ```bash tailscale serve reset tailscale serve --bg 4243 # For production mode # or tailscale serve --bg 5173 # For dev mode ``` ## Launch Script (`scripts/start-remote.js`) The cross-platform Node.js script (`npm run start:remote`) performs these steps: > **Note**: The legacy bash script `scripts/start-remote.sh` is kept for reference but is no longer the default. ### Preflight Checks 1. **Tailscale installed?** — checks `command -v tailscale` 2. **Tailscale running?** — checks `tailscale status` 3. **Detect hostname** — parses `tailscale status --self --json` for `DNSName` 4. **Clean stale Serve config** — runs `tailscale serve reset` 5. **Serve enabled?** — attempts `tailscale serve --bg 4243`; if not enabled, prints the admin console link and exits ### Startup 6. **Build if needed** — builds server/GUI if `dist/` files missing 7. **Stop existing server** — `npm run stop:server` 8. **Start server** — `JACQUES_TAILSCALE_AUTH=true npm run start:server` in background 9. **Expose via Tailscale Serve** — `tailscale serve --bg 4243` 10. **Generate setup code** — `POST http://localhost:4243/api/auth/setup` 11. **Print access info** — URL (`https://`) + 6-digit PIN ### Cleanup (on Ctrl+C) 12. `tailscale serve reset` — tears down Serve proxy 13. `npm run stop:server` — stops Jacques ## Auth Flow ### First-Time Device Approval ``` 1. Server starts → generates 6-digit setup code → displays on terminal 2. Remote user opens https:// on phone ↓ 3. GUI loads (static files always served regardless of auth) ↓ 4. AuthGate component calls GET /api/auth/me ↓ 5. Server sees Tailscale headers but user not in approved list → returns { authEnabled: true, pendingApproval: true, identity: {...} } ↓ 6. PendingApproval screen shown — 6-digit PIN input ↓ 7. User enters code → POST /api/auth/approve { code: "123456" } ↓ 8. Server validates code → adds user to ~/.jacques/approved-users.json ↓ 9. Response: { approved: true, identity: { login, name } } ↓ 10. App reloads → full dashboard access ``` ### Subsequent Connections Approved users are recognized automatically via Tailscale identity headers — no PIN required. Local connections (127.0.0.1 without Tailscale headers) are always allowed. ### Setup Code Details | Property | Value | |----------|-------| | Format | 6-digit numeric (100000–999999) | | Generation | Cryptographically random | | Lifetime | 5 minutes (`CODE_TTL_MS = 300000`) | | Usage | Single-use — consumed on first successful validation | | Concurrency | Only one active code at a time; generating a new one replaces the previous | | Access | Local-only — `GET/POST /api/auth/setup` returns 403 for remote users | To generate a new code after expiry: use the GUI Settings page (locally) or re-run `npm run start:remote`. ## Environment Variables | Variable | Default | Purpose | |----------|---------|---------| | `JACQUES_TAILSCALE_AUTH` | `false` | Enable Tailscale authentication. When `true`, server binds to `127.0.0.1` | | `JACQUES_TAILSCALE_ALLOWED_USERS` | _(empty)_ | Comma-separated whitelist of Tailscale logins (e.g., `alice@example.com,bob@example.com`). Empty means all Tailnet users allowed (after PIN approval) | | `JACQUES_BIND_ADDRESS` | `0.0.0.0` (or `127.0.0.1` when auth enabled) | Server bind address. Auto-set to `127.0.0.1` when `JACQUES_TAILSCALE_AUTH=true` unless overridden | These are defined in `server/src/config/config.ts` (`ServerConfig` object). ## Auth API Endpoints All endpoints are in `server/src/routes/auth-routes.ts`. | Method | Path | Access | Description | |--------|------|--------|-------------| | `GET` | `/api/auth/me` | Anyone | Current identity and approval status | | `GET` | `/api/auth/setup` | Local only | Get current active setup code | | `POST` | `/api/auth/setup` | Local only | Generate (or regenerate) setup code | | `POST` | `/api/auth/approve` | Anyone | Approve device with setup code | | `GET` | `/api/auth/devices` | Local only | List all approved users | | `DELETE` | `/api/auth/devices/:login` | Local only | Revoke a user's access | ### Request/Response Shapes **GET /api/auth/me** ```json { "authEnabled": true, "allowed": false, "isLocal": false, "pendingApproval": true, "identity": { "login": "user@example.com", "name": "Alice", "profilePic": "https://..." } } ``` **GET/POST /api/auth/setup** ```json { "code": "482917", "expiresAt": 1740700000000 } ``` **POST /api/auth/approve** - Request: `{ "code": "482917" }` - Success: `{ "approved": true, "identity": { "login": "user@example.com", "name": "Alice" } }` - Error: `{ "error": "invalid_code" }` (HTTP 403) **GET /api/auth/devices** ```json { "devices": [ { "login": "user@example.com", "name": "Alice", "approvedAt": "2026-02-27T...", "lastSeen": "2026-02-27T..." } ] } ``` **DELETE /api/auth/devices/:login** ```json { "revoked": true, "login": "user@example.com" } ``` ### Auth Middleware (`server/src/http-api.ts`) When Tailscale auth is enabled: - **Gated**: All `/api/*` routes return 403 for unapproved Tailscale users (except `/api/auth/*` which is always accessible for the approval flow) - **Ungated**: Static GUI files are always served so the `AuthGate` component can render the PendingApproval screen - **WebSocket**: Upgrade requests are verified via `verifyUpgrade` callback in `start-server.ts` — unapproved users cannot establish WebSocket connections - **Local**: Connections from `127.0.0.1` / `::1` without Tailscale headers are always allowed (backward compat) ## GUI Components ### AuthGate (`gui-v2/src/components/auth/AuthGate.tsx`) Wraps the entire app in `App.tsx`. On mount, calls `GET /api/auth/me`. - **Loading**: Returns null (brief flash while checking) - **Allowed**: Renders children (full app) - **Pending**: Renders `` screen instead of the app - **Error or auth disabled**: Proceeds normally (backward compat with servers without auth) ### PendingApproval (`gui-v2/src/components/auth/PendingApproval.tsx`) Full-screen PIN entry form shown when a Tailscale user connects but is not yet approved. - Displays identity info: "Connecting as {name} ({login})" - Monospace 6-digit input with auto-focus, filters non-digits - Submit button disabled until 6 digits entered - On success: calls `onApproved()` → page reload → full access - On error: shows error message, allows retry ### ApprovedDevices (`gui-v2/src/components/auth/ApprovedDevices.tsx`) Settings panel for managing approved remote users (accessible locally only). - Lists approved users with name, login, approval date - "Revoke" button per user → calls `DELETE /api/auth/devices/:login` - "Generate Setup Code" / "Regenerate Code" button → calls `POST /api/auth/setup` - Shows active code with expiration time ## GUI API Client (`gui-v2/src/api/auth.ts`) | Function | Method | Endpoint | Returns | |----------|--------|----------|---------| | `getAuthMe()` | GET | `/api/auth/me` | `AuthMe` (authEnabled, allowed, identity, etc.) | | `getSetupCode()` | GET | `/api/auth/setup` | `{ code, expiresAt }` | | `regenerateSetupCode()` | POST | `/api/auth/setup` | `{ code, expiresAt }` | | `approveDevice(code)` | POST | `/api/auth/approve` | `{ approved, identity }` | | `getApprovedDevices()` | GET | `/api/auth/devices` | `ApprovedDevice[]` | | `revokeDevice(login)` | DELETE | `/api/auth/devices/:login` | void | ## Data Storage | File | Format | Purpose | |------|--------|---------| | `~/.jacques/approved-users.json` | JSON array | Persistent list of approved Tailscale users | Each entry: `{ login: string, name: string, approvedAt: string, lastSeen?: string }`. Setup codes are held in memory only (not persisted to disk). ## Key Files | File | Responsibility | |------|----------------| | `server/src/auth/tailscale-auth.ts` | Core auth: `authenticateRequest()`, `extractIdentity()`, `isLocalAddress()`, `extractSetupCode()` | | `server/src/auth/approved-users.ts` | Approved user CRUD: `getApprovedUsers()`, `isApproved()`, `addApprovedUser()`, `removeApprovedUser()`, `touchApprovedUser()` | | `server/src/auth/setup-code.ts` | PIN management: `generateSetupCode()`, `validateSetupCode()`, `getActiveSetupCode()`, `clearSetupCode()` | | `server/src/auth/index.ts` | Barrel re-exports for all auth modules | | `server/src/routes/auth-routes.ts` | 6 HTTP routes for the auth flow (`createAuthRoutes()`) | | `server/src/http-api.ts` | Auth middleware that gates `/api/*` routes | | `server/src/start-server.ts` | WS `verifyUpgrade` callback for WebSocket auth | | `server/src/config/config.ts` | `ServerConfig` fields: `tailscaleAuth`, `tailscaleAllowedUsers`, `bindAddress` | | `gui-v2/src/components/auth/AuthGate.tsx` | App-level auth wrapper | | `gui-v2/src/components/auth/PendingApproval.tsx` | PIN entry screen | | `gui-v2/src/components/auth/ApprovedDevices.tsx` | Settings panel for device management | | `gui-v2/src/api/auth.ts` | HTTP client for auth endpoints | | `scripts/start-remote.js` | Cross-platform Node.js launch script (macOS/Linux/Windows) | | `scripts/start-remote.sh` | Legacy bash launch script (deprecated) | | `gui-v2/src/components/RemoteAccessModal.tsx` | Remote access modal with QR code, PIN, devices | | `gui-v2/src/api/auth.ts` | GUI API client (`getRemoteStatus()`, auth endpoints) | ## Tests | File | Coverage | |------|----------| | `server/src/auth/tailscale-auth.test.ts` | Auth logic: local/remote detection, identity extraction, setup code flow, user whitelist (14 tests) | | `server/src/auth/approved-users.test.ts` | User store: add/remove/check, persistence, no duplicates (8 tests) | | `server/src/auth/setup-code.test.ts` | Code generation, validation, single-use, expiry, replacement (9 tests) | Run with: `cd server && npm test` ## Phone/Tablet Setup ### iOS 1. Install **Tailscale** from the App Store 2. Open Tailscale → Sign in with the same account used on your host machine 3. Wait for the device to appear in your Tailnet (automatic) 4. Scan the QR code from `npm run start:remote` (or type the URL into Safari) 5. Enter the 6-digit PIN shown in the terminal 6. Bookmark the URL for quick access ### Android 1. Install **Tailscale** from the Play Store 2. Open Tailscale → Sign in with the same account used on your host machine 3. Wait for the device to appear in your Tailnet (automatic) 4. Scan the QR code from `npm run start:remote` (or type the URL into Chrome) 5. Enter the 6-digit PIN shown in the terminal 6. Add to home screen for app-like access ### After First Approval Once approved, your device is remembered in `~/.jacques/approved-users.json`. Subsequent visits are auto-approved via Tailscale identity headers — no PIN required. ## Windows Host Setup Jacques remote access works fully on Windows: 1. **Install Tailscale**: Download from [tailscale.com/download/windows](https://tailscale.com/download/windows) (MSI or EXE) 2. **Sign in**: Open Tailscale → sign in to your Tailnet 3. **CLI access**: The Tailscale CLI may not be in PATH. Default locations: - `C:\Program Files\Tailscale IPN\tailscale.exe` - `C:\Program Files\Tailscale\tailscale.exe` - The `start-remote.js` script checks these automatically 4. **Run**: `npm run start:remote` — works identically to macOS/Linux 5. **Serve**: `tailscale serve --bg 4243` works the same way 6. **Identity headers**: Injected identically, platform-independent 7. **tmux**: Not available on Windows — terminal features disabled, but monitoring/dashboard work fully ## Remote Status API `GET /api/remote/status` — Returns the current remote access mode and Tailscale hostname. When Tailscale auth is enabled: ```json { "mode": "tailscale", "hostname": "my-machine.tail-abc123.ts.net", "url": "https://my-machine.tail-abc123.ts.net" } ``` When disabled: ```json { "mode": "none" } ``` Used by the GUI `RemoteAccessModal` to display real URL and QR code. ## Troubleshooting ### "Tailscale is not installed" Install Tailscale: `brew install tailscale` (macOS) or see [tailscale.com/download](https://tailscale.com/download). ### "Tailscale is not running" Open the Tailscale app or run `sudo tailscaled` to start the daemon. ### "Could not detect Tailscale hostname" Check your Tailscale status: `tailscale status`. Ensure you're logged in to your Tailnet. ### "Tailscale Serve not enabled" This is a one-time setup. The launch script will print an admin console link. Open it, click "Enable" (do NOT select Funnel — Funnel exposes to the internet, Serve stays within your Tailnet). ### "Setup code expired" Codes expire after 5 minutes. Generate a new one: - **From the GUI**: Settings > Approved Devices > "Regenerate Code" - **From the terminal**: `curl -s -X POST http://localhost:4243/api/auth/setup` - **Re-run**: `npm run start:remote` (generates a fresh code each time) ### "User not in allowed users list" If `JACQUES_TAILSCALE_ALLOWED_USERS` is set, only those Tailscale logins can approve. Check the value and add the user's login (their Tailscale email). ### "Can't connect from phone" 1. Ensure Tailscale is running on your phone 2. Ensure both devices are on the same Tailnet 3. Try pinging your machine from your phone: Settings > Devices > tap your machine 4. Check the URL matches your Tailscale hostname (`tailscale status --self`) ### Windows: "tailscale not found" The Tailscale CLI on Windows may not be in PATH. The `start-remote.js` script checks common install paths automatically. If it still fails, add the Tailscale directory to your PATH or run: ```powershell & "C:\Program Files\Tailscale IPN\tailscale.exe" status ``` ### Server still accessible when it shouldn't be Verify the server is bound to `127.0.0.1`: `lsof -i :4243` should show `127.0.0.1:4243`, not `*:4243`. If bound to `*`, ensure `JACQUES_TAILSCALE_AUTH=true` is set (this auto-sets bind address to `127.0.0.1`).