Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 0 additions & 20 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────
Expand Down Expand Up @@ -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

Expand Down
7 changes: 4 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
node_modules/
dist/
*.tsbuildinfo
.env
.env.local
*.log
Expand Down Expand Up @@ -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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 12 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.5.3
0.5.4
59 changes: 27 additions & 32 deletions apps/control-plane/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}
Expand All @@ -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',
Expand Down Expand Up @@ -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));
Expand All @@ -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));
Expand Down Expand Up @@ -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,
);
}
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -1402,7 +1417,6 @@ export async function createControlPlaneApp(deps: ControlPlaneDeps) {
queuedSessions,
activeDaemons: daemonStore.countActive(),
activeSessions: sessionStore.countActiveSessions(),
...(deps.pangolinStatus && { pangolin: deps.pangolinStatus() }),
},
200,
);
Expand Down Expand Up @@ -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');
Expand Down
6 changes: 5 additions & 1 deletion apps/control-plane/src/auth/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
44 changes: 8 additions & 36 deletions apps/control-plane/src/autoscaler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,46 +114,14 @@
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

# Install bun
curl -fsSL https://bun.sh/install | bash
export PATH=\$PATH:\$HOME/.bun/bin

Check warning on line 124 in apps/control-plane/src/autoscaler.ts

View workflow job for this annotation

GitHub Actions / check

eslint(no-useless-escape)

Unnecessary escape character '$'

Check warning on line 124 in apps/control-plane/src/autoscaler.ts

View workflow job for this annotation

GitHub Actions / check

eslint(no-useless-escape)

Unnecessary escape character '$'

# Clone and install paws
git clone https://github.com/arek-e/paws /opt/paws
Expand All @@ -163,9 +131,13 @@
# 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 &
`;
}

Expand Down
Loading
Loading