diff --git a/.env.example b/.env.example index 3209aa8..6c6c1a3 100644 --- a/.env.example +++ b/.env.example @@ -10,20 +10,15 @@ # # Or manually: # docker compose up -d -# # Then visit https://pangolin.DOMAIN to complete Pangolin setup # ── Domain ────────────────────────────────────────────────────────────────── # Base domain. Subdomains are created automatically: # fleet.DOMAIN — paws dashboard + API # grafana.DOMAIN — Grafana dashboards -# pangolin.DOMAIN — Pangolin tunnel management dashboard -# tunnel.DOMAIN — WireGuard endpoint (DNS-only, no Cloudflare proxy) # # DNS records needed (all pointing to your VPS IP): # *.DOMAIN A record (for all subdomains) -# tunnel.DOMAIN A record (DNS-only / gray cloud for WireGuard UDP) DOMAIN=tpops.dev -TUNNEL_DOMAIN=tunnel.tpops.dev GRAFANA_DOMAIN=grafana.tpops.dev # ── TLS Certificates ─────────────────────────────────────────────────────── @@ -56,21 +51,6 @@ OIDC_CLIENT_SECRET=paws-dex-secret-changeme GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= -# ── Pangolin ──────────────────────────────────────────────────────────────── -# Secret for Pangolin sessions — must be 32+ characters. -# Generate: openssl rand -hex 32 -PANGOLIN_SECRET=change-me-to-a-random-32-char-string-for-pangolin - -# OIDC client secret for Pangolin → Dex SSO (auto-generated by setup script). -# Users log into exposed ports with the same identity as the paws dashboard. -PANGOLIN_OIDC_SECRET=pangolin-dex-secret-changeme - -# After Pangolin first-boot: create an API key in the Pangolin dashboard -# and set these so the control plane can discover workers via tunnel. -PANGOLIN_API_URL=http://pangolin:3001/api/v1 -PANGOLIN_API_KEY= -PANGOLIN_ORG_ID= - # ── Grafana ───────────────────────────────────────────────────────────────── GRAFANA_ADMIN_PASSWORD=admin diff --git a/.gitignore b/.gitignore index 2dc7151..f9b02a6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +*.tsbuildinfo .env .env.local *.log @@ -32,7 +33,7 @@ infra/dex/config.dev.yaml infra/cloudflare/tunnel-config.yml # Generated configs (created by scripts/setup-control-plane.sh) -config/pangolin/config.yml config/dex/config.yaml -config/traefik/traefik_config.yml -config/traefik/dynamic_config.yml + +# Turborepo +.turbo diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a1c59d..e1fa5d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [0.5.4] - 2026-04-03 — Pangolin Removal + +### Removed + +- **Pangolin WireGuard tunneling** — removed Pangolin, Gerbil, and Traefik from docker-compose, env config, and all setup scripts +- **Pangolin discovery** — control plane no longer polls Pangolin API for worker discovery. K8s pod watcher (primary) and WebSocket call-home (remote) are the discovery mechanisms. +- **Pangolin port exposure** — tunnel-based port exposure removed. PortExposureProvider interface remains for future control plane reverse proxy. +- **Tunnels dashboard page** — removed from sidebar, router, and command palette +- **Pangolin admin proxy** — removed /v1/pangolin/\* routes, admin client, and SSO auto-registration + ## [0.5.2.0] - 2026-03-29 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 1a70c7b..9c8bd7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,8 +84,8 @@ Read before making changes: @docs/architecture.md, @docs/security.md - Zero secrets enter the VM — credentials injected at network layer by per-VM proxy - Daemons are ephemeral — each trigger spins up a fresh VM, state persists via control plane DB + mounted volumes -- Workers connect via Pangolin WireGuard tunnels (Newt agent). Control plane discovers workers by - polling Pangolin's API. Fallback: WebSocket call-home, K8s discovery, static URL. +- Workers connect via K8s Services (in-cluster) or WebSocket call-home (remote). Control plane + discovers workers via K8s pod watcher (primary) or static URL (dev). ## Non-Negotiable Decisions diff --git a/README.md b/README.md index c0ecd67..ef63205 100644 --- a/README.md +++ b/README.md @@ -86,17 +86,17 @@ The API keys in that request never enter the VM. They stay on the host, injected ## Features -| | Feature | Description | -| ---------------------- | -------------------------- | ----------------------------------------------------------------------- | -| :lock: | **Zero-trust credentials** | Per-VM TLS MITM proxy injects API keys at the network layer | -| :zap: | **Sub-second boot** | Firecracker memory snapshots restore VMs in <800ms | -| :bar_chart: | **Dashboard** | Fleet management, session history, daemon config, audit log | -| :robot: | **Daemon workflows** | Persistent agent roles triggered by webhooks, cron, or GitHub events | -| :shield: | **Governance** | Rate limits, approval gates, full audit logging per daemon | -| :electric_plug: | **MCP Gateway** | Connect agents to MCP tool servers running on the host | -| :globe_with_meridians: | **Port exposure** | Agents expose web apps via Pangolin tunnels with SSO/PIN access control | -| :computer: | **CLI** | `paws run`, `paws top`, `paws logs` -- one-command agent execution | -| :package: | **SDKs** | TypeScript and Python clients, generated from OpenAPI spec | +| | Feature | Description | +| ---------------------- | -------------------------- | -------------------------------------------------------------------- | +| :lock: | **Zero-trust credentials** | Per-VM TLS MITM proxy injects API keys at the network layer | +| :zap: | **Sub-second boot** | Firecracker memory snapshots restore VMs in <800ms | +| :bar_chart: | **Dashboard** | Fleet management, session history, daemon config, audit log | +| :robot: | **Daemon workflows** | Persistent agent roles triggered by webhooks, cron, or GitHub events | +| :shield: | **Governance** | Rate limits, approval gates, full audit logging per daemon | +| :electric_plug: | **MCP Gateway** | Connect agents to MCP tool servers running on the host | +| :globe_with_meridians: | **Port exposure** | Agents expose web apps via port exposure with SSO/PIN access control | +| :computer: | **CLI** | `paws run`, `paws top`, `paws logs` -- one-command agent execution | +| :package: | **SDKs** | TypeScript and Python clients, generated from OpenAPI spec | ## Architecture @@ -127,7 +127,7 @@ The API keys in that request never enter the VM. They stay on the host, injected - **Workers** run on bare metal with `/dev/kvm`, each VM gets a dedicated TLS proxy - **One proxy per VM** -- never shared, spawned with the VM, killed with the VM - VMs boot from Firecracker memory snapshots in <800ms -- Workers auto-register via WireGuard tunnels (Pangolin/Newt) +- Workers connect via K8s Services (in-cluster) or WebSocket call-home (remote) ## Comparison diff --git a/VERSION b/VERSION index be14282..7d85683 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.3 +0.5.4 diff --git a/apps/control-plane/src/app.ts b/apps/control-plane/src/app.ts index d92be68..d93956c 100644 --- a/apps/control-plane/src/app.ts +++ b/apps/control-plane/src/app.ts @@ -132,10 +132,6 @@ export interface ControlPlaneDeps { auditStore?: AuditStore | undefined; /** Bun WebSocket upgrader — needed for worker WS and session streaming. */ upgradeWebSocket?: import('hono/ws').UpgradeWebSocket | undefined; - /** Pangolin tunnel status for fleet overview. */ - pangolinStatus?: - | (() => { connected: boolean; tunnelWorkers: number; lastPollAt: string | null }) - | undefined; } const startTime = Date.now(); @@ -426,7 +422,12 @@ export async function createControlPlaneApp(deps: ControlPlaneDeps) { return c.json({ authenticated: false }, 401); } - const session = passwordAuth?.validateSession(match[1]); + const sessionToken = match[1]; + if (!sessionToken) { + return c.json({ authenticated: false }, 401); + } + + const session = passwordAuth?.validateSession(sessionToken); if (!session) { return c.json({ authenticated: false }, 401); } @@ -438,10 +439,11 @@ export async function createControlPlaneApp(deps: ControlPlaneDeps) { app.post('/auth/password-logout', (c) => { const cookies = c.req.header('cookie') ?? ''; const match = cookies.match(/paws_session=([^;]+)/); - if (match && passwordAuth) { + const logoutToken = match?.[1]; + if (logoutToken && passwordAuth) { // Get email before deleting session - const session = passwordAuth.validateSession(match[1]); - passwordAuth.logout(match[1]); + const session = passwordAuth.validateSession(logoutToken); + passwordAuth.logout(logoutToken); auditStore.append({ category: 'auth', action: 'auth.logout', @@ -518,7 +520,11 @@ export async function createControlPlaneApp(deps: ControlPlaneDeps) { // --- Auth middleware for all /v1 routes (except webhooks) --- - const authConfig: AuthConfig = { apiKey: deps.apiKey, oidcEnabled, passwordAuth }; + const authConfig: AuthConfig = { + apiKey: deps.apiKey, + oidcEnabled, + ...(passwordAuth ? { passwordAuth } : {}), + }; app.use('/v1/sessions', authMiddleware(authConfig)); app.use('/v1/sessions/*', authMiddleware(authConfig)); app.use('/v1/daemons/*', authMiddleware(authConfig)); @@ -530,8 +536,6 @@ export async function createControlPlaneApp(deps: ControlPlaneDeps) { app.use('/v1/snapshot-configs', authMiddleware(authConfig)); app.use('/v1/setup/*', authMiddleware(authConfig)); app.use('/v1/setup', authMiddleware(authConfig)); - app.use('/v1/pangolin/*', authMiddleware(authConfig)); - app.use('/v1/pangolin', authMiddleware(authConfig)); app.use('/v1/servers', authMiddleware(authConfig)); app.use('/v1/servers/*', authMiddleware(authConfig)); app.use('/v1/provisioning', authMiddleware(authConfig)); @@ -1084,7 +1088,12 @@ export async function createControlPlaneApp(deps: ControlPlaneDeps) { } else { createLogger('daemon').error('No workload or agent configured', { role }); return c.json( - { error: { code: 'DAEMON_MISCONFIGURED', message: 'No workload or agent configured' } }, + { + error: { + code: 'INTERNAL_ERROR' as const, + message: 'No workload or agent configured', + }, + }, 500, ); } @@ -1305,6 +1314,12 @@ export async function createControlPlaneApp(deps: ControlPlaneDeps) { const sessionId = randomUUID(); // Build session request from daemon config (use fullDaemon for proper types) const src = fullDaemon ?? daemon; + if (!src.workload) { + return c.json( + { error: { code: 'DAEMON_MISCONFIGURED', message: 'No workload configured' } }, + 500, + ); + } const sessionRequest = { snapshot: src.snapshot, workload: { @@ -1402,7 +1417,6 @@ export async function createControlPlaneApp(deps: ControlPlaneDeps) { queuedSessions, activeDaemons: daemonStore.countActive(), activeSessions: sessionStore.countActiveSessions(), - ...(deps.pangolinStatus && { pangolin: deps.pangolinStatus() }), }, 200, ); @@ -1541,25 +1555,6 @@ export async function createControlPlaneApp(deps: ControlPlaneDeps) { return c.body(null, 204); }); - // --- Pangolin admin proxy --- - - if (deps.discovery) { - const pangolinApiUrl = process.env['PANGOLIN_API_URL'] ?? ''; - const pangolinOrgId = process.env['PANGOLIN_ORG_ID'] ?? ''; - if (pangolinApiUrl && pangolinOrgId) { - const { createPangolinAdmin } = await import('./pangolin-admin.js'); - const { createPangolinRoutes } = await import('./routes/pangolin.js'); - const pangolinAdmin = createPangolinAdmin({ - apiUrl: pangolinApiUrl, - apiKey: process.env['PANGOLIN_API_KEY'] ?? undefined, - email: process.env['PANGOLIN_EMAIL'] ?? undefined, - password: process.env['PANGOLIN_PASSWORD'] ?? undefined, - orgId: pangolinOrgId, - }); - app.route('/v1/pangolin', createPangolinRoutes(pangolinAdmin)); - } - } - // --- Provisioner (shared by setup wizard + provisioning routes) --- const { createProvisioner, createSshClient } = await import('@paws/provisioner'); diff --git a/apps/control-plane/src/auth/oauth.ts b/apps/control-plane/src/auth/oauth.ts index d43c897..82f7499 100644 --- a/apps/control-plane/src/auth/oauth.ts +++ b/apps/control-plane/src/auth/oauth.ts @@ -131,7 +131,11 @@ export function createOAuthProvider(db: PawsDatabase, passwordAuth: PasswordAuth return { valid: false }; } - return { valid: true, clientName: client.clientName ?? undefined }; + const clientName = client.clientName; + return { + valid: true, + ...(clientName ? { clientName } : {}), + }; }, createAuthCode(opts) { diff --git a/apps/control-plane/src/autoscaler.ts b/apps/control-plane/src/autoscaler.ts index d710c27..3ceba11 100644 --- a/apps/control-plane/src/autoscaler.ts +++ b/apps/control-plane/src/autoscaler.ts @@ -114,39 +114,7 @@ export function createAutoscaler(config: AutoscalerConfig): Autoscaler { return Date.now() - lastScaleTime >= cooldownMs; } - function generateCloudInit(newtSiteId?: string, newtSiteSecret?: string): string { - const newtSetup = - newtSiteId && newtSiteSecret - ? ` -# Install and start Newt (Pangolin tunnel agent) -curl -fsSL https://static.pangolin.net/get-newt.sh | bash -cat > /etc/systemd/system/newt.service << 'SYSTEMD' -[Unit] -Description=Newt Tunnel Agent -After=network-online.target -Wants=network-online.target - -[Service] -ExecStart=/usr/local/bin/newt --id ${newtSiteId} --secret ${newtSiteSecret} --endpoint ${gatewayUrl} -Restart=always -RestartSec=5 - -[Install] -WantedBy=multi-user.target -SYSTEMD -systemctl daemon-reload -systemctl enable --now newt -` - : ` -# Start worker with call-home (legacy, no Pangolin) -GATEWAY_URL=${gatewayUrl} \\ -API_KEY=${apiKey} \\ -WORKER_NAME=worker-$(hostname) \\ -WORKER_URL=http://$(curl -s http://169.254.169.254/hetzner/v1/metadata/public-ipv4):3000 \\ -PORT=3000 \\ -nohup bun run apps/worker/src/server.ts > /var/log/paws-worker.log 2>&1 & -`; - + function generateCloudInit(): string { return `#!/bin/bash set -euo pipefail export DEBIAN_FRONTEND=noninteractive @@ -163,9 +131,13 @@ bun install # Install Firecracker scripts/install-firecracker.sh -# Start worker -PORT=3000 nohup bun run apps/worker/src/server.ts > /var/log/paws-worker.log 2>&1 & -${newtSetup} +# Start worker with call-home +GATEWAY_URL=${gatewayUrl} \\ +API_KEY=${apiKey} \\ +WORKER_NAME=worker-$(hostname) \\ +WORKER_URL=http://$(curl -s http://169.254.169.254/hetzner/v1/metadata/public-ipv4):3000 \\ +PORT=3000 \\ +nohup bun run apps/worker/src/server.ts > /var/log/paws-worker.log 2>&1 & `; } diff --git a/apps/control-plane/src/discovery/pangolin.test.ts b/apps/control-plane/src/discovery/pangolin.test.ts deleted file mode 100644 index ee44a58..0000000 --- a/apps/control-plane/src/discovery/pangolin.test.ts +++ /dev/null @@ -1,314 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { createServer, type Server } from 'node:http'; - -import { createPangolinDiscovery } from './pangolin.js'; - -// --- Fake Pangolin API server --- - -interface FakeSite { - siteId: string; - name: string; - online: boolean; - subnet: string; -} - -function makePangolinApi(opts: { sites: FakeSite[]; statusCode?: number; malformed?: boolean }) { - return createServer((req, res) => { - if (req.url?.startsWith('/api/v1/org/') && req.method === 'GET') { - res.writeHead(opts.statusCode ?? 200, { 'Content-Type': 'application/json' }); - if (opts.malformed) { - res.end('not json{{{'); - return; - } - res.end(JSON.stringify({ data: { sites: opts.sites } })); - } else { - res.writeHead(404); - res.end(); - } - }); -} - -// --- Fake Worker health server --- - -function makeWorkerServer(opts: { status?: string } = {}) { - return createServer((req, res) => { - if (req.url === '/health' && req.method === 'GET') { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - status: opts.status ?? 'healthy', - worker: 'test-worker', - uptime: 5000, - capacity: { maxConcurrent: 5, running: 1, queued: 0, available: 4 }, - }), - ); - } else { - res.writeHead(404); - res.end(); - } - }); -} - -function listen(server: Server): Promise<{ url: string; port: number }> { - return new Promise((resolve) => { - server.listen(0, '127.0.0.1', () => { - const addr = server.address(); - if (typeof addr === 'object' && addr) { - resolve({ url: `http://127.0.0.1:${addr.port}`, port: addr.port }); - } - }); - }); -} - -describe('createPangolinDiscovery', () => { - let stdoutLines: string[]; - // Suppress structured logger output in tests (writes to process.stdout) - beforeEach(() => { - stdoutLines = []; - vi.spyOn(process.stdout, 'write').mockImplementation((chunk: unknown) => { - if (typeof chunk === 'string') stdoutLines.push(chunk); - return true; - }); - vi.spyOn(console, 'log').mockImplementation(() => {}); - vi.spyOn(console, 'warn').mockImplementation(() => {}); - vi.spyOn(console, 'error').mockImplementation(() => {}); - }); - afterEach(() => { - vi.restoreAllMocks(); - }); - - test('discovers healthy workers from Pangolin API', async () => { - // Worker server on a known port — Pangolin reports this as the tunnel IP - const workerServer = makeWorkerServer(); - const { port: workerPort } = await listen(workerServer); - - const pangolinApi = makePangolinApi({ - sites: [{ siteId: 'site-1', name: 'worker-1', online: true, subnet: `127.0.0.1/32` }], - }); - const { url: apiUrl } = await listen(pangolinApi); - - const discovery = createPangolinDiscovery({ - apiUrl: `${apiUrl}/api/v1`, - apiKey: 'test-key', - orgId: 'test-org', - workerPort, - pollIntervalMs: 100_000, // don't auto-poll during test - }); - - // Wait for initial poll (CI can be slow) - let workers: Awaited> = []; - for (let i = 0; i < 20; i++) { - await new Promise((r) => setTimeout(r, 100)); - workers = await discovery.getWorkers(); - if (workers.length > 0) break; - } - expect(workers).toHaveLength(1); - expect(workers[0]!.name).toBe(`http://127.0.0.1:${workerPort}`); - expect(workers[0]!.status).toBe('healthy'); - expect(workers[0]!.capacity.available).toBe(4); - - workerServer.close(); - pangolinApi.close(); - }); - - test('returns empty array when no sites are online', async () => { - const pangolinApi = makePangolinApi({ - sites: [{ siteId: 'site-1', name: 'worker-1', online: false, subnet: '100.89.1.1/32' }], - }); - const { url: apiUrl } = await listen(pangolinApi); - - const discovery = createPangolinDiscovery({ - apiUrl: `${apiUrl}/api/v1`, - apiKey: 'test-key', - orgId: 'test-org', - pollIntervalMs: 100_000, - }); - - await new Promise((r) => setTimeout(r, 100)); - - const workers = await discovery.getWorkers(); - expect(workers).toEqual([]); - - pangolinApi.close(); - }); - - test('returns empty array when API returns empty sites', async () => { - const pangolinApi = makePangolinApi({ sites: [] }); - const { url: apiUrl } = await listen(pangolinApi); - - const discovery = createPangolinDiscovery({ - apiUrl: `${apiUrl}/api/v1`, - apiKey: 'test-key', - orgId: 'test-org', - pollIntervalMs: 100_000, - }); - - await new Promise((r) => setTimeout(r, 100)); - - const workers = await discovery.getWorkers(); - expect(workers).toEqual([]); - - pangolinApi.close(); - }); - - test('skips sites with missing subnet', async () => { - const pangolinApi = makePangolinApi({ - sites: [{ siteId: 'site-1', name: 'worker-1', online: true, subnet: '' }], - }); - const { url: apiUrl } = await listen(pangolinApi); - - const discovery = createPangolinDiscovery({ - apiUrl: `${apiUrl}/api/v1`, - apiKey: 'test-key', - orgId: 'test-org', - pollIntervalMs: 100_000, - }); - - await new Promise((r) => setTimeout(r, 100)); - - const workers = await discovery.getWorkers(); - expect(workers).toEqual([]); - - pangolinApi.close(); - }); - - test('keeps cached state on 401 and logs specific error', async () => { - const pangolinApi = makePangolinApi({ sites: [], statusCode: 401 }); - const { url: apiUrl } = await listen(pangolinApi); - - const discovery = createPangolinDiscovery({ - apiUrl: `${apiUrl}/api/v1`, - apiKey: 'bad-key', - orgId: 'test-org', - pollIntervalMs: 100_000, - }); - - await new Promise((r) => setTimeout(r, 100)); - - const workers = await discovery.getWorkers(); - expect(workers).toEqual([]); // empty cache initially - - expect(stdoutLines.some((l) => l.includes('PANGOLIN_API_KEY'))).toBe(true); - - pangolinApi.close(); - }); - - test('keeps cached state on 500', async () => { - const pangolinApi = makePangolinApi({ sites: [], statusCode: 500 }); - const { url: apiUrl } = await listen(pangolinApi); - - const discovery = createPangolinDiscovery({ - apiUrl: `${apiUrl}/api/v1`, - apiKey: 'test-key', - orgId: 'test-org', - pollIntervalMs: 100_000, - }); - - await new Promise((r) => setTimeout(r, 100)); - - const workers = await discovery.getWorkers(); - expect(workers).toEqual([]); - - expect(stdoutLines.some((l) => l.includes('500'))).toBe(true); - - pangolinApi.close(); - }); - - test('keeps cached state on malformed JSON', async () => { - const pangolinApi = makePangolinApi({ sites: [], malformed: true }); - const { url: apiUrl } = await listen(pangolinApi); - - const discovery = createPangolinDiscovery({ - apiUrl: `${apiUrl}/api/v1`, - apiKey: 'test-key', - orgId: 'test-org', - pollIntervalMs: 100_000, - }); - - await new Promise((r) => setTimeout(r, 100)); - - const workers = await discovery.getWorkers(); - expect(workers).toEqual([]); - - expect(stdoutLines.some((l) => l.includes('malformed JSON'))).toBe(true); - - pangolinApi.close(); - }); - - test('skips workers that fail health check', async () => { - // No worker server running → health check will fail - const pangolinApi = makePangolinApi({ - sites: [{ siteId: 'site-1', name: 'worker-1', online: true, subnet: '127.0.0.1/32' }], - }); - const { url: apiUrl } = await listen(pangolinApi); - - const discovery = createPangolinDiscovery({ - apiUrl: `${apiUrl}/api/v1`, - apiKey: 'test-key', - orgId: 'test-org', - workerPort: 1, // unreachable port - pollIntervalMs: 100_000, - }); - - await new Promise((r) => setTimeout(r, 200)); - - const workers = await discovery.getWorkers(); - expect(workers).toEqual([]); - - pangolinApi.close(); - }); - - test('worker.name is the full URL for dispatch compatibility', async () => { - const workerServer = makeWorkerServer(); - const { port: workerPort } = await listen(workerServer); - - const pangolinApi = makePangolinApi({ - sites: [{ siteId: 'site-1', name: 'my-worker', online: true, subnet: '127.0.0.1/32' }], - }); - const { url: apiUrl } = await listen(pangolinApi); - - const discovery = createPangolinDiscovery({ - apiUrl: `${apiUrl}/api/v1`, - apiKey: 'test-key', - orgId: 'test-org', - workerPort, - pollIntervalMs: 100_000, - }); - - await new Promise((r) => setTimeout(r, 100)); - - const workers = await discovery.getWorkers(); - expect(workers[0]!.name).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); - // name is NOT the human-readable "my-worker" label - expect(workers[0]!.name).not.toBe('my-worker'); - - workerServer.close(); - pangolinApi.close(); - }); - - test('filters out unhealthy workers from getWorkers()', async () => { - const workerServer = makeWorkerServer({ status: 'unhealthy' }); - const { port: workerPort } = await listen(workerServer); - - const pangolinApi = makePangolinApi({ - sites: [{ siteId: 'site-1', name: 'worker-1', online: true, subnet: '127.0.0.1/32' }], - }); - const { url: apiUrl } = await listen(pangolinApi); - - const discovery = createPangolinDiscovery({ - apiUrl: `${apiUrl}/api/v1`, - apiKey: 'test-key', - orgId: 'test-org', - workerPort, - pollIntervalMs: 100_000, - }); - - await new Promise((r) => setTimeout(r, 100)); - - const workers = await discovery.getWorkers(); - expect(workers).toEqual([]); - - workerServer.close(); - pangolinApi.close(); - }); -}); diff --git a/apps/control-plane/src/discovery/pangolin.ts b/apps/control-plane/src/discovery/pangolin.ts deleted file mode 100644 index d0a8267..0000000 --- a/apps/control-plane/src/discovery/pangolin.ts +++ /dev/null @@ -1,345 +0,0 @@ -import { createLogger } from '@paws/logger'; -import type { Worker } from '@paws/domain-fleet'; - -const log = createLogger('pangolin'); - -/** - * Pangolin API discovery — polls Pangolin's site list to find connected workers. - * - * Each worker runs Newt (Pangolin's tunnel agent) which establishes a WireGuard - * tunnel back to Gerbil on the control plane. Pangolin tracks connected sites - * and assigns each a tunnel IP from the CGNAT range. - * - * Discovery flow: - * Pangolin API (GET /api/v1/org/{orgId}/sites) - * → filter to online sites - * → extract tunnel IP from subnet field - * → health-check each worker at http://{tunnelIP}:{port}/health - * → return Worker[] matching WorkerDiscovery interface - */ - -export interface PangolinDiscoveryOptions { - /** Pangolin API URL (e.g., http://pangolin:3000/api/v1). */ - apiUrl: string; - /** Pangolin organization ID. */ - orgId: string; - /** - * Auth: either an API key (Bearer token) or session credentials (email+password). - * Session auth logs in on first request and refreshes on 401. - */ - apiKey?: string; - email?: string; - password?: string; - /** Worker port. Defaults to 3000. */ - workerPort?: number; - /** Poll interval in milliseconds. Defaults to 10_000 (10 seconds). */ - pollIntervalMs?: number; - /** Grace period before removing a disconnected worker. Defaults to 30_000 (30s). */ - disconnectGraceMs?: number; -} - -/** Shape of a site in Pangolin's API response. */ -interface PangolinSite { - siteId: string; - name: string; - online: boolean; - subnet: string; // CIDR like "100.89.137.5/32" - type: string; // "newt" | "local" | "wireguard" - endpoint: string | null; // "65.108.10.170:56974" (public IP:port of the Newt agent) -} - -interface PangolinSitesResponse { - data: { sites: PangolinSite[] }; -} - -export function createPangolinDiscovery(opts: PangolinDiscoveryOptions) { - const { - apiUrl, - apiKey, - email, - password, - orgId, - workerPort = 3000, - pollIntervalMs = 10_000, - disconnectGraceMs = 30_000, - } = opts; - - // Session cookie for login-based auth - let sessionCookie = ''; - - // Cache: last known workers (kept on API failure for graceful degradation) - let cachedWorkers: Worker[] = []; - - // Map worker name (URL) → Pangolin siteId for disconnect tracking - const workerToSiteId = new Map(); - - // Track when sites went offline for grace period - const disconnectTimestamps = new Map(); - - // Background poll state - let timer: ReturnType | null = null; - - let lastPollAt: string | null = null; - let apiReachable = false; - - async function pollSites(): Promise { - lastPollAt = new Date().toISOString(); - let sites: PangolinSite[]; - try { - sites = await fetchSites(); - apiReachable = true; - } catch { - apiReachable = false; - // Keep cached state on failure — don't remove workers on transient errors - return; - } - - const now = Date.now(); - const onlineSites = sites.filter((s) => s.online && s.type !== 'local'); - - // Track disconnections for grace period - const currentSiteIds = new Set(onlineSites.map((s) => s.siteId)); - const previousSiteIds = new Set( - cachedWorkers.map((w) => workerToSiteId.get(w.name)).filter(Boolean) as string[], - ); - - for (const siteId of previousSiteIds) { - if (!currentSiteIds.has(siteId) && !disconnectTimestamps.has(siteId)) { - disconnectTimestamps.set(siteId, now); - log.info('Worker disconnected, removing after grace period', { siteId }); - } - } - - // Clean up grace periods for sites that came back online - for (const siteId of currentSiteIds) { - disconnectTimestamps.delete(siteId); - } - - // Include workers still within grace period - const graceWorkers = cachedWorkers.filter((w) => { - const siteId = workerToSiteId.get(w.name); - if (!siteId) return false; - const disconnectTime = disconnectTimestamps.get(siteId); - if (disconnectTime === undefined) return false; - if (now - disconnectTime > disconnectGraceMs) { - disconnectTimestamps.delete(siteId); - workerToSiteId.delete(w.name); - log.info('Worker grace period expired, removed from fleet', { siteId }); - return false; - } - return true; - }); - - // Health-check online sites - const healthChecked = await Promise.allSettled( - onlineSites.map((site) => healthCheckSite(site)), - ); - - const freshWorkers: Worker[] = []; - for (const result of healthChecked) { - if (result.status === 'fulfilled' && result.value !== null) { - freshWorkers.push(result.value); - } - } - - // Log new discoveries - for (const w of freshWorkers) { - const existed = cachedWorkers.some((c) => c.name === w.name); - if (!existed) { - const siteId = workerToSiteId.get(w.name) ?? w.name; - log.info('Discovered worker', { siteId, url: w.name }); - } - } - - cachedWorkers = [...freshWorkers, ...graceWorkers]; - } - - async function login(): Promise { - if (!email || !password) return; - try { - const res = await fetch(`${apiUrl}/auth/login`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-csrf-token': 'x-csrf-protection', - }, - body: JSON.stringify({ email, password }), - signal: AbortSignal.timeout(5_000), - }); - const cookies = res.headers.getSetCookie?.() ?? []; - sessionCookie = cookies.map((c) => c.split(';')[0]).join('; '); - if (res.ok) { - log.info('Session login successful'); - } else { - log.error('Login failed', { status: res.status }); - } - } catch (err) { - log.error('Login request failed', { error: String(err) }); - } - } - - function buildHeaders(): Record { - const headers: Record = { 'x-csrf-token': 'x-csrf-protection' }; - if (apiKey) { - headers['Authorization'] = `Bearer ${apiKey}`; - } else if (sessionCookie) { - headers['Cookie'] = sessionCookie; - } - return headers; - } - - async function fetchSites(): Promise { - const url = `${apiUrl}/org/${orgId}/sites`; - let res = await fetch(url, { - headers: buildHeaders(), - signal: AbortSignal.timeout(5_000), - }); - - // On 401, try re-login (session expired) and retry once - if (res.status === 401 && email && password) { - log.info('Session expired, re-authenticating'); - await login(); - res = await fetch(url, { - headers: buildHeaders(), - signal: AbortSignal.timeout(5_000), - }); - } - - if (res.status === 401) { - log.error( - 'API returned 401 — check credentials (PANGOLIN_API_KEY or PANGOLIN_EMAIL/PASSWORD)', - ); - throw new Error('Pangolin API authentication failed'); - } - - if (!res.ok) { - log.warn('API error, keeping cached fleet state', { status: res.status }); - throw new Error(`Pangolin API error: ${res.status}`); - } - - let body: PangolinSitesResponse; - try { - body = (await res.json()) as PangolinSitesResponse; - } catch { - log.error('API returned malformed JSON, keeping cached fleet state'); - throw new Error('Pangolin API returned malformed JSON'); - } - - if (!Array.isArray(body.data?.sites)) { - log.error('API response missing data.sites array, keeping cached fleet state'); - throw new Error('Pangolin API response missing data.sites array'); - } - - return body.data.sites; - } - - /** Fetch a single site's full details (includes endpoint IP not in list response). */ - async function fetchSiteDetail(siteId: string): Promise<{ endpoint?: string } | null> { - try { - const res = await fetch(`${apiUrl}/site/${siteId}`, { - headers: buildHeaders(), - signal: AbortSignal.timeout(5_000), - }); - if (!res.ok) return null; - const body = (await res.json()) as { data?: { endpoint?: string } }; - return body.data ?? null; - } catch { - return null; - } - } - - async function healthCheckSite(site: PangolinSite): Promise { - // Skip local sites (they're not workers) - if (site.type === 'local') return null; - - // Fetch full site details to get the endpoint IP (not included in list response) - const detail = await fetchSiteDetail(site.siteId); - const endpoint = detail?.endpoint?.split(':')[0]; // "65.108.10.170:56974" → "65.108.10.170" - const tunnelIp = site.subnet?.split('/')[0]; - const baseUrl = endpoint - ? `http://${endpoint}:${workerPort}` - : tunnelIp - ? `http://${tunnelIp}:${workerPort}` - : null; - if (!baseUrl) return null; - try { - const res = await fetch(`${baseUrl}/health`, { signal: AbortSignal.timeout(5_000) }); - if (!res.ok) return null; - - const body = (await res.json()) as { - status: string; - worker: string; - uptime: number; - capacity: { - maxConcurrent: number; - running: number; - queued: number; - available: number; - }; - }; - - const status = normalizeStatus(body.status); - - // Track siteId → worker URL mapping for disconnect detection - workerToSiteId.set(baseUrl, site.siteId); - - return { - // name = URL so dispatchSession can use it as the worker HTTP base URL - name: baseUrl, - status, - capacity: { - maxConcurrent: body.capacity.maxConcurrent, - running: body.capacity.running, - queued: body.capacity.queued, - available: body.capacity.available, - }, - snapshot: { id: 'default', version: 1, ageMs: 0 }, - uptime: body.uptime, - }; - } catch { - return null; - } - } - - function normalizeStatus(raw: string): 'healthy' | 'degraded' | 'unhealthy' { - if (raw === 'healthy' || raw === 'degraded' || raw === 'unhealthy') return raw; - if (raw === 'ok') return 'healthy'; - return 'unhealthy'; - } - - // Start background polling - function startPolling(): void { - if (timer) return; - // Initial poll - pollSites(); - timer = setInterval(pollSites, pollIntervalMs); - } - - function stopPolling(): void { - if (timer) { - clearInterval(timer); - timer = null; - } - } - - // Login then start polling - if (email && password) { - login().then(() => startPolling()); - } else { - startPolling(); - } - - return { - async getWorkers(): Promise { - return cachedWorkers.filter((w) => w.status === 'healthy' || w.status === 'degraded'); - }, - stop: stopPolling, - status(): { connected: boolean; tunnelWorkers: number; lastPollAt: string | null } { - return { - connected: apiReachable, - tunnelWorkers: cachedWorkers.length, - lastPollAt, - }; - }, - }; -} diff --git a/apps/control-plane/src/ec2-sync.ts b/apps/control-plane/src/ec2-sync.ts index 82408a1..2c82ab4 100644 --- a/apps/control-plane/src/ec2-sync.ts +++ b/apps/control-plane/src/ec2-sync.ts @@ -190,7 +190,7 @@ export function createEc2Sync(deps: Ec2SyncDeps) { connectionStore.update(conn.id, { status: 'connected', - error: undefined, + error: null, lastSyncAt: new Date().toISOString(), }); } catch (err) { diff --git a/apps/control-plane/src/errors.ts b/apps/control-plane/src/errors.ts index 7ebb751..5d8b75b 100644 --- a/apps/control-plane/src/errors.ts +++ b/apps/control-plane/src/errors.ts @@ -20,6 +20,7 @@ const statusMap: Record = { UNAUTHORIZED: 401, FORBIDDEN: 403, NOT_FOUND: 404, + NOT_IMPLEMENTED: 501, CONFLICT: 409, SESSION_NOT_FOUND: 404, DAEMON_NOT_FOUND: 404, diff --git a/apps/control-plane/src/middleware/auth.ts b/apps/control-plane/src/middleware/auth.ts index a90b3d5..71e6094 100644 --- a/apps/control-plane/src/middleware/auth.ts +++ b/apps/control-plane/src/middleware/auth.ts @@ -33,8 +33,9 @@ export function authMiddleware(config: AuthConfig) { if (passwordAuth) { const cookies = c.req.header('cookie') ?? ''; const match = cookies.match(/paws_session=([^;]+)/); - if (match) { - const session = passwordAuth.validateSession(match[1]); + const sessionToken = match?.[1]; + if (sessionToken) { + const session = passwordAuth.validateSession(sessionToken); if (session) { return next(); } diff --git a/apps/control-plane/src/pangolin-admin.ts b/apps/control-plane/src/pangolin-admin.ts deleted file mode 100644 index c0f0a15..0000000 --- a/apps/control-plane/src/pangolin-admin.ts +++ /dev/null @@ -1,233 +0,0 @@ -/** - * Pangolin admin client — proxies dashboard operations through the paws control plane. - * - * Wraps the Pangolin REST API so the paws dashboard never talks to Pangolin directly. - * Covers: resources (tunnels), sites, domains, users, identity providers. - */ - -import { createLogger } from '@paws/logger'; - -const log = createLogger('pangolin-admin'); - -export interface PangolinAdminConfig { - apiUrl: string; - apiKey?: string | undefined; - email?: string | undefined; - password?: string | undefined; - orgId: string; -} - -export interface PangolinResource { - resourceId: string | number; - name: string; - subdomain?: string; - fullDomain?: string; - http: boolean; - protocol: string; -} - -export interface PangolinSite { - siteId: string | number; - name: string; - online: boolean; - subnet?: string; - type: string; -} - -export interface PangolinDomain { - domainId: string; - baseDomain: string; -} - -export interface PangolinUser { - userId: string; - email: string; - name?: string; - role?: string; -} - -export interface PangolinIdp { - idpId: number; - name: string; - type: string; -} - -export function createPangolinAdmin(config: PangolinAdminConfig) { - const { apiUrl, apiKey, email, password, orgId } = config; - let sessionCookie = ''; - - async function login(): Promise { - if (!email || !password) return; - const res = await fetch(`${apiUrl}/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', 'x-csrf-token': 'x-csrf-protection' }, - body: JSON.stringify({ email, password }), - signal: AbortSignal.timeout(10_000), - }); - if (!res.ok) throw new Error(`Pangolin login failed: ${res.status}`); - const cookies = res.headers.getSetCookie?.() ?? []; - sessionCookie = cookies.map((c: string) => c.split(';')[0]).join('; '); - } - - function headers(): Record { - const h: Record = { - 'Content-Type': 'application/json', - 'x-csrf-token': 'x-csrf-protection', - }; - if (apiKey) h['Authorization'] = `Bearer ${apiKey}`; - else if (sessionCookie) h['Cookie'] = sessionCookie; - return h; - } - - async function request(method: string, path: string, body?: unknown): Promise { - let res = await fetch(`${apiUrl}${path}`, { - method, - headers: headers(), - ...(body !== undefined ? { body: JSON.stringify(body) } : {}), - signal: AbortSignal.timeout(10_000), - }); - - if (res.status === 401 && email && password) { - await login(); - res = await fetch(`${apiUrl}${path}`, { - method, - headers: headers(), - ...(body !== undefined ? { body: JSON.stringify(body) } : {}), - signal: AbortSignal.timeout(10_000), - }); - } - - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`Pangolin API ${method} ${path}: ${res.status} ${text}`); - } - - const contentType = res.headers.get('content-type') ?? ''; - if (contentType.includes('application/json')) { - return res.json(); - } - return null; - } - - // Initialize session if using email/password auth - if (email && password) { - login().catch((err) => log.error('Initial login failed', { error: String(err) })); - } - - return { - // --- Resources (tunnels) --- - - async listResources(): Promise { - const data = (await request('GET', `/org/${orgId}/resources?limit=1000&offset=0`)) as { - data?: { resources?: PangolinResource[] }; - }; - return data?.data?.resources ?? []; - }, - - async deleteResource(resourceId: string | number): Promise { - await request('DELETE', `/resource/${resourceId}`); - }, - - // --- Sites (workers) --- - - async listSites(): Promise { - const data = (await request('GET', `/org/${orgId}/sites`)) as { - data?: { sites?: PangolinSite[] }; - }; - return data?.data?.sites ?? []; - }, - - async createSite(name: string): Promise<{ siteId: string; secret: string }> { - const data = (await request('PUT', `/org/${orgId}/site`, { - name, - type: 'newt', - })) as { data: { siteId: string; secret: string } }; - return data.data; - }, - - async deleteSite(siteId: string | number): Promise { - await request('DELETE', `/org/${orgId}/site/${siteId}`); - }, - - // --- Domains --- - - async listDomains(): Promise { - const data = (await request('GET', `/org/${orgId}/domains?limit=100&offset=0`)) as { - data?: { domains?: PangolinDomain[] }; - }; - return data?.data?.domains ?? []; - }, - - // --- Users --- - - async listUsers(): Promise { - const data = (await request('GET', `/org/${orgId}/users?limit=100&offset=0`)) as { - data?: { users?: PangolinUser[] }; - }; - return data?.data?.users ?? []; - }, - - async inviteUser(email: string, roleId?: string): Promise { - await request('POST', `/org/${orgId}/user/invite`, { - email, - ...(roleId ? { roleId } : {}), - }); - }, - - async removeUser(userId: string): Promise { - await request('DELETE', `/org/${orgId}/user/${userId}`); - }, - - // --- Identity Providers --- - - async listIdps(): Promise { - const data = (await request('GET', `/org/${orgId}/idp`)) as { - data?: { idps?: PangolinIdp[] }; - }; - return data?.data?.idps ?? []; - }, - - async createOidcIdp(config: { - name: string; - clientId: string; - clientSecret: string; - authUrl: string; - tokenUrl: string; - scopes?: string; - emailPath?: string; - namePath?: string; - identifierPath?: string; - }): Promise<{ idpId: number }> { - const data = (await request('PUT', `/org/${orgId}/idp`, { - name: config.name, - type: 'oidc', - clientId: config.clientId, - clientSecret: config.clientSecret, - authorizationUrl: config.authUrl, - tokenUrl: config.tokenUrl, - scopes: config.scopes ?? 'openid profile email', - emailPath: config.emailPath ?? 'email', - namePath: config.namePath ?? 'name', - identifierPath: config.identifierPath ?? 'sub', - })) as { data: { idpId: number } }; - return data.data; - }, - - async deleteIdp(idpId: number): Promise { - await request('DELETE', `/org/${orgId}/idp/${idpId}`); - }, - - // --- Status --- - - async status(): Promise<{ reachable: boolean; orgId: string }> { - try { - await request('GET', `/org/${orgId}`); - return { reachable: true, orgId }; - } catch { - return { reachable: false, orgId }; - } - }, - }; -} - -export type PangolinAdmin = ReturnType; diff --git a/apps/control-plane/src/pangolin-client.test.ts b/apps/control-plane/src/pangolin-client.test.ts deleted file mode 100644 index 4912984..0000000 --- a/apps/control-plane/src/pangolin-client.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import { createServer, type Server } from 'node:http'; - -import { createPangolinClient } from './pangolin-client.js'; - -function listen(server: Server): Promise { - return new Promise((resolve) => { - server.listen(0, '127.0.0.1', () => { - const addr = server.address(); - if (typeof addr === 'object' && addr) { - resolve(`http://127.0.0.1:${addr.port}`); - } - }); - }); -} - -describe('createPangolinClient', () => { - beforeEach(() => { - vi.spyOn(console, 'log').mockImplementation(() => {}); - }); - afterEach(() => { - vi.restoreAllMocks(); - }); - - test('createSite returns site ID and secret', async () => { - const server = createServer((req, res) => { - if (req.method === 'POST' && req.url?.includes('/sites')) { - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end( - JSON.stringify({ - data: { siteId: 'site-123', secret: 'sec-abc', name: 'my-worker' }, - }), - ); - } else { - res.writeHead(404); - res.end(); - } - }); - const url = await listen(server); - - const client = createPangolinClient({ - apiUrl: `${url}/api/v1`, - apiKey: 'test-key', - orgId: 'test-org', - }); - - const result = await client.createSite('my-worker'); - expect(result.siteId).toBe('site-123'); - expect(result.secret).toBe('sec-abc'); - expect(result.name).toBe('my-worker'); - - server.close(); - }); - - test('createSite throws on API error', async () => { - const server = createServer((_req, res) => { - res.writeHead(500); - res.end('Internal Server Error'); - }); - const url = await listen(server); - - const client = createPangolinClient({ - apiUrl: `${url}/api/v1`, - apiKey: 'test-key', - orgId: 'test-org', - }); - - await expect(client.createSite('worker')).rejects.toThrow('Pangolin API error 500'); - - server.close(); - }); - - test('deleteSite succeeds on 200', async () => { - const server = createServer((_req, res) => { - res.writeHead(200); - res.end(); - }); - const url = await listen(server); - - const client = createPangolinClient({ - apiUrl: `${url}/api/v1`, - apiKey: 'test-key', - orgId: 'test-org', - }); - - await expect(client.deleteSite('site-123')).resolves.toBeUndefined(); - - server.close(); - }); -}); diff --git a/apps/control-plane/src/pangolin-client.ts b/apps/control-plane/src/pangolin-client.ts deleted file mode 100644 index e5e317e..0000000 --- a/apps/control-plane/src/pangolin-client.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Thin client for Pangolin's site management API. - * - * Used by the autoscaler to auto-create Pangolin sites when provisioning - * new workers, so Newt can connect immediately without manual dashboard setup. - */ - -export interface PangolinClientConfig { - /** Pangolin internal API URL (e.g., http://pangolin:3001/api/v1). */ - apiUrl: string; - /** Pangolin API key for authentication. */ - apiKey: string; - /** Pangolin organization ID. */ - orgId: string; -} - -export interface PangolinSiteCreateResult { - siteId: string; - secret: string; - name: string; -} - -export interface PangolinClient { - createSite(name: string): Promise; - deleteSite(siteId: string): Promise; -} - -export function createPangolinClient(config: PangolinClientConfig): PangolinClient { - const { apiUrl, apiKey, orgId } = config; - - async function createSite(name: string): Promise { - const res = await fetch(`${apiUrl}/org/${orgId}/sites`, { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ name, type: 'newt' }), - signal: AbortSignal.timeout(10_000), - }); - - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`Pangolin API error ${res.status}: ${text}`); - } - - const body = (await res.json()) as { - data: { siteId: string; secret: string; name: string }; - }; - - return body.data; - } - - async function deleteSite(siteId: string): Promise { - const res = await fetch(`${apiUrl}/org/${orgId}/sites/${siteId}`, { - method: 'DELETE', - headers: { Authorization: `Bearer ${apiKey}` }, - signal: AbortSignal.timeout(10_000), - }); - - if (!res.ok && res.status !== 404) { - const text = await res.text().catch(() => ''); - throw new Error(`Pangolin API error ${res.status}: ${text}`); - } - } - - return { createSite, deleteSite }; -} diff --git a/apps/control-plane/src/routes/cloud-connections.ts b/apps/control-plane/src/routes/cloud-connections.ts index 3114f15..9ffc308 100644 --- a/apps/control-plane/src/routes/cloud-connections.ts +++ b/apps/control-plane/src/routes/cloud-connections.ts @@ -164,7 +164,7 @@ export function createCloudConnectionRoutes(deps: CloudConnectionDeps) { connectionStore.update(conn.id, { status: 'connected', - error: undefined, + error: null, lastSyncAt: new Date().toISOString(), }); diff --git a/apps/control-plane/src/routes/pangolin.ts b/apps/control-plane/src/routes/pangolin.ts deleted file mode 100644 index fe06752..0000000 --- a/apps/control-plane/src/routes/pangolin.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { Hono } from 'hono'; - -import type { PangolinAdmin } from '../pangolin-admin.js'; - -/** - * Proxy routes for Pangolin admin operations. - * All routes require auth (applied via middleware in app.ts). - */ -export function createPangolinRoutes(admin: PangolinAdmin) { - const app = new Hono(); - - // --- Status --- - app.get('/status', async (c) => { - const status = await admin.status(); - return c.json(status); - }); - - // --- Resources (tunnels) --- - app.get('/resources', async (c) => { - try { - const resources = await admin.listResources(); - return c.json({ resources }); - } catch (err) { - return c.json({ error: { code: 'PANGOLIN_ERROR', message: String(err) } }, 502); - } - }); - - app.delete('/resources/:id', async (c) => { - try { - await admin.deleteResource(c.req.param('id')); - return c.body(null, 204); - } catch (err) { - return c.json({ error: { code: 'PANGOLIN_ERROR', message: String(err) } }, 502); - } - }); - - // --- Sites (workers) --- - app.get('/sites', async (c) => { - try { - const sites = await admin.listSites(); - return c.json({ sites }); - } catch (err) { - return c.json({ error: { code: 'PANGOLIN_ERROR', message: String(err) } }, 502); - } - }); - - app.post('/sites', async (c) => { - try { - const { name } = await c.req.json<{ name: string }>(); - const result = await admin.createSite(name); - return c.json(result, 201); - } catch (err) { - return c.json({ error: { code: 'PANGOLIN_ERROR', message: String(err) } }, 502); - } - }); - - app.delete('/sites/:id', async (c) => { - try { - await admin.deleteSite(c.req.param('id')); - return c.body(null, 204); - } catch (err) { - return c.json({ error: { code: 'PANGOLIN_ERROR', message: String(err) } }, 502); - } - }); - - // --- Domains --- - app.get('/domains', async (c) => { - try { - const domains = await admin.listDomains(); - return c.json({ domains }); - } catch (err) { - return c.json({ error: { code: 'PANGOLIN_ERROR', message: String(err) } }, 502); - } - }); - - // --- Users --- - app.get('/users', async (c) => { - try { - const users = await admin.listUsers(); - return c.json({ users }); - } catch (err) { - return c.json({ error: { code: 'PANGOLIN_ERROR', message: String(err) } }, 502); - } - }); - - app.post('/users/invite', async (c) => { - try { - const { email, roleId } = await c.req.json<{ email: string; roleId?: string }>(); - await admin.inviteUser(email, roleId); - return c.json({ invited: true, email }, 201); - } catch (err) { - return c.json({ error: { code: 'PANGOLIN_ERROR', message: String(err) } }, 502); - } - }); - - app.delete('/users/:id', async (c) => { - try { - await admin.removeUser(c.req.param('id')); - return c.body(null, 204); - } catch (err) { - return c.json({ error: { code: 'PANGOLIN_ERROR', message: String(err) } }, 502); - } - }); - - // --- Identity Providers --- - app.get('/idps', async (c) => { - try { - const idps = await admin.listIdps(); - return c.json({ idps }); - } catch (err) { - return c.json({ error: { code: 'PANGOLIN_ERROR', message: String(err) } }, 502); - } - }); - - app.post('/idps/oidc', async (c) => { - try { - const body = await c.req.json<{ - name: string; - clientId: string; - clientSecret: string; - authUrl: string; - tokenUrl: string; - }>(); - const result = await admin.createOidcIdp(body); - return c.json(result, 201); - } catch (err) { - return c.json({ error: { code: 'PANGOLIN_ERROR', message: String(err) } }, 502); - } - }); - - app.delete('/idps/:id', async (c) => { - try { - await admin.deleteIdp(parseInt(c.req.param('id'), 10)); - return c.body(null, 204); - } catch (err) { - return c.json({ error: { code: 'PANGOLIN_ERROR', message: String(err) } }, 502); - } - }); - - return app; -} diff --git a/apps/control-plane/src/routes/setup.ts b/apps/control-plane/src/routes/setup.ts index a0c0959..3f50a4c 100644 --- a/apps/control-plane/src/routes/setup.ts +++ b/apps/control-plane/src/routes/setup.ts @@ -358,9 +358,9 @@ export function createSetupRoutes(deps: SetupDeps) { slog.info('Starting SSH bootstrap', { ip: server.ip }); await provisioner.start({ server, - password: sshPassword, - privateKey: sshPrivateKey, - passphrase: sshPassphrase, + ...(sshPassword ? { password: sshPassword } : {}), + ...(sshPrivateKey ? { privateKey: sshPrivateKey } : {}), + ...(sshPassphrase ? { passphrase: sshPassphrase } : {}), gatewayUrl: process.env['GATEWAY_URL'] ?? 'http://localhost:4000', apiKey: process.env['API_KEY'] ?? '', }); diff --git a/apps/control-plane/src/server.ts b/apps/control-plane/src/server.ts index d8f4dfc..e6aa3c6 100644 --- a/apps/control-plane/src/server.ts +++ b/apps/control-plane/src/server.ts @@ -2,7 +2,6 @@ import { createBunWebSocket } from 'hono/bun'; import { createControlPlaneApp } from './app.js'; import { createK8sDiscovery } from './discovery/k8s.js'; -import { createPangolinDiscovery } from './discovery/pangolin.js'; import { createWorkerRegistry } from './discovery/registry.js'; import { createStaticDiscovery } from './discovery/static.js'; import { createDatabase } from './db/index.js'; @@ -11,13 +10,6 @@ const PORT = parseInt(process.env['PORT'] ?? '4000', 10); const API_KEY = process.env['API_KEY'] ?? 'paws-dev-key'; const WORKER_URL = process.env['WORKER_URL'] ?? ''; -// Pangolin discovery config (optional — set URL + orgId + either apiKey or email/password) -const PANGOLIN_API_URL = process.env['PANGOLIN_API_URL'] ?? ''; -const PANGOLIN_API_KEY = process.env['PANGOLIN_API_KEY'] ?? ''; -const PANGOLIN_ORG_ID = process.env['PANGOLIN_ORG_ID'] ?? ''; -const PANGOLIN_EMAIL = process.env['PANGOLIN_EMAIL'] ?? ''; -const PANGOLIN_PASSWORD = process.env['PANGOLIN_PASSWORD'] ?? ''; - // OIDC config (optional — set all 4 to enable) const OIDC_ISSUER = process.env['OIDC_ISSUER'] ?? ''; const OIDC_CLIENT_ID = process.env['OIDC_CLIENT_ID'] ?? ''; @@ -39,22 +31,10 @@ const oidc = } : undefined; -// Worker discovery — four layers, first match wins: -// 1. Pangolin tunnel discovery (workers connect via Newt/WireGuard) -// 2. Call-home registry (workers connect via WebSocket — legacy) -// 3. K8s pod-watching (in-cluster) -// 4. Static URLs (manual WORKER_URL) -const hasPangolinAuth = PANGOLIN_API_KEY || (PANGOLIN_EMAIL && PANGOLIN_PASSWORD); -const pangolinDiscovery = - PANGOLIN_API_URL && PANGOLIN_ORG_ID && hasPangolinAuth - ? createPangolinDiscovery({ - apiUrl: PANGOLIN_API_URL, - orgId: PANGOLIN_ORG_ID, - ...(PANGOLIN_API_KEY && { apiKey: PANGOLIN_API_KEY }), - ...(PANGOLIN_EMAIL && { email: PANGOLIN_EMAIL }), - ...(PANGOLIN_PASSWORD && { password: PANGOLIN_PASSWORD }), - }) - : null; +// Worker discovery — three layers, first match wins: +// 1. Call-home registry (workers connect via WebSocket) +// 2. K8s pod-watching (in-cluster) +// 3. Static URLs (manual WORKER_URL) const workerRegistry = createWorkerRegistry(); const k8sDiscovery = createK8sDiscovery(); const staticUrls = WORKER_URL ? [WORKER_URL] : []; @@ -62,13 +42,7 @@ const staticDiscovery = createStaticDiscovery(staticUrls); const discovery = { async getWorkers() { - // Pangolin tunnel discovery (primary when configured) - if (pangolinDiscovery) { - const pangolinWorkers = await pangolinDiscovery.getWorkers(); - if (pangolinWorkers.length > 0) return pangolinWorkers; - } - - // Call-home registry (legacy WebSocket connection) + // Call-home registry (WebSocket connection) const registryWorkers = await workerRegistry.getWorkers(); if (registryWorkers.length > 0) return registryWorkers; @@ -95,59 +69,8 @@ const app = await createControlPlaneApp({ upgradeWebSocket, ...(DASHBOARD_DIR && { dashboardDir: DASHBOARD_DIR }), ...(oidc && { oidc }), - ...(pangolinDiscovery && { pangolinStatus: () => pangolinDiscovery.status() }), }); -// --- Auto-register Dex as Pangolin OIDC provider (if both configured) --- -const PANGOLIN_OIDC_SECRET = process.env['PANGOLIN_OIDC_SECRET'] ?? ''; - -if ( - pangolinDiscovery && - PANGOLIN_API_URL && - PANGOLIN_ORG_ID && - OIDC_ISSUER && - PANGOLIN_OIDC_SECRET -) { - void (async () => { - try { - const { createPangolinAdmin } = await import('./pangolin-admin.js'); - const admin = createPangolinAdmin({ - apiUrl: PANGOLIN_API_URL, - apiKey: PANGOLIN_API_KEY || undefined, - email: PANGOLIN_EMAIL || undefined, - password: PANGOLIN_PASSWORD || undefined, - orgId: PANGOLIN_ORG_ID, - }); - - // Check if Dex IdP already exists - const existing = await admin.listIdps(); - const hasDex = existing.some( - (idp) => idp.name === 'paws (Dex)' || idp.name.toLowerCase().includes('dex'), - ); - - if (!hasDex) { - // Derive Dex URLs from the OIDC issuer (e.g., https://fleet.tpops.dev/dex) - const issuerBase = OIDC_ISSUER.replace(/\/$/, ''); - await admin.createOidcIdp({ - name: 'paws (Dex)', - clientId: 'pangolin', - clientSecret: PANGOLIN_OIDC_SECRET, - authUrl: `${issuerBase}/auth`, - tokenUrl: `${issuerBase}/token`, - scopes: 'openid profile email', - emailPath: 'email', - namePath: 'name', - identifierPath: 'sub', - }); - console.log('pangolin: auto-registered Dex as OIDC identity provider'); - } - } catch (err) { - // Non-fatal — Pangolin might not be ready yet on first boot - console.warn('pangolin: failed to auto-register Dex IdP (will retry on next restart):', err); - } - })(); -} - // --- Autoscaler --- const AUTOSCALE_ENABLED = process.env['AUTOSCALE_ENABLED'] === 'true'; const AUTOSCALE_PROVIDER = process.env['AUTOSCALE_PROVIDER'] ?? 'hetzner-cloud'; @@ -218,7 +141,6 @@ if (AUTOSCALE_ENABLED) { } const discoveryMode = []; -if (pangolinDiscovery) discoveryMode.push('pangolin'); discoveryMode.push('call-home'); if (WORKER_URL) discoveryMode.push(`static (${WORKER_URL})`); discoveryMode.push('k8s'); diff --git a/apps/control-plane/src/store/cloud-connections.ts b/apps/control-plane/src/store/cloud-connections.ts index 8c89adf..911408e 100644 --- a/apps/control-plane/src/store/cloud-connections.ts +++ b/apps/control-plane/src/store/cloud-connections.ts @@ -15,10 +15,15 @@ export interface CloudConnection { createdAt: string; } +/** Patch type for update — allows `null` to clear optional fields */ +export type CloudConnectionPatch = Partial> & { + error?: string | null; +}; + export interface CloudConnectionStore { create(conn: CloudConnection): void; get(id: string): CloudConnection | undefined; - update(id: string, patch: Partial): CloudConnection | undefined; + update(id: string, patch: CloudConnectionPatch): CloudConnection | undefined; delete(id: string): boolean; list(): CloudConnection[]; /** List connections for a specific provider */ @@ -35,8 +40,8 @@ function rowToConnection(row: Row): CloudConnection { region: row.region, credentialsEncrypted: row.credentialsEncrypted, status: row.status as CloudConnection['status'], - error: row.error ?? undefined, - lastSyncAt: row.lastSyncAt ?? undefined, + ...(row.error != null ? { error: row.error } : {}), + ...(row.lastSyncAt != null ? { lastSyncAt: row.lastSyncAt } : {}), createdAt: row.createdAt, }; } @@ -55,7 +60,13 @@ export function createCloudConnectionStore(): CloudConnectionStore { update(id, patch) { const existing = connections.get(id); if (!existing) return undefined; - const updated = { ...existing, ...patch }; + const { error: errorPatch, ...rest } = patch; + const updated = { ...existing, ...rest }; + if (errorPatch === null) { + delete updated.error; + } else if (errorPatch !== undefined) { + updated.error = errorPatch; + } connections.set(id, updated); return updated; }, @@ -105,7 +116,7 @@ export function createSqliteCloudConnectionStore(db: PawsDatabase): CloudConnect if (patch.credentialsEncrypted !== undefined) values['credentialsEncrypted'] = patch.credentialsEncrypted; if (patch.status !== undefined) values['status'] = patch.status; - if (patch.error !== undefined) values['error'] = patch.error; + if (patch.error !== undefined) values['error'] = patch.error ?? null; if (patch.lastSyncAt !== undefined) values['lastSyncAt'] = patch.lastSyncAt; if (Object.keys(values).length > 0) { diff --git a/apps/dashboard/src/api/client.ts b/apps/dashboard/src/api/client.ts index 3678ff7..4f1ebf1 100644 --- a/apps/dashboard/src/api/client.ts +++ b/apps/dashboard/src/api/client.ts @@ -39,7 +39,7 @@ function getClient(): PawsClient { _client = createClient({ baseUrl: '', apiKey, - fetch: _useSession ? fetchWithCredentials : undefined, + ...(_useSession ? { fetch: fetchWithCredentials } : {}), }); } return _client; @@ -122,120 +122,6 @@ export async function buildSnapshot(id: string): Promise { if (!res.ok) throw new Error(`Failed to trigger snapshot build: ${res.status}`); } -// --- Pangolin Admin --- - -export interface PangolinResource { - resourceId: string | number; - name: string; - subdomain?: string; - fullDomain?: string; - http: boolean; - protocol: string; -} - -export interface PangolinSite { - siteId: string | number; - name: string; - online: boolean; - type: string; -} - -export interface PangolinUser { - userId: string; - email: string; - name?: string; - role?: string; -} - -export interface PangolinIdp { - idpId: number; - name: string; - type: string; -} - -export async function getPangolinStatus(): Promise<{ reachable: boolean; orgId: string }> { - const res = await fetch('/v1/pangolin/status', { headers: apiKeyHeaders() }); - if (!res.ok) return { reachable: false, orgId: '' }; - return res.json(); -} - -export async function getPangolinResources(): Promise { - const res = await fetch('/v1/pangolin/resources', { headers: apiKeyHeaders() }); - if (!res.ok) throw new Error(`Failed to fetch tunnels: ${res.status}`); - const body = await res.json(); - return body.resources ?? []; -} - -export async function deletePangolinResource(id: string | number): Promise { - const res = await fetch(`/v1/pangolin/resources/${id}`, { - method: 'DELETE', - headers: apiKeyHeaders(), - }); - if (!res.ok) throw new Error(`Failed to delete tunnel: ${res.status}`); -} - -export async function getPangolinSites(): Promise { - const res = await fetch('/v1/pangolin/sites', { headers: apiKeyHeaders() }); - if (!res.ok) throw new Error(`Failed to fetch sites: ${res.status}`); - const body = await res.json(); - return body.sites ?? []; -} - -export async function getPangolinUsers(): Promise { - const res = await fetch('/v1/pangolin/users', { headers: apiKeyHeaders() }); - if (!res.ok) throw new Error(`Failed to fetch users: ${res.status}`); - const body = await res.json(); - return body.users ?? []; -} - -export async function invitePangolinUser(email: string): Promise { - const res = await fetch('/v1/pangolin/users/invite', { - method: 'POST', - headers: apiKeyHeaders(), - body: JSON.stringify({ email }), - }); - if (!res.ok) throw new Error(`Failed to invite user: ${res.status}`); -} - -export async function removePangolinUser(userId: string): Promise { - const res = await fetch(`/v1/pangolin/users/${userId}`, { - method: 'DELETE', - headers: apiKeyHeaders(), - }); - if (!res.ok) throw new Error(`Failed to remove user: ${res.status}`); -} - -export async function getPangolinIdps(): Promise { - const res = await fetch('/v1/pangolin/idps', { headers: apiKeyHeaders() }); - if (!res.ok) throw new Error(`Failed to fetch IdPs: ${res.status}`); - const body = await res.json(); - return body.idps ?? []; -} - -export async function createPangolinOidcIdp(config: { - name: string; - clientId: string; - clientSecret: string; - authUrl: string; - tokenUrl: string; -}): Promise<{ idpId: number }> { - const res = await fetch('/v1/pangolin/idps/oidc', { - method: 'POST', - headers: apiKeyHeaders(), - body: JSON.stringify(config), - }); - if (!res.ok) throw new Error(`Failed to create IdP: ${res.status}`); - return res.json(); -} - -export async function deletePangolinIdp(idpId: number): Promise { - const res = await fetch(`/v1/pangolin/idps/${idpId}`, { - method: 'DELETE', - headers: apiKeyHeaders(), - }); - if (!res.ok) throw new Error(`Failed to delete IdP: ${res.status}`); -} - // --- Servers --- export interface ServerInfo { diff --git a/apps/dashboard/src/components/CommandPalette.tsx b/apps/dashboard/src/components/CommandPalette.tsx index 016b727..ffcb0b5 100644 --- a/apps/dashboard/src/components/CommandPalette.tsx +++ b/apps/dashboard/src/components/CommandPalette.tsx @@ -15,7 +15,6 @@ const PAGES = [ { name: 'Fleet', path: '/fleet', group: 'Infrastructure' }, { name: 'Servers', path: '/servers', group: 'Infrastructure' }, { name: 'Snapshots', path: '/snapshots', group: 'Infrastructure' }, - { name: 'Tunnels', path: '/tunnels', group: 'Infrastructure' }, { name: 'Daemons', path: '/daemons', group: 'Agents' }, { name: 'Templates', path: '/templates', group: 'Agents' }, { name: 'Sessions', path: '/sessions', group: 'Agents' }, diff --git a/apps/dashboard/src/components/Layout.tsx b/apps/dashboard/src/components/Layout.tsx index d0a255f..1d9efb9 100644 --- a/apps/dashboard/src/components/Layout.tsx +++ b/apps/dashboard/src/components/Layout.tsx @@ -93,7 +93,6 @@ function SidebarNav({ - Agents diff --git a/apps/dashboard/src/pages/AuditLog.tsx b/apps/dashboard/src/pages/AuditLog.tsx index 6be038a..c331f0c 100644 --- a/apps/dashboard/src/pages/AuditLog.tsx +++ b/apps/dashboard/src/pages/AuditLog.tsx @@ -61,7 +61,8 @@ function EventRow({ {event.resourceType && event.resourceId ? ( event.resourceType === 'session' ? ( e.stopPropagation()} > diff --git a/apps/dashboard/src/pages/Fleet.tsx b/apps/dashboard/src/pages/Fleet.tsx index 1e11774..d359916 100644 --- a/apps/dashboard/src/pages/Fleet.tsx +++ b/apps/dashboard/src/pages/Fleet.tsx @@ -4,38 +4,11 @@ import { MiniChart } from '../components/MiniChart.js'; import { StatCard } from '../components/StatCard.js'; import { WorkerCard } from '../components/WorkerCard.js'; import { Alert, AlertDescription } from '../components/ui/alert.js'; -import { Badge } from '../components/ui/badge.js'; import { Card, CardContent } from '../components/ui/card.js'; import { Skeleton } from '../components/ui/skeleton.js'; import { useMetrics } from '../hooks/useMetrics.js'; import { usePolling } from '../hooks/usePolling.js'; -function TunnelStatus({ - pangolin, -}: { - pangolin?: { connected: boolean; tunnelWorkers: number; lastPollAt: string | null }; -}) { - if (!pangolin) return null; - - return ( - - - {pangolin.connected - ? `Tunnel active \u00b7 ${pangolin.tunnelWorkers} worker${pangolin.tunnelWorkers !== 1 ? 's' : ''} connected` - : 'Tunnel disconnected'} - - ); -} - export function Fleet() { const fleet = usePolling(getFleet, 5000); const workers = usePolling(getWorkers, 5000); @@ -46,18 +19,11 @@ export function Fleet() { const workersChart = useMetrics('paws_workers_healthy', 60, 30); const requestsChart = useMetrics('sum(rate(paws_http_requests_total[1m]))', 60, 30); - const fleetData = fleet.data as - | (typeof fleet.data & { - pangolin?: { connected: boolean; tunnelWorkers: number; lastPollAt: string | null }; - }) - | undefined; - return (

Fleet Overview

-
{fleet.loading ? ( diff --git a/apps/dashboard/src/pages/McpServers.tsx b/apps/dashboard/src/pages/McpServers.tsx index 5720b80..4ecb4f5 100644 --- a/apps/dashboard/src/pages/McpServers.tsx +++ b/apps/dashboard/src/pages/McpServers.tsx @@ -141,7 +141,7 @@ function AddMcpServerForm({ onAdded }: { onAdded: () => void }) { name, transport, ...(transport === 'stdio' - ? { command, args: args ? args.split(/\s+/) : undefined } + ? { command, ...(args ? { args: args.split(/\s+/) } : {}) } : { url }), }); toast.success('MCP server added'); diff --git a/apps/dashboard/src/pages/Snapshots.tsx b/apps/dashboard/src/pages/Snapshots.tsx index 525f218..26978be 100644 --- a/apps/dashboard/src/pages/Snapshots.tsx +++ b/apps/dashboard/src/pages/Snapshots.tsx @@ -46,7 +46,7 @@ export function Snapshots() { const requiredDomains = template ? getTemplate(template).requiredDomains : []; await createSnapshotConfig({ id: newId.trim(), - template, + ...(template ? { template } : {}), setup: setupScript, requiredDomains, }); diff --git a/apps/dashboard/src/pages/Tunnels.tsx b/apps/dashboard/src/pages/Tunnels.tsx deleted file mode 100644 index 918b0e7..0000000 --- a/apps/dashboard/src/pages/Tunnels.tsx +++ /dev/null @@ -1,367 +0,0 @@ -import { useState } from 'react'; - -import { - getPangolinResources, - getPangolinSites, - getPangolinUsers, - getPangolinIdps, - getPangolinStatus, - deletePangolinResource, - invitePangolinUser, - removePangolinUser, - createPangolinOidcIdp, - deletePangolinIdp, - type PangolinResource, - type PangolinSite, - type PangolinUser, - type PangolinIdp, -} from '../api/client.js'; -import { Alert, AlertDescription } from '../components/ui/alert.js'; -import { Badge } from '../components/ui/badge.js'; -import { Button } from '../components/ui/button.js'; -import { Card, CardContent } from '../components/ui/card.js'; -import { Input } from '../components/ui/input.js'; -import { Skeleton } from '../components/ui/skeleton.js'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '../components/ui/tabs.js'; -import { usePolling } from '../hooks/usePolling.js'; - -export function Tunnels() { - const status = usePolling(getPangolinStatus, 10000); - - return ( -
-
-
-

Tunnels

- {status.data && ( - - - {status.data.reachable ? 'Pangolin connected' : 'Pangolin unreachable'} - - )} -
-
- - - - {(['tunnels', 'sites', 'users', 'sso'] as const).map((t) => ( - - {t === 'sso' ? 'SSO' : t} - - ))} - - - - - - - - - - - - - - - -
- ); -} - -function TunnelsTab() { - const resources = usePolling(getPangolinResources, 5000); - - async function handleDelete(id: string | number) { - await deletePangolinResource(id); - } - - if (resources.loading && !resources.data) { - return ; - } - if (resources.error) { - return ( - - - {resources.error.message} - - - ); - } - - const items = resources.data ?? []; - if (items.length === 0) { - return ( - - -

- No active tunnels. Exposed ports appear here when sessions with{' '} - network.expose are running. -

-
-
- ); - } - - return ( -
- {items.map((r: PangolinResource) => ( - - -
- - {r.fullDomain ?? r.subdomain ?? r.name} - - {r.protocol} - {r.http && ( - - open - - )} -
- -
-
- ))} -
- ); -} - -function SitesTab() { - const sites = usePolling(getPangolinSites, 5000); - - if (sites.loading && !sites.data) { - return ; - } - - const items = sites.data ?? []; - return ( -
- {items.length === 0 ? ( - - -

No sites registered. Workers connect via Newt.

-
-
- ) : ( - items.map((s: PangolinSite) => ( - - -
- {s.name} - - {s.online ? 'online' : 'offline'} - - {s.type} -
- {s.siteId} -
-
- )) - )} -
- ); -} - -function UsersTab() { - const users = usePolling(getPangolinUsers, 10000); - const [inviteEmail, setInviteEmail] = useState(''); - const [inviting, setInviting] = useState(false); - - async function handleInvite() { - if (!inviteEmail.trim()) return; - setInviting(true); - try { - await invitePangolinUser(inviteEmail.trim()); - setInviteEmail(''); - } catch (err) { - console.error('Failed to invite user:', err); - } finally { - setInviting(false); - } - } - - async function handleRemove(userId: string) { - await removePangolinUser(userId); - } - - return ( -
-
- setInviteEmail(e.target.value)} - placeholder="email@example.com" - className="flex-1 bg-zinc-800 border-zinc-700 text-zinc-100 placeholder-zinc-600 focus-visible:border-emerald-500 focus-visible:ring-emerald-500/20" - onKeyDown={(e) => e.key === 'Enter' && handleInvite()} - /> - -
- -
- {(users.data ?? []).map((u: PangolinUser) => ( - - -
- {u.email} - {u.name && {u.name}} - {u.role && ( - - {u.role} - - )} -
- -
-
- ))} -
-
- ); -} - -function SsoTab() { - const idps = usePolling(getPangolinIdps, 10000); - const [showSetup, setShowSetup] = useState(false); - const [setting, setSetting] = useState(false); - - async function handleAutoSetup() { - setSetting(true); - try { - // Auto-configure Dex as the OIDC provider - const domain = window.location.hostname.replace(/^fleet\./, ''); - await createPangolinOidcIdp({ - name: 'paws (Dex)', - clientId: 'pangolin', - clientSecret: '', // User must provide this - authUrl: `https://fleet.${domain}/dex/auth`, - tokenUrl: `https://fleet.${domain}/dex/token`, - }); - setShowSetup(false); - } catch (err) { - console.error('Failed to create IdP:', err); - } finally { - setSetting(false); - } - } - - return ( -
-
-

- Identity providers for exposed port authentication. Users log in once to access all tunnel - URLs. -

- -
- - {showSetup && ( - - -

- This will register Dex (your existing OIDC provider) as a Pangolin identity provider. - Users who can log into the paws dashboard will also be able to access exposed tunnel - URLs. -

-

- Make sure the PANGOLIN_OIDC_SECRET env var is - set and matches the Dex client secret. The setup script generates this automatically. -

- -
-
- )} - -
- {(idps.data ?? []).length === 0 ? ( - - -

- No identity providers configured. Add Dex SSO so tunnel URLs use the same login as - the dashboard. -

-
-
- ) : ( - (idps.data ?? []).map((idp: PangolinIdp) => ( - - -
- {idp.name} - - {idp.type} - -
- -
-
- )) - )} -
-
- ); -} diff --git a/apps/dashboard/src/router.tsx b/apps/dashboard/src/router.tsx index 6989abf..0634b31 100644 --- a/apps/dashboard/src/router.tsx +++ b/apps/dashboard/src/router.tsx @@ -125,8 +125,6 @@ const Snapshots = lazy(() => const Templates = lazy(() => import('./pages/Templates.js').then((m) => ({ default: m.Templates })), ); -const Tunnels = lazy(() => import('./pages/Tunnels.js').then((m) => ({ default: m.Tunnels }))); - // Root route wraps everything in AuthGate const rootRoute = createRootRoute({ component: () => ( @@ -201,12 +199,6 @@ const snapshotsRoute = createRoute({ component: lazyPage(Snapshots, ), }); -const tunnelsRoute = createRoute({ - getParentRoute: () => layoutRoute, - path: '/tunnels', - component: lazyPage(Tunnels, ), -}); - const serversRoute = createRoute({ getParentRoute: () => layoutRoute, path: '/servers', @@ -252,7 +244,6 @@ const routeTree = rootRoute.addChildren([ daemonsRoute, templatesRoute, snapshotsRoute, - tunnelsRoute, serversRoute, sessionsRoute, sessionDetailRoute, diff --git a/apps/site/public/install.sh b/apps/site/public/install.sh index 6542b6a..ec7a417 100755 --- a/apps/site/public/install.sh +++ b/apps/site/public/install.sh @@ -96,41 +96,12 @@ API_KEY=$(openssl rand -hex 24) cat > .env << EOF # Generated by paws installer API_KEY=${API_KEY} -PANGOLIN_API_URL=http://pangolin:3001/api/v1 -PANGOLIN_API_KEY= -PANGOLIN_ORG_ID= -PANGOLIN_SECRET=$(openssl rand -hex 32) GRAFANA_ADMIN_PASSWORD=$(openssl rand -hex 16) LLM_GATEWAY= LLM_GATEWAY_URL= LLM_GATEWAY_KEY= EOF -# ── Generate Pangolin config (minimal, for worker tunnels) ──────────────── -mkdir -p config/pangolin -cat > config/pangolin/config.yml << EOF -gerbil: - start_port: 51820 - base_endpoint: "${SERVER_IP}" - -app: - dashboard_url: "http://${SERVER_IP}:3000" - log_level: "info" - -domains: - default: - base_domain: "${SERVER_IP}.nip.io" - -server: - secret: "$(openssl rand -hex 32)" - -flags: - require_email_verification: false - disable_signup_without_invite: false - disable_user_create_org: false - allow_raw_resources: true -EOF - # ── Generate Dex config (minimal, OIDC enabled later with domain) ───────── mkdir -p config/dex cat > config/dex/config.yaml << EOF @@ -147,25 +118,6 @@ web: enablePasswordDB: true EOF -# ── Generate Traefik config (for tunnel subdomains) ─────────────────────── -mkdir -p config/traefik -cat > config/traefik/traefik_config.yml << EOF -api: - insecure: true - -log: - level: INFO - -entryPoints: - web: - address: ":80" - -providers: - http: - endpoint: "http://pangolin:3001/api/v1/traefik-config" - pollInterval: "5s" -EOF - # ── Start ───────────────────────────────────────────────────────────────── info "Starting paws..." docker compose build gateway 2>&1 | tail -3 @@ -183,27 +135,6 @@ for i in $(seq 1 30); do sleep 2 done -# Auto-bootstrap Pangolin (create admin + org so Gerbil can start) -info "Configuring tunnels..." -PANGOLIN_ADMIN_PASS=$(openssl rand -hex 16) - -for i in $(seq 1 30); do - if curl -sf "http://localhost:3001/api/v1/" >/dev/null 2>&1; then - break - fi - sleep 2 -done - -# Create initial admin via Pangolin's setup endpoint -curl -sf -X POST "http://localhost:3001/api/v1/auth/initial-setup" \ - -H "Content-Type: application/json" \ - -H "x-csrf-token: x-csrf-protection" \ - -d "{\"email\":\"admin@paws.local\",\"password\":\"${PANGOLIN_ADMIN_PASS}\"}" >/dev/null 2>&1 || true - -# Wait for Gerbil to stabilize after Pangolin has an org -sleep 10 -docker compose up -d 2>/dev/null - # ── Done ────────────────────────────────────────────────────────────────── echo "" print_cat "paws is running!" diff --git a/apps/site/src/content/docs/concepts/architecture.md b/apps/site/src/content/docs/concepts/architecture.md index 3fa416e..22f0cd7 100644 --- a/apps/site/src/content/docs/concepts/architecture.md +++ b/apps/site/src/content/docs/concepts/architecture.md @@ -11,17 +11,17 @@ paws has two core services: **Control plane** (`apps/control-plane/`) -- the brain. Receives API requests, stores daemon configs, tracks sessions, holds all credentials, and routes work to workers. Runs on any VPS. -**Worker** (`apps/worker/`) -- the muscle. Manages Firecracker VMs on bare metal. Each worker runs on a Linux host with `/dev/kvm`. Workers connect to the control plane via Pangolin WireGuard tunnels. +**Worker** (`apps/worker/`) -- the muscle. Manages Firecracker VMs on bare metal. Each worker runs on a Linux host with `/dev/kvm`. Workers connect to the control plane via K8s Services (in-cluster) or WebSocket call-home (remote). ``` -Control Plane (VPS) Worker (bare metal) +Control Plane (K8s Deployment) Worker (bare metal, DaemonSet) ├── API + dashboard ├── Worker process ├── Daemon registry ├── Firecracker VMs ├── Session tracker │ ├── VM 1 + Proxy 1 ├── Scheduler │ ├── VM 2 + Proxy 2 -├── Pangolin (tunnels) │ └── VM N + Proxy N -└── Dex (OIDC SSO) └── Newt (tunnel agent) - ↕ WireGuard tunnel ↕ +└── Dex (OIDC SSO) │ └── VM N + Proxy N + └── WebSocket call-home (remote) + Connected via: K8s Service / WebSocket ``` ## Two execution models @@ -75,12 +75,11 @@ Boot time: under 1 second (28ms with userfaultfd lazy loading). ## Worker discovery -The control plane discovers workers through four layers (first match wins): +The control plane discovers workers through three layers (first match wins): -1. **Pangolin** -- tunnel-connected workers via WireGuard (primary) -2. **Call-home** -- workers register via WebSocket -3. **K8s** -- in-cluster pod discovery -4. **Static URL** -- manual `WORKER_URL` env var for development +1. **K8s pod watcher** -- in-cluster pod discovery (primary) +2. **WebSocket call-home** -- remote workers register via WebSocket +3. **Static URL** -- manual `WORKER_URL` env var for development ## Scheduling diff --git a/apps/site/src/content/docs/concepts/port-exposure.md b/apps/site/src/content/docs/concepts/port-exposure.md index f0084c8..92ecb15 100644 --- a/apps/site/src/content/docs/concepts/port-exposure.md +++ b/apps/site/src/content/docs/concepts/port-exposure.md @@ -1,19 +1,17 @@ --- title: Port Exposure -description: Expose ports from VMs to the internet via Pangolin tunnels with per-port access control. +description: Expose ports from VMs to authorized users via the control plane reverse proxy with per-port access control. --- -Agents often run web servers -- dev servers, preview apps, dashboards. paws can expose ports from inside the VM to the internet through Pangolin tunnels, with access control on each port. +Agents often run web servers -- dev servers, preview apps, dashboards. paws can expose ports from inside the VM to authorized users through the control plane reverse proxy, with access control on each port. ## How it works 1. Your daemon config declares which ports to expose -2. When the session starts, the worker creates iptables DNAT rules to forward traffic from the host to the VM -3. The worker registers each port as a Pangolin resource with a unique subdomain -4. Pangolin routes external traffic through its WireGuard tunnel to the worker, which forwards it to the VM -5. When the session ends, the resources are cleaned up - -The result: each exposed port gets a public URL like `https://session-abc-3000.fleet.example.com`. +2. When the session starts, the worker sets up routing from the host to the VM via the TAP device +3. The control plane acts as a reverse proxy, authenticating requests and forwarding them to the correct worker and VM +4. Each exposed port gets a session-scoped URL like `https://s-abc123.fleet.example.com` +5. When the session ends, the URLs stop working and resources are cleaned up ## Daemon config with port exposure @@ -58,7 +56,7 @@ Each exposed port has an access control mode that determines who can reach it. | Mode | How it works | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `sso` (default) | Requires Pangolin login. Uses the OIDC provider configured on your control plane (Dex). | +| `sso` (default) | Requires OIDC login via the provider configured on your control plane (Dex). | | `pin` | Auto-generates a numeric PIN. Anyone with the PIN can access the port. The PIN is returned in the session's `exposedPorts` response. | | `email` | Restricts access to specific email addresses or domains. Supports wildcards like `*@company.com`. | @@ -75,7 +73,7 @@ Each exposed port has an access control mode that determines who can reach it. ## Reading exposed port URLs -After a session starts, poll the session endpoint. The `exposedPorts` field contains the public URLs: +After a session starts, poll the session endpoint. The `exposedPorts` field contains the URLs: ```bash curl -s "$PAWS_URL/v1/sessions/$SESSION_ID" \ @@ -86,43 +84,59 @@ curl -s "$PAWS_URL/v1/sessions/$SESSION_ID" \ [ { "port": 3000, - "url": "https://sess-abc-3000.fleet.example.com", + "url": "https://s-abc123.fleet.example.com", "label": "Next.js dev server", - "access": "sso", - "shareLink": "https://fleet.example.com/share/abc123" + "access": "sso" }, { "port": 5432, - "url": "https://sess-abc-5432.fleet.example.com", + "url": "https://s-abc123-5432.fleet.example.com", "label": "pgAdmin", "access": "pin", - "pin": "847291", - "shareLink": "https://fleet.example.com/share/def456" + "pin": "847291" } ] ``` ## Shareable links -Every exposed port gets a time-limited shareable link. You can send this to anyone -- they don't need a Pangolin account. The link respects the port's access control mode (SSO, PIN, or email). +Every exposed port gets a time-limited shareable link. The link respects the port's access control mode (SSO, PIN, or email). -## Worker configuration +## Architecture -Port exposure requires Pangolin configuration on the worker. Set these environment variables: +Port exposure uses the control plane as a reverse proxy: -```bash -PANGOLIN_API_URL=https://pangolin.example.com -PANGOLIN_ORG_ID=your-org-id -PANGOLIN_SITE_ID=your-site-id -PANGOLIN_BASE_DOMAIN=fleet.example.com -PANGOLIN_API_KEY=your-api-key ``` +User browser + | + | https://s-abc123.fleet.example.com/ + | + v +Control Plane + 1. Extract session ID from subdomain + 2. Authenticate (OIDC / PIN) + 3. Check port is in daemon's expose list + 4. Reverse-proxy to worker + | + v +Worker + 5. Route to session's VM via TAP device + 6. Forward to VM guest IP (172.16.x.2:port) + | + v +MicroVM (172.16.x.2) + Dev server on :3000 +``` + +The inbound preview path is separate from the outbound MITM proxy. Inbound traffic is a simple TCP forward from the control plane through the worker to the VM. The MITM proxy only handles outbound traffic (agent API calls with credential injection). + +## WebSocket support -Without these, the `expose` field in daemon configs is silently ignored and ports are not exposed. +Dev servers use WebSockets for HMR (hot module replacement). The reverse proxy chain supports WebSocket upgrade through the control plane and worker to the VM. ## Forwarded headers -Pangolin forwards standard headers to the VM's web server: +The control plane forwards standard headers to the VM's web server: - `X-Forwarded-For` -- client's real IP - `X-Forwarded-Proto` -- original protocol (https) diff --git a/apps/site/src/content/docs/getting-started/install.md b/apps/site/src/content/docs/getting-started/install.md index 8f8fedc..ab34e37 100644 --- a/apps/site/src/content/docs/getting-started/install.md +++ b/apps/site/src/content/docs/getting-started/install.md @@ -15,7 +15,7 @@ This installs Docker (if missing), clones the repo, generates all secrets, and s - Linux server (VPS or bare metal) - Root access -- Ports 80, 443, 51820/udp open +- Ports 80, 443 open No Cloudflare or DNS provider account needed. Domain is optional. @@ -29,15 +29,12 @@ curl -fsSL https://getpaws.dev/install.sh | bash -s -- \ ## What it sets up -| Service | Purpose | -| ------------------- | ------------------------------------ | -| **Pangolin** | Tunnel management + reverse proxy | -| **Gerbil** | WireGuard tunnel server | -| **Traefik** | TLS termination (auto Let's Encrypt) | -| **Gateway** | paws control plane API + dashboard | -| **Dex** | OIDC identity provider (SSO) | -| **VictoriaMetrics** | Metrics storage | -| **Grafana** | Dashboards | +| Service | Purpose | +| ------------------- | ---------------------------------- | +| **Gateway** | paws control plane API + dashboard | +| **Dex** | OIDC identity provider (SSO) | +| **VictoriaMetrics** | Metrics storage | +| **Grafana** | Dashboards | ## Adding a domain later @@ -65,4 +62,4 @@ curl -fsSL https://getpaws.dev/install.sh | bash # install Docker ./scripts/setup-worker.sh # connects to control plane ``` -The worker connects via Pangolin WireGuard tunnel and appears in the dashboard automatically. +The worker connects via WebSocket call-home and appears in the dashboard automatically. diff --git a/apps/site/src/content/docs/getting-started/introduction.mdx b/apps/site/src/content/docs/getting-started/introduction.mdx index 1c7c220..e444505 100644 --- a/apps/site/src/content/docs/getting-started/introduction.mdx +++ b/apps/site/src/content/docs/getting-started/introduction.mdx @@ -29,7 +29,7 @@ Your agent should have **nothing worth stealing**. When an AI agent runs inside microVM. - Agents run fullstack apps, users access them via Pangolin tunnel URLs with per-port access + Agents run fullstack apps, users access them via port exposure with per-port access control: SSO, PIN, or email whitelist. diff --git a/apps/site/src/content/docs/reference/api.md b/apps/site/src/content/docs/reference/api.md index 55ee47d..d3b903b 100644 --- a/apps/site/src/content/docs/reference/api.md +++ b/apps/site/src/content/docs/reference/api.md @@ -50,7 +50,7 @@ Submit a workload for execution in an isolated VM. | `timeoutMs` | number | No | 600000 | Max execution time in ms | | `network.allowOut` | string[] | No | [] | Allowed outbound domains (supports `*.example.com`) | | `network.credentials` | object | No | {} | Per-domain credential headers | -| `network.expose` | array | No | [] | Ports to expose via Pangolin | +| `network.expose` | array | No | [] | Ports to expose via port exposure | | `callbackUrl` | string | No | -- | URL to POST result on completion | | `metadata` | object | No | -- | Opaque metadata, returned in result | diff --git a/apps/site/src/content/docs/reference/config.md b/apps/site/src/content/docs/reference/config.md index 1816712..20cf90f 100644 --- a/apps/site/src/content/docs/reference/config.md +++ b/apps/site/src/content/docs/reference/config.md @@ -19,20 +19,6 @@ The control plane runs on any VPS. It serves the API, dashboard, and coordinates | `DASHBOARD_DIR` | -- | Path to built dashboard assets (enables web UI) | | `DATA_DIR` | `/var/lib/paws/data` | Persistent data directory (daemon store) | -### Pangolin (worker discovery) - -Set these to discover workers via Pangolin WireGuard tunnels: - -| Variable | Default | Description | -| ------------------- | ------- | ------------------------------------------------ | -| `PANGOLIN_API_URL` | -- | Pangolin API base URL | -| `PANGOLIN_ORG_ID` | -- | Pangolin organization ID | -| `PANGOLIN_API_KEY` | -- | Pangolin API key (alternative to email/password) | -| `PANGOLIN_EMAIL` | -- | Pangolin admin email (alternative to API key) | -| `PANGOLIN_PASSWORD` | -- | Pangolin admin password | - -You need either `PANGOLIN_API_KEY` or both `PANGOLIN_EMAIL` + `PANGOLIN_PASSWORD`. - ### OIDC authentication Set all four to enable SSO login on the dashboard and API: @@ -46,14 +32,6 @@ Set all four to enable SSO login on the dashboard and API: | `OIDC_REDIRECT_URI` | `http://localhost:{PORT}/auth/callback` | OAuth callback URL | | `OIDC_AUTH_EXTERNAL_URL` | -- | External-facing URL for auth redirects (if behind a proxy) | -### Pangolin OIDC bridge - -Auto-registers Dex as an identity provider in Pangolin (for port exposure SSO): - -| Variable | Default | Description | -| ---------------------- | ------- | ------------------------------------------------------ | -| `PANGOLIN_OIDC_SECRET` | -- | Client secret for Pangolin's OIDC integration with Dex | - ### Autoscaler | Variable | Default | Description | @@ -97,20 +75,6 @@ The worker runs on bare metal with `/dev/kvm`. It manages Firecracker VMs. | `API_KEY` | -- | API key to authenticate with control plane | | `WORKER_URL` | `http://localhost:{PORT}` | URL the control plane should use to reach this worker | -### Port exposure (Pangolin) - -| Variable | Default | Description | -| ---------------------- | ------- | ------------------------------------------------------------- | -| `PANGOLIN_API_URL` | -- | Pangolin API base URL | -| `PANGOLIN_ORG_ID` | -- | Pangolin organization ID | -| `PANGOLIN_SITE_ID` | -- | Pangolin site ID for this worker | -| `PANGOLIN_DOMAIN_ID` | -- | Pangolin domain ID for subdomain routing | -| `PANGOLIN_BASE_DOMAIN` | -- | Base domain for exposed ports (e.g., `fleet.example.com`) | -| `PANGOLIN_API_KEY` | -- | Pangolin API key | -| `PANGOLIN_EMAIL` | -- | Pangolin admin email (alternative to API key) | -| `PANGOLIN_PASSWORD` | -- | Pangolin admin password | -| `WORKER_EXTERNAL_URL` | -- | Direct worker URL for port forwarding (non-Pangolin fallback) | - ### Snapshot sync (R2) | Variable | Default | Description | @@ -133,11 +97,6 @@ API_KEY=paws-your-secret-key DASHBOARD_DIR=/opt/paws/dashboard/dist DATA_DIR=/var/lib/paws/data -# Worker discovery via Pangolin -PANGOLIN_API_URL=https://pangolin.example.com -PANGOLIN_ORG_ID=org-123 -PANGOLIN_API_KEY=pk-your-key - # OIDC (Dex) OIDC_ISSUER=https://fleet.example.com/dex OIDC_CLIENT_ID=paws diff --git a/apps/site/src/pages/index.astro b/apps/site/src/pages/index.astro index 223135f..bc0d570 100644 --- a/apps/site/src/pages/index.astro +++ b/apps/site/src/pages/index.astro @@ -223,8 +223,8 @@ const description = 'Self-hosted or managed. Zero-trust by default. Your secrets
-

Port exposure via tunnels

-

Agents run fullstack apps inside the VM. Users access them via Pangolin tunnel URLs with per-port access control: SSO, PIN, or email allowlist.

+

Port exposure

+

Agents run fullstack apps inside the VM. Users access them via port exposure with per-port access control: SSO, PIN, or email allowlist.

diff --git a/apps/worker/src/routes.ts b/apps/worker/src/routes.ts index 126e082..57bcf61 100644 --- a/apps/worker/src/routes.ts +++ b/apps/worker/src/routes.ts @@ -156,13 +156,9 @@ export function createSessionApp(deps: AppDeps) { status: 'running', startedAt: active.startedAt.toISOString(), worker: workerName, - exposedPorts: active.exposedTunnels?.map((t) => ({ - port: t.port, - url: t.publicUrl, - label: t.label, - access: t.access, - pin: t.pin, - shareLink: t.shareLink, + exposedPorts: active.inboundPorts?.map((p) => ({ + port: p.guestPort, + hostPort: p.hostPort, })), }); } diff --git a/apps/worker/src/server.ts b/apps/worker/src/server.ts index 00c10e8..d9f668b 100644 --- a/apps/worker/src/server.ts +++ b/apps/worker/src/server.ts @@ -7,8 +7,6 @@ import { createExecutor } from './session/executor.js'; import { createSemaphore } from './semaphore.js'; import { createSyncLoop } from './sync/sync-loop.js'; import type { SyncLoop } from './sync/sync-loop.js'; -import { createPangolinResourceManager } from './tunnel/pangolin-resources.js'; -import type { PangolinResourceManager } from './tunnel/pangolin-resources.js'; // Read version from env (Docker) or VERSION file (bare metal) const PAWS_VERSION = @@ -41,32 +39,11 @@ const semaphore = createSemaphore(MAX_CONCURRENT, MAX_QUEUED); const SNAPSHOT_BASE_DIR = process.env['SNAPSHOT_BASE_DIR'] ?? '/var/lib/paws/snapshots'; // Port exposure configuration (optional) -const PANGOLIN_API_URL_WORKER = process.env['PANGOLIN_API_URL'] ?? ''; -const PANGOLIN_API_KEY_WORKER = process.env['PANGOLIN_API_KEY'] ?? ''; -const PANGOLIN_EMAIL_WORKER = process.env['PANGOLIN_EMAIL'] ?? ''; -const PANGOLIN_PASSWORD_WORKER = process.env['PANGOLIN_PASSWORD'] ?? ''; -const PANGOLIN_ORG_ID_WORKER = process.env['PANGOLIN_ORG_ID'] ?? ''; -const PANGOLIN_SITE_ID = process.env['PANGOLIN_SITE_ID'] ?? ''; -const PANGOLIN_DOMAIN_ID = process.env['PANGOLIN_DOMAIN_ID'] ?? ''; -const PANGOLIN_BASE_DOMAIN = process.env['PANGOLIN_BASE_DOMAIN'] ?? ''; const WORKER_EXTERNAL_URL = process.env['WORKER_EXTERNAL_URL'] ?? ''; -let pangolinResources: PangolinResourceManager | undefined; +// Port exposure provider is injected by the runtime adapter (see PortExposureProvider interface) const portPool = createPortPool(); -if (PANGOLIN_API_URL_WORKER && PANGOLIN_ORG_ID_WORKER && PANGOLIN_SITE_ID && PANGOLIN_BASE_DOMAIN) { - pangolinResources = createPangolinResourceManager({ - apiUrl: PANGOLIN_API_URL_WORKER, - apiKey: PANGOLIN_API_KEY_WORKER || undefined, - email: PANGOLIN_EMAIL_WORKER || undefined, - password: PANGOLIN_PASSWORD_WORKER || undefined, - orgId: PANGOLIN_ORG_ID_WORKER, - siteId: PANGOLIN_SITE_ID, - domainId: PANGOLIN_DOMAIN_ID, - baseDomain: PANGOLIN_BASE_DOMAIN, - }); -} - // LLM gateway plugin (optional — routes LLM API calls through an external proxy) const LLM_GATEWAY_NAME = process.env['LLM_GATEWAY'] ?? ''; const LLM_GATEWAY_URL = process.env['LLM_GATEWAY_URL'] ?? ''; @@ -108,7 +85,6 @@ const executor = createExecutor({ semaphore, workerName: WORKER_NAME, portPool, - pangolinResources, workerExternalUrl: WORKER_EXTERNAL_URL || undefined, llmGateway, }); @@ -181,11 +157,7 @@ if (GATEWAY_URL && API_KEY) { callHome.start(); } -const portExposureStatus = pangolinResources - ? `Pangolin (${PANGOLIN_BASE_DOMAIN})` - : WORKER_EXTERNAL_URL - ? `direct (${WORKER_EXTERNAL_URL})` - : 'disabled'; +const portExposureStatus = WORKER_EXTERNAL_URL ? `direct (${WORKER_EXTERNAL_URL})` : 'disabled'; console.log(` /\\_/\\ diff --git a/apps/worker/src/session/executor.ts b/apps/worker/src/session/executor.ts index 5586836..330926b 100644 --- a/apps/worker/src/session/executor.ts +++ b/apps/worker/src/session/executor.ts @@ -18,7 +18,6 @@ import type { ProxyInstance, SessionCa } from '@paws/proxy'; import { WorkerError, WorkerErrorCode } from '../errors.js'; import { sshExec, sshReadFile, sshWriteFile, waitForSsh } from '../ssh/client.js'; import type { Semaphore } from '../semaphore.js'; -import type { ExposedTunnel, PangolinResourceManager } from '../tunnel/pangolin-resources.js'; /** Configuration for the session executor */ export interface ExecutorConfig { @@ -40,9 +39,7 @@ export interface ExecutorConfig { workerName: string; /** Port pool for inbound port exposure (optional — needed for port exposure) */ portPool?: PortPool | undefined; - /** Pangolin resource manager for tunnel URLs (optional — needed for port exposure) */ - pangolinResources?: PangolinResourceManager | undefined; - /** Fallback worker URL when Pangolin is not configured (e.g., "http://65.108.10.170") */ + /** Worker external URL for port exposure (e.g., "http://65.108.10.170") */ workerExternalUrl?: string | undefined; /** LLM gateway — routes provider API calls through an external proxy (LiteLLM, OpenRouter, etc.) */ llmGateway?: LlmGateway | undefined; @@ -88,8 +85,6 @@ export interface ActiveSession { vmHandle?: VmHandle; proxyHandle?: ProxyInstance; ca?: SessionCa; - /** Pangolin tunnels for exposed ports */ - exposedTunnels?: ExposedTunnel[] | undefined; /** Allocated host ports for inbound DNAT */ inboundPorts?: Array<{ hostPort: number; guestPort: number }> | undefined; } @@ -273,24 +268,8 @@ export function createExecutor(config: ExecutorConfig) { } } - // Create Pangolin tunnels (if configured) or use direct URLs - if (config.pangolinResources) { - const tunnels = await config.pangolinResources.expose( - sessionId, - exposePorts, - hostPorts, - ); - session.exposedTunnels = tunnels; - exposedPortUrls = tunnels.map((t) => ({ - port: t.port, - url: t.publicUrl, - label: t.label, - access: t.access, - pin: t.pin, - shareLink: t.shareLink, - })); - } else if (config.workerExternalUrl) { - // Fallback: direct host port URLs + // Create direct host port URLs (port exposure provider can be injected at runtime) + if (config.workerExternalUrl) { exposedPortUrls = exposePorts.map((ep, i) => ({ port: ep.port, url: `${config.workerExternalUrl}:${hostPorts[i]}`, @@ -365,11 +344,6 @@ export function createExecutor(config: ExecutorConfig) { // Cleanup — always runs session.status = 'stopping'; - // Clean up Pangolin resources - if (session.exposedTunnels?.length && config.pangolinResources) { - await config.pangolinResources.cleanup(session.exposedTunnels); - } - // Clean up inbound iptables rules and release host ports if (session.inboundPorts?.length && allocation) { for (const mapping of session.inboundPorts) { diff --git a/apps/worker/src/tunnel/pangolin-resources.test.ts b/apps/worker/src/tunnel/pangolin-resources.test.ts deleted file mode 100644 index 82ee836..0000000 --- a/apps/worker/src/tunnel/pangolin-resources.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { createPangolinResourceManager } from './pangolin-resources.js'; - -const BASE_CONFIG = { - apiUrl: 'http://pangolin:3001/api/v1', - apiKey: 'test-api-key', - orgId: 'org-123', - siteId: '456', - domainId: 'dom-789', - baseDomain: 'fleet.tpops.dev', -}; - -/** Mock a successful share link response */ -function mockShareLink(token = 'share-tok-1') { - return new Response(JSON.stringify({ data: { token } }), { status: 200 }); -} - -describe('createPangolinResourceManager', () => { - const fetchSpy = vi.fn(); - - beforeEach(() => { - fetchSpy.mockReset(); - globalThis.fetch = fetchSpy as unknown as typeof fetch; - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('expose', () => { - it('creates resources with resource + target + share link', async () => { - // Port 3000: create resource → target → share link - fetchSpy.mockResolvedValueOnce( - new Response(JSON.stringify({ data: { resourceId: 'res-1' } }), { status: 200 }), - ); - fetchSpy.mockResolvedValueOnce( - new Response(JSON.stringify({ data: { targetId: 'tgt-1' } }), { status: 200 }), - ); - fetchSpy.mockResolvedValueOnce(mockShareLink('tok-1')); - // Port 5432: create resource → target → share link - fetchSpy.mockResolvedValueOnce( - new Response(JSON.stringify({ data: { resourceId: 'res-2' } }), { status: 200 }), - ); - fetchSpy.mockResolvedValueOnce( - new Response(JSON.stringify({ data: { targetId: 'tgt-2' } }), { status: 200 }), - ); - fetchSpy.mockResolvedValueOnce(mockShareLink('tok-2')); - - const manager = createPangolinResourceManager(BASE_CONFIG); - const tunnels = await manager.expose( - 'abcdef12-3456-7890-abcd-ef1234567890', - [ - { port: 3000, protocol: 'http', label: 'Web' }, - { port: 5432, protocol: 'http' }, - ], - [10001, 10002], - ); - - expect(tunnels).toHaveLength(2); - expect(tunnels[0]?.port).toBe(3000); - expect(tunnels[0]?.resourceId).toBe('res-1'); - expect(tunnels[0]?.publicUrl).toBe('https://s-abcdef123456-web.fleet.tpops.dev'); - expect(tunnels[0]?.access).toBe('sso'); - expect(tunnels[0]?.shareLink).toContain('tok-1'); - - expect(tunnels[1]?.port).toBe(5432); - expect(tunnels[1]?.resourceId).toBe('res-2'); - - // 6 API calls: (create + target + share) x 2 ports - expect(fetchSpy).toHaveBeenCalledTimes(6); - - // Verify create resource call - const [url1, opts1] = fetchSpy.mock.calls[0]!; - expect(url1).toBe('http://pangolin:3001/api/v1/org/org-123/resource'); - const body1 = JSON.parse((opts1 as RequestInit).body as string); - expect(body1.subdomain).toBe('s-abcdef123456-web'); - expect(body1.domainId).toBe('dom-789'); - - // Verify target call - const [url2, opts2] = fetchSpy.mock.calls[1]!; - expect(url2).toBe('http://pangolin:3001/api/v1/resource/res-1/target'); - const body2 = JSON.parse((opts2 as RequestInit).body as string); - expect(body2.siteId).toBe(456); - expect(body2.port).toBe(10001); - }); - - it('configures PIN auth when access is pin', async () => { - fetchSpy.mockResolvedValueOnce( - new Response(JSON.stringify({ data: { resourceId: 'res-pin' } }), { status: 200 }), - ); - fetchSpy.mockResolvedValueOnce( - new Response(JSON.stringify({ data: { targetId: 'tgt-1' } }), { status: 200 }), - ); - // Auth config call (PIN) - fetchSpy.mockResolvedValueOnce(new Response(null, { status: 200 })); - // Share link - fetchSpy.mockResolvedValueOnce(mockShareLink('tok-pin')); - - const manager = createPangolinResourceManager(BASE_CONFIG); - const tunnels = await manager.expose( - 'abcdef12-0000-0000-0000-000000000000', - [{ port: 3000, protocol: 'http', access: 'pin' }], - [10001], - ); - - expect(tunnels[0]?.access).toBe('pin'); - expect(tunnels[0]?.pin).toMatch(/^\d{6}$/); // 6-digit PIN - - // Auth config call should set pincodeEnabled - const [authUrl, authOpts] = fetchSpy.mock.calls[2]!; - expect(authUrl).toBe('http://pangolin:3001/api/v1/resource/res-pin/auth'); - const authBody = JSON.parse((authOpts as RequestInit).body as string); - expect(authBody.pincodeEnabled).toBe(true); - expect(authBody.sso).toBe(false); - }); - - it('configures email whitelist when access is email', async () => { - fetchSpy.mockResolvedValueOnce( - new Response(JSON.stringify({ data: { resourceId: 'res-email' } }), { status: 200 }), - ); - fetchSpy.mockResolvedValueOnce( - new Response(JSON.stringify({ data: { targetId: 'tgt-1' } }), { status: 200 }), - ); - // Auth config call (email) - fetchSpy.mockResolvedValueOnce(new Response(null, { status: 200 })); - // Share link - fetchSpy.mockResolvedValueOnce(mockShareLink('tok-email')); - - const manager = createPangolinResourceManager(BASE_CONFIG); - const tunnels = await manager.expose( - 'abcdef12-0000-0000-0000-000000000000', - [{ port: 8080, protocol: 'http', access: 'email', allowedEmails: ['*@acme.com'] }], - [10002], - ); - - expect(tunnels[0]?.access).toBe('email'); - - const [authUrl, authOpts] = fetchSpy.mock.calls[2]!; - expect(authUrl).toBe('http://pangolin:3001/api/v1/resource/res-email/auth'); - const authBody = JSON.parse((authOpts as RequestInit).body as string); - expect(authBody.emailWhitelistEnabled).toBe(true); - expect(authBody.emailWhitelist).toEqual(['*@acme.com']); - }); - - it('includes Authorization header with API key', async () => { - fetchSpy.mockResolvedValueOnce( - new Response(JSON.stringify({ data: { resourceId: 'res-1' } }), { status: 200 }), - ); - fetchSpy.mockResolvedValueOnce( - new Response(JSON.stringify({ data: { targetId: 'tgt-1' } }), { status: 200 }), - ); - fetchSpy.mockResolvedValueOnce(mockShareLink()); - - const manager = createPangolinResourceManager(BASE_CONFIG); - await manager.expose( - 'abcdef12-0000-0000-0000-000000000000', - [{ port: 80, protocol: 'http' }], - [10001], - ); - - const [, opts] = fetchSpy.mock.calls[0]!; - const headers = (opts as RequestInit).headers as Record; - expect(headers['Authorization']).toBe('Bearer test-api-key'); - }); - - it('throws on resource creation error', async () => { - fetchSpy.mockResolvedValueOnce(new Response('server error', { status: 500 })); - - const manager = createPangolinResourceManager(BASE_CONFIG); - await expect( - manager.expose( - 'abcdef12-0000-0000-0000-000000000000', - [{ port: 3000, protocol: 'http' }], - [10001], - ), - ).rejects.toThrow('Pangolin resource creation failed for port 3000: 500'); - }); - }); - - describe('cleanup', () => { - it('deletes each resource', async () => { - fetchSpy.mockResolvedValue(new Response(null, { status: 204 })); - - const manager = createPangolinResourceManager(BASE_CONFIG); - await manager.cleanup([ - { - port: 3000, - hostPort: 10001, - resourceId: 'res-1', - publicUrl: 'https://s-abc-3000.fleet.tpops.dev', - }, - { - port: 5432, - hostPort: 10002, - resourceId: 'res-2', - publicUrl: 'https://s-abc-5432.fleet.tpops.dev', - }, - ]); - - expect(fetchSpy).toHaveBeenCalledTimes(2); - const [url1] = fetchSpy.mock.calls[0]!; - expect(url1).toBe('http://pangolin:3001/api/v1/resource/res-1'); - const [, opts1] = fetchSpy.mock.calls[0]!; - expect((opts1 as RequestInit).method).toBe('DELETE'); - }); - - it('continues on network error (best-effort)', async () => { - fetchSpy.mockRejectedValueOnce(new Error('network down')); - fetchSpy.mockResolvedValueOnce(new Response(null, { status: 204 })); - - const manager = createPangolinResourceManager(BASE_CONFIG); - await manager.cleanup([ - { port: 3000, hostPort: 10001, resourceId: 'res-1', publicUrl: 'https://x.dev' }, - { port: 5432, hostPort: 10002, resourceId: 'res-2', publicUrl: 'https://y.dev' }, - ]); - - expect(fetchSpy).toHaveBeenCalledTimes(2); - }); - }); -}); diff --git a/apps/worker/src/tunnel/pangolin-resources.ts b/apps/worker/src/tunnel/pangolin-resources.ts deleted file mode 100644 index 4b2b370..0000000 --- a/apps/worker/src/tunnel/pangolin-resources.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { createLogger } from '@paws/logger'; -import type { PortExposure } from '@paws/domain-network'; - -const log = createLogger('pangolin'); - -export interface PangolinResourceConfig { - /** Pangolin API URL (e.g., http://pangolin:3001/api/v1) */ - apiUrl: string; - /** API key for Bearer auth */ - apiKey?: string | undefined; - /** Session credentials (alternative to apiKey) */ - email?: string | undefined; - password?: string | undefined; - /** Pangolin organization ID */ - orgId: string; - /** This worker's Pangolin site ID (numeric, for target attachment) */ - siteId: string; - /** Pangolin domain ID for the base domain (from GET /org/{orgId}/domains) */ - domainId: string; - /** Base domain for generated URLs (e.g., "fleet.tpops.dev") */ - baseDomain: string; -} - -export interface ExposedTunnel { - /** Port inside the VM */ - port: number; - /** Allocated host port for iptables DNAT */ - hostPort: number; - /** Pangolin resource ID (for cleanup) */ - resourceId: string; - /** Public URL to access the port */ - publicUrl: string; - /** Human-readable label */ - label?: string | undefined; - /** Access control mode */ - access?: string | undefined; - /** Auto-generated PIN (when access is 'pin') */ - pin?: string | undefined; - /** Time-limited shareable link */ - shareLink?: string | undefined; -} - -/** - * Manages per-session Pangolin resources for port exposure. - * - * Creates "resources" within the worker's existing Pangolin site, each mapping - * a subdomain to a host:port. When a session ends, resources are cleaned up. - */ -export function createPangolinResourceManager(config: PangolinResourceConfig) { - const { apiUrl, apiKey, email, password, orgId, siteId, domainId, baseDomain } = config; - - let sessionCookie = ''; - - async function login(): Promise { - if (!email || !password) return; - const res = await fetch(`${apiUrl}/auth/login`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-csrf-token': 'x-csrf-protection', - }, - body: JSON.stringify({ email, password }), - signal: AbortSignal.timeout(10_000), - }); - if (!res.ok) { - throw new Error(`Pangolin login failed: ${res.status}`); - } - const cookies = res.headers.getSetCookie?.() ?? []; - sessionCookie = cookies.map((c: string) => c.split(';')[0]).join('; '); - } - - function buildHeaders(): Record { - const headers: Record = { - 'Content-Type': 'application/json', - 'x-csrf-token': 'x-csrf-protection', - }; - if (apiKey) { - headers['Authorization'] = `Bearer ${apiKey}`; - } else if (sessionCookie) { - headers['Cookie'] = sessionCookie; - } - return headers; - } - - async function authFetch(url: string, init: RequestInit): Promise { - let res = await fetch(url, { - ...init, - headers: { ...buildHeaders(), ...(init.headers as Record) }, - signal: AbortSignal.timeout(10_000), - }); - - // Retry once on 401 with fresh session - if (res.status === 401 && email && password) { - await login(); - res = await fetch(url, { - ...init, - headers: { ...buildHeaders(), ...(init.headers as Record) }, - signal: AbortSignal.timeout(10_000), - }); - } - - return res; - } - - /** Best-effort cleanup of Pangolin resources */ - async function cleanupTunnels(tunnels: ExposedTunnel[]): Promise { - for (const tunnel of tunnels) { - try { - const res = await authFetch(`${apiUrl}/resource/${tunnel.resourceId}`, { - method: 'DELETE', - }); - if (!res.ok && res.status !== 404) { - log.error('Failed to delete resource', { - resourceId: tunnel.resourceId, - status: res.status, - }); - } - } catch (err) { - log.error('Error deleting resource', { - resourceId: tunnel.resourceId, - error: err instanceof Error ? err.message : String(err), - }); - } - } - } - - /** - * Generate a subdomain for a session + port, using label if provided. - * Uses 12 hex chars from session UUID (48 bits) — collision-safe to ~420k concurrent sessions. - */ - function subdomain(sessionId: string, port: number, label?: string): string { - // Strip hyphens from UUID and take first 12 hex chars (48 bits of entropy) - const shortId = sessionId.replace(/-/g, '').slice(0, 12); - if (label) { - const slug = label - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-|-$/g, ''); - if (slug) return `s-${shortId}-${slug}`; - } - return `s-${shortId}-${port}`; - } - - return { - /** - * Create Pangolin resources for each exposed port. - * Returns the created tunnels with public URLs. - */ - async expose( - sessionId: string, - ports: PortExposure[], - hostPorts: number[], - ): Promise { - if (email && password && !sessionCookie) { - await login(); - } - - const tunnels: ExposedTunnel[] = []; - - try { - for (let i = 0; i < ports.length; i++) { - const portConfig = ports[i]!; - const hostPort = hostPorts[i]!; - const sub = subdomain(sessionId, portConfig.port, portConfig.label); - const fullDomain = `${sub}.${baseDomain}`; - - // Step 1: Create resource (subdomain + domain) - const createRes = await authFetch(`${apiUrl}/org/${orgId}/resource`, { - method: 'PUT', - body: JSON.stringify({ - name: `paws-${sub}`, - subdomain: sub, - domainId, - http: true, - protocol: 'tcp', - }), - }); - - if (!createRes.ok) { - const text = await createRes.text().catch(() => ''); - throw new Error( - `Pangolin resource creation failed for port ${portConfig.port}: ${createRes.status} ${text}`, - ); - } - - const createBody = (await createRes.json()) as { - data: { resourceId: string | number }; - }; - const resourceId = String(createBody.data.resourceId); - - // Step 2: Add target (site + host + port) - const targetRes = await authFetch(`${apiUrl}/resource/${resourceId}/target`, { - method: 'PUT', - body: JSON.stringify({ - siteId: Number(siteId), - ip: 'localhost', - port: hostPort, - method: portConfig.protocol === 'https' ? 'https' : 'http', - }), - }); - - if (!targetRes.ok) { - const text = await targetRes.text().catch(() => ''); - // Clean up the resource we just created - await authFetch(`${apiUrl}/resource/${resourceId}`, { method: 'DELETE' }).catch( - () => {}, - ); - throw new Error( - `Pangolin target creation failed for port ${portConfig.port}: ${targetRes.status} ${text}`, - ); - } - - // Step 3: Configure access control on the resource - const accessMode = portConfig.access ?? 'sso'; - let pin: string | undefined; - - if (accessMode === 'pin') { - // Generate a 6-digit PIN - pin = String(Math.floor(100000 + Math.random() * 900000)); - await authFetch(`${apiUrl}/resource/${resourceId}/auth`, { - method: 'PUT', - body: JSON.stringify({ - sso: false, - pincodeEnabled: true, - pincode: pin, - }), - }).catch((err) => { - log.error('Failed to set PIN for resource', { - resourceId, - error: err instanceof Error ? err.message : String(err), - }); - }); - } else if (accessMode === 'email') { - const emails = portConfig.allowedEmails ?? []; - await authFetch(`${apiUrl}/resource/${resourceId}/auth`, { - method: 'PUT', - body: JSON.stringify({ - sso: false, - emailWhitelistEnabled: true, - emailWhitelist: emails, - }), - }).catch((err) => { - log.error('Failed to set email whitelist for resource', { - resourceId, - error: err instanceof Error ? err.message : String(err), - }); - }); - } - // 'sso' is the default — no extra config needed - - // Step 4: Create a time-limited shareable link - let shareLink: string | undefined; - const shareLinkRes = await authFetch(`${apiUrl}/resource/${resourceId}/share-link`, { - method: 'PUT', - body: JSON.stringify({ - // Link expires when the session's timeout expires (default 10 min) - expiresIn: '24h', - }), - }).catch(() => null); - - if (shareLinkRes?.ok) { - const linkBody = (await shareLinkRes.json().catch(() => null)) as { - data?: { link?: string; token?: string }; - } | null; - if (linkBody?.data?.link) { - shareLink = linkBody.data.link; - } else if (linkBody?.data?.token) { - shareLink = `https://${fullDomain}?token=${linkBody.data.token}`; - } - } - - tunnels.push({ - port: portConfig.port, - hostPort, - resourceId, - publicUrl: `https://${fullDomain}`, - label: portConfig.label, - access: accessMode, - pin, - shareLink, - }); - } - } catch (err) { - // Clean up any resources created before the failure - if (tunnels.length > 0) { - await cleanupTunnels(tunnels); - } - throw err; - } - - return tunnels; - }, - - /** Delete all Pangolin resources for a session (best-effort, logs errors) */ - async cleanup(tunnels: ExposedTunnel[]): Promise { - await cleanupTunnels(tunnels); - }, - }; -} - -export type PangolinResourceManager = ReturnType; diff --git a/docker-compose.yml b/docker-compose.yml index 0fa1827..c0fd554 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,11 +4,9 @@ # # Architecture: # Caddy (:80/:443) → Gateway (:4000) — dashboard + API -# Pangolin + Gerbil (:51820/udp) — worker WireGuard tunnels only -# Traefik (Pangolin network) — tunnel subdomain routing for exposed ports # -# The dashboard and API are served directly by the gateway behind Caddy. -# Pangolin is only used for worker tunnel connectivity and port exposure. +# Workers connect via K8s Services (in-cluster) or WebSocket call-home (remote). +# No tunneling dependencies required. services: # ── Caddy (Reverse Proxy + Auto-HTTPS — enabled when domain is set) ────── @@ -59,13 +57,6 @@ services: OIDC_REDIRECT_URI: '${OIDC_REDIRECT_URI:-}' OIDC_AUTH_EXTERNAL_URL: '${OIDC_AUTH_EXTERNAL_URL:-}' AUTH_SECRET: '${AUTH_SECRET:-change-me-to-a-random-32-char-string}' - PANGOLIN_OIDC_SECRET: '${PANGOLIN_OIDC_SECRET:-}' - # Pangolin discovery (for workers) - PANGOLIN_API_URL: '${PANGOLIN_API_URL:-}' - PANGOLIN_API_KEY: '${PANGOLIN_API_KEY:-}' - PANGOLIN_ORG_ID: '${PANGOLIN_ORG_ID:-}' - PANGOLIN_EMAIL: '${PANGOLIN_EMAIL:-}' - PANGOLIN_PASSWORD: '${PANGOLIN_PASSWORD:-}' # Metrics VICTORIAMETRICS_URL: 'http://victoriametrics:8428' healthcheck: @@ -75,60 +66,6 @@ services: retries: 3 start_period: 10s - # ── Pangolin (Tunnel Management — enabled when workers are added) ───────── - pangolin: - image: fosrl/pangolin:latest - container_name: paws-pangolin - restart: unless-stopped - networks: - - paws - volumes: - - ./config/pangolin:/app/config - healthcheck: - test: ['CMD', 'curl', '-f', 'http://localhost:3001/api/v1/'] - interval: 10s - timeout: 10s - retries: 15 - start_period: 15s - - # ── Gerbil (WireGuard Tunnel Server) ───────────────────────────────────── - gerbil: - image: fosrl/gerbil:latest - container_name: paws-gerbil - restart: unless-stopped - depends_on: - pangolin: - condition: service_healthy - networks: - - paws - command: - - --reachableAt=http://gerbil:3004 - - --generateAndSaveKeyTo=/var/config/key - - --remoteConfig=http://pangolin:3001/api/v1/ - volumes: - - ./config/pangolin:/var/config - cap_add: - - NET_ADMIN - - SYS_MODULE - ports: - - '51820:51820/udp' - - '21820:21820/udp' - - # ── Traefik (Tunnel Subdomain Routing — for exposed VM ports only) ─────── - # Runs on Gerbil's network stack. Caddy forwards tunnel subdomains here. - traefik: - image: traefik:v3.6 - container_name: paws-traefik - restart: unless-stopped - depends_on: - pangolin: - condition: service_healthy - network_mode: service:gerbil - command: - - --configFile=/etc/traefik/traefik_config.yml - volumes: - - ./config/traefik:/etc/traefik:ro - # ── Dex (OIDC Identity Provider) ───────────────────────────────────────── dex: image: ghcr.io/dexidp/dex:v2.41.1 @@ -220,7 +157,6 @@ volumes: caddy-config: dex-data: gateway-data: - letsencrypt-data: vm-data: loki-data: grafana-data: diff --git a/docs/architecture.md b/docs/architecture.md index 1a522af..a536945 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -178,54 +178,53 @@ Per VM: ## Worker Connectivity -Workers connect to the control plane via Pangolin WireGuard tunnels. Each worker runs -Newt (Pangolin's tunnel agent) which establishes an encrypted tunnel back to Gerbil on -the control plane VPS. +Workers connect to the control plane via K8s Services (in-cluster) or WebSocket call-home +(remote bare-metal workers). ``` -Control Plane VPS -├── Pangolin (tunnel control plane + dashboard) -├── Gerbil (WireGuard tunnel server, :51820/udp) -├── Traefik (reverse proxy, :80/:443) -├── Control Plane (API + dashboard, :4000) -├── Dex (OIDC, :5556) -└── VictoriaMetrics + Grafana (metrics) - - ↕ WireGuard tunnel +K8s Cluster +├── Control Plane (Deployment, :4000) +│ ├── API + dashboard +│ ├── Dex (OIDC, :5556) +│ └── VictoriaMetrics + Grafana (metrics) +│ +└── Worker (DaemonSet, :3000) + ├── Worker process + └── Firecracker VMs -Worker (bare metal, anywhere) -├── Newt (tunnel agent → connects to Gerbil) -├── Worker process (:3000) -└── Firecracker VMs +Connected via: ClusterIP Services (standard K8s networking) ``` -### Discovery - -The control plane discovers workers by polling Pangolin's API for connected sites: +For remote bare-metal workers that can't join the K8s cluster: ``` -Pangolin API: GET /api/v1/org/{orgId}/sites - → filter to online: true sites - → extract tunnel IP from subnet field - → health-check worker at http://{tunnelIP}:3000/health - → add to fleet registry +K8s cluster: Remote server: + Control Plane ◄─── WebSocket ───── Worker (systemd) + call-home connection + (worker initiates) ``` -Four discovery layers (first match wins): +### Discovery + +The control plane discovers workers through three layers (first match wins): -1. **Pangolin** — tunnel-connected workers (primary) -2. **Call-home registry** — WebSocket-connected workers (legacy) -3. **K8s pod discovery** — in-cluster Kubernetes deployments -4. **Static URL** — manual WORKER_URL env var (dev/single-node) +1. **K8s pod watcher** — in-cluster Kubernetes deployments (primary) +2. **WebSocket call-home** — remote workers register via WebSocket +3. **Static URL** — manual WORKER_URL env var (dev/single-node) ### Worker Onboarding +**In-cluster (K8s):** Workers are deployed as a DaemonSet. They are discovered automatically +by the control plane's K8s pod watcher. + +**Remote bare-metal:** + ```bash # On the worker machine: curl -fsSL https://raw.githubusercontent.com/arek-e/paws/main/scripts/setup-worker.sh | bash -# Prompts for: Site ID, Site Secret, Pangolin Endpoint -# Installs: Newt + paws worker + Firecracker -# Starts: paws-newt.service + paws-worker.service +# Prompts for: Control plane URL, API key +# Installs: paws worker + Firecracker +# Starts: paws-worker.service (connects via WebSocket call-home) ``` ## VM Lifecycle (per session) diff --git a/docs/design-collaborative-sessions.md b/docs/design-collaborative-sessions.md new file mode 100644 index 0000000..899dd01 --- /dev/null +++ b/docs/design-collaborative-sessions.md @@ -0,0 +1,449 @@ +# Collaborative Sessions — Design Doc + +``` + /\_/\ +( o.o ) pair programming with cats + > ^ < +``` + +## Problem + +Today paws sessions are fire-and-forget: trigger fires, VM boots, agent runs script, VM destroyed. +This is fine for background automation (CI review, cron jobs), but doesn't support the most +valuable use case: **a developer and an agent working together on a task until it ships.** + +The developer needs to: + +- See the running app (frontend on :3000, backend on :3001) via a live preview URL +- Watch the agent's work in real-time (terminal, file changes, browser) +- Give the agent feedback and course-correct +- Test changes in the browser before shipping +- Ship a PR when the task is done +- Close the session and destroy the VM + +This is the Eva/Devin model — not fire-and-forget, but collaborative. + +## Prior Art + +| Product | Model | Port Exposure | Dev UX | +| --------------------- | ------------- | ----------------------------------------------- | -------------------------------------------------- | +| **Eva** | Collaborative | Daytona signed URLs per port, port picker in UI | Split-pane: chat + preview/editor/terminal/desktop | +| **Devin** | Collaborative | `expose_port` → `*.devinapps.com` | Replay timeline + live shell/editor/browser | +| **Background Agents** | Async | Code-server only (Modal tunnels) | WebSocket event stream, multiplayer | +| **Codex** | Async | None | Review diffs when done | +| **Copilot Workspace** | Hybrid | Codespaces port forwarding | Plan → implement → validate → PR | + +**We want the Eva/Devin model** — collaborative, live preview, real-time visibility — but self- +hosted, no SaaS dependencies, and built on paws's zero-secret VM architecture. + +--- + +## Session Lifecycle + +``` +1. MISSION CREATED + Developer describes a task: "Add dark mode to the settings page" + Optionally selects: repo, branch, daemon config, exposed ports + +2. VM BOOTS (<1s from snapshot) + ├── Firecracker VM restored from snapshot + ├── Per-VM MITM proxy spawned (credential injection) + ├── agentgateway config written (MCP tool access) + ├── iptables DNAT rules applied + ├── Git repo cloned/pulled (via proxy → GitHub with injected token) + └── Dev server started (npm run dev → :3000, :3001) + +3. SESSION URL ACTIVE + Developer gets: https://s-{sessionId}.paws.example.com/ + ├── /preview/:port — live app preview (iframe to dev server) + ├── /terminal — PTY session into the VM + ├── /editor — code-server (VS Code in browser) + ├── /chat — send prompts to the agent + └── /desktop — VNC (if browser-use/Xvfb is enabled) + +4. COLLABORATION LOOP + ┌──────────────────────────────────────────────────┐ + │ │ + │ Developer sees live preview ← agent makes changes│ + │ │ ↑ │ + │ ▼ │ │ + │ Developer gives feedback ──→ agent adjusts │ + │ │ ↑ │ + │ ▼ │ │ + │ Developer tests in browser ─→ "looks good" │ + │ │ + └──────────────────────────────────────────────────┘ + +5. SHIP + Developer (or agent) creates a PR from the session branch + PR includes: diff, screenshots, session replay link + +6. SESSION CLOSED + ├── VM destroyed (guaranteed cleanup) + ├── MITM proxy killed + ├── agentgateway config removed + ├── TAP device + iptables cleaned up + ├── Session replay preserved (audit log) + └── State volume snapshot taken (optional, for resuming) +``` + +--- + +## Port Exposure Architecture + +Built on `docs/design-vm-exposure.md`. The control plane is a reverse proxy for exposed VM ports. + +### How it works + +``` +Developer browser + │ + │ https://s-abc123.paws.example.com/preview/3000/ + │ + ▼ +┌───────────────────────────────────────────────┐ +│ Control Plane (Gateway) │ +│ │ +│ 1. Parse session ID from subdomain (s-abc123) │ +│ 2. Auth: OIDC (GitHub/Google) or session PIN │ +│ 3. Check port 3000 is in daemon's expose list │ +│ 4. Look up which worker owns this session │ +│ 5. Reverse-proxy → worker │ +│ │ +└───────────────┬───────────────────────────────┘ + │ ClusterIP (K8s) or direct (single-server) + ▼ +┌───────────────────────────────────────────────┐ +│ Worker │ +│ │ +│ 6. Route to session's VM │ +│ 7. Forward to VM guest IP (172.16.x.2:3000) │ +│ via the TAP device (INBOUND, not DNAT) │ +│ │ +└───────────────┬───────────────────────────────┘ + │ TAP device + ▼ +┌───────────────────────────────────────────────┐ +│ MicroVM (172.16.x.2) │ +│ │ +│ Next.js dev server on :3000 ← HMR works │ +│ Express API on :3001 │ +│ Vite on :5173 │ +│ Whatever the developer's stack needs │ +│ │ +│ (zero secrets, locked-down egress) │ +└───────────────────────────────────────────────┘ +``` + +### Inbound vs Outbound — two different paths + +This is important to understand. Outbound (VM → internet) and inbound (developer → VM) traffic +take completely different paths: + +``` +OUTBOUND (agent makes API call): + VM :443 → iptables DNAT → MITM proxy (172.16.x.1:8443) → internet + Proxy enforces: domain allowlist, credential injection + +INBOUND (developer views preview): + Developer → control plane → worker → TAP → VM :3000 + Control plane enforces: auth, port allowlist, session ownership + Worker forwards directly to VM guest IP on the TAP device + No MITM proxy involved — this is a simple TCP forward +``` + +The MITM proxy only handles OUTBOUND traffic. Inbound preview traffic bypasses it entirely — +the worker opens a direct TCP connection to the VM's guest IP on the allowed port. + +### WebSocket support (critical for dev servers) + +Dev servers use WebSockets for HMR (hot module replacement). The reverse proxy chain must support +WebSocket upgrade: + +``` +Browser (HMR WebSocket) + → control plane (HTTP Upgrade → WebSocket proxy) + → worker (WebSocket proxy) + → VM :3000 (Vite/Next.js HMR endpoint) +``` + +Both the control plane and worker reverse proxies must handle `Connection: Upgrade` headers and +establish bidirectional WebSocket tunnels. Hono supports this via `c.req.raw` + native WebSocket +APIs. + +### Port readiness detection + +Before showing the preview iframe, the dashboard should check if the dev server is actually +listening. Pattern from Eva: + +``` +Dashboard polls: GET /s/{sessionId}/health/{port} + → Worker checks: TCP connect to 172.16.x.2:{port} + → Returns: { ready: true } or { ready: false } + +Dashboard shows: + - Spinner while ready=false + - Live iframe when ready=true + - Auto-refreshes on HMR WebSocket reconnect +``` + +--- + +## Dashboard UX — Split Pane + +``` +┌──────────────────────────────────────────────────────────────────┐ +│ 🐾 paws — session s-abc123 [Ship PR] [End] │ +├─────────────────────────────┬────────────────────────────────────┤ +│ │ [Preview] [Terminal] [Editor] │ +│ CHAT │ │ +│ │ ┌──────────────────────────────┐ │ +│ 🤖 Agent: I've added the │ │ │ │ +│ dark mode toggle to the │ │ Live preview (:3000) │ │ +│ settings page. The theme │ │ │ │ +│ persists in localStorage. │ │ ┌────────────────────────┐ │ │ +│ │ │ │ Settings │ │ │ +│ 👤 You: Looks good but │ │ │ │ │ │ +│ the toggle animation is │ │ │ [🌙 Dark Mode: ON ] │ │ │ +│ janky. Can you smooth it? │ │ │ │ │ │ +│ │ │ │ Theme: Dark │ │ │ +│ 🤖 Agent: Fixed. I added │ │ └────────────────────────┘ │ │ +│ a 200ms CSS transition. │ │ │ │ +│ Check the preview. │ │ │ │ +│ │ └──────────────────────────────┘ │ +│ │ │ +│ ┌───────────────────────┐ │ Port: [3000 ▼] Status: ● Live │ +│ │ Type a message... │ │ │ +│ └───────────────────────┘ │ │ +├─────────────────────────────┴────────────────────────────────────┤ +│ Files changed: 3 │ Branch: feat/dark-mode │ ⏱ 12m active │ +└──────────────────────────────────────────────────────────────────┘ +``` + +### Tabs on the right pane + +| Tab | What | How | +| ------------ | ------------------- | ---------------------------------------- | +| **Preview** | Live app in iframe | `