secure-access

Secure Access — One-Click Secure Access for Connected Machines

Status (2026-07-01): Phase 1 (cloudflared) has shipped — the expose/unexpose job kinds (agent/internal/jobs/jobs.go, lib/platform/jobs.ts) and the exposures table (migration 0016-exposures) are live; an agent claims an expose job and runs cloudflared to publish a Node-local port at a public hostname. Phase 3 (the native reverse-tunnel that retires the Cloudflare dependency) remains spec. The data model was designed transport-agnostic from day one (spec.expose.mode ∈ {'cloudflared','native'}), so Phase 3 is a transport swap behind a stable model, not a rewrite. The rest of this doc is the original design record.

North star fit: "Coolify deploys your apps. HostSSH runs your server." Secure Access is the runs-your-server primitive that lets an operator reach an on-box admin surface (Coolify :8000, an app dashboard, a web-SSH terminal) over authenticated HTTPS — with zero inbound ports, no VPN, and no per-box manual DNS/cert/tunnel work.


1. Problem & goal

The Girard Media fleet's Coolify panels are served at http://31.220.104.207:8000, .211:8000, .223:8000 — raw-IP HTTP on a non-standard port. The operator's ISP (CUJO/Charter) 302-blocks raw-IP HTTP to that port (see memory note Coolify API ISP Filter), so reaching any panel today forces a VPN/hotspot detour. Every box is in this state: no public hostname, no TLS, panel bound to an IP:port that is either firewalled or filtered. Goal: on a connected machine's page in the god-console, an operator clicks "Expose securely", picks the port (auto-detected as Coolify :8000), chooses an auth policy, and gets a live https://<machine>.<tenant>.hostssh.app URL — TLS-terminated, auth-gated, brokered entirely over the agent's existing outbound channel. No inbound port is opened on the box, no VPN is involved, and DNS/cert are handled once at our edge, not per-box.


2. Decision: own vs rent the tunnel

Three options, judged against the locked constraint "fully our platform / consolidated cost / minimize external deps" (PLATFORM.md:17-22, control-panel.md:27-28, ARCHITECTURE.md:35-36):

(a) NATIVE reverse-tunnel through the HostSSH agent↔control-plane spine

A 4th agent goroutine (alongside heartbeat/jobs/reaper in cli.go:374-524) dials an outbound multiplexed connection (yamux over WSS, both MIT — hashicorp/yamux, coder/websocket) to a control-plane edge relay. The control plane terminates TLS at a per-machine hostname and brokers each HTTP(S)/SSH stream back down the mux to the agent, which proxies to localhost:PORT. Reuses secure.Client's pinning/mTLS transport (secure.go:102-138), the fingerprint identity, the license gate, and the job-claim dispatch pattern.

  • Cost: $0 forever. No per-seat, no per-tunnel, no third party in the data path.
  • Owns: transport, TLS, auth, hostname, audit — end to end.
  • Cost: net-new edge relay component (a public ingress the control plane does not have today — it exposes only /v1/* request/response routes, no long-lived socket). This is the one real build.

(b) Orchestrate Cloudflare Tunnel one-click

Agent claims an expose job whose spec carries a cloudflared token; agent runs cloudflared (Apache-2.0, permissive — control-panel.md:118) as a sidecar; control plane provisions the CF Tunnel + a *.hostssh.app CNAME via the existing setARecord() path (lib/fleet/dns.ts:71) and a Cloudflare Access policy for auth.

  • Cost: $0 today — Cloudflare Tunnel is free; Cloudflare Access is free ≤ 50 users. Past 50 seats Access is paid, and the tunnel control plane is Cloudflare's.
  • Fast: the DNS leg (lib/fleet/dns.ts), the connections(kind='access', provider='cloudflare') schema (schema-connections.sql:64,72), and the tunnel/web_ssh capability tags (CONNECTIONS.md:43,60) already exist. No edge relay to build.
  • Dep: Cloudflare in the trust + auth path; a 50-seat cap on the free auth tier; contradicts "fully our platform."

Ship (b) as Phase 1 to deliver value this week off seams that already exist (DNS + connections + jobs), then build (a) as Phase 3 and migrate the data path to native, retiring the Cloudflare dependency. The expose job-kind and the exposures table are designed transport-agnostic from day one (spec.expose.mode ∈ {'cloudflared','native'}), so Phase 3 is a transport swap behind a stable data model and UX — not a rewrite.

Recommendation: Path (c), terminating on (a). Cloudflare buys speed-to-value while the free tier holds; native reverse-tunnel is the destination because it honors zero-external-dep and consolidated cost ($0 forever, nobody else in the path). The motivating Coolify-:8000 case is solved in Phase 1 (CF) and re-solved identically in Phase 3 (native) with no UX change.


A Secure Access session reuses every existing seam except the long-lived data plane, which is the single net-new primitive.

Operator browser
      │  1. HTTPS GET https://207.girard.hostssh.app  (passkey/session cookie)
      ▼
┌─────────────────────────── CONTROL PLANE ───────────────────────────┐
│  Edge relay (NEW public ingress)                                     │
│   • TLS terminates at *.<tenant>.hostssh.app (own ACME wildcard)     │
│   • Security spine: guard('access.ssh') + WebAuthn step-up + RLS     │
│   • Looks up exposures row → finds machine fingerprint + local_addr  │
│   • Opens a stream over the agent's existing outbound mux            │
│  /v1/jobs/claim  ──(enqueue 'expose' job, pull-model)──►             │
└─────────────────────────────────────────────────────────────────────┘
      ▲  outbound WSS+yamux (agent dialed OUT — NO inbound port)
      │  2. agent multiplexes the operator's HTTP stream down
┌─────────────────────────────── AGENT (egress-only) ─────────────────┐
│  4th goroutine in cmdAgent (cli.go:374-524): tunnel dialer           │
│   • dials control-plane edge with secure.Client (mTLS+SPKI pin)      │
│   • on each inbound stream → internal/proxy reverse-proxies to       │
│     127.0.0.1:8000  (Coolify)  /  127.0.0.1:8765 (agent mux)         │
└─────────────────────────────────────────────────────────────────────┘
      │  3. plain localhost HTTP to the on-box service
      ▼
   Coolify :8000  /  app dashboard  /  web-SSH PTY

Flow:

  1. Operator hits https://207.girard.hostssh.app. TLS terminates at our edge (not on the box).
  2. Auth gate runs the security spine: guard('access.ssh') (lib/access.ts:20-26, RBAC PERMISSIONS['access.ssh'] at rbac.ts:31) plus the per-session WebAuthn step-up specified in web-ssh.md:51. Session is recorded to the hash-chained audit trail (audit.go, action access.session).
  3. Edge looks up the exposures row for that hostname → resolves fingerprint + local_addr. It opens a logical stream over the agent's already-open outbound mux and forwards the operator's HTTP(S)/SSH bytes.
  4. Agent receives the stream and hands it to internal/proxy (extended — see §5) which dials 127.0.0.1:8000 and reverse-proxies the connection. Response flows back up the same mux.

Where TLS / cert lives. Edge cert is our own ACME wildcard *.<tenant>.hostssh.app, obtained via DNS-01 against Cloudflare using the existing CLOUDFLARE_DNS_TOKEN connector (FRAMEWORK.md:50, lib/fleet/dns.ts) — the same DNS-01 leg already used for app routing, no per-box cert work. This sidesteps the unproven alt-port HTTP-01 ACME gap (PLATFORM.md:113-116): wildcard DNS-01 needs no inbound :80/:443 on any box. The box itself runs no Traefik and no LE for this feature — the agent's existing proxy.Traefik labeler is for public app routing and is untouched; Secure Access terminates TLS only at the edge and carries plaintext-inside-mTLS down the tunnel.

Auth gate = the security spine. mTLS + SPKI pinning on the agent↔edge transport (secure.go:102-138); ed25519 license entitlement gates whether the box may expose at all (the expose job is license-gated like ssh, see §5); operator-side auth is RBAC access.ssh + WebAuthn step-up + audit. This is the exact model web-ssh.md:140-148 specifies ("You → HostSSH gateway (authn+grant) → tunnel (no open :22) → Agent → shell"), now realized with our own relay instead of Cloudflare's.


4. Data model & API

New job-kind

Extend the CHECK in schema-jobs.sql:16-17 and the TS union in jobs.ts:10:

-- schema-jobs.sql
kind TEXT CHECK (kind IN ('deploy','redeploy','stop','remove','expose','unexpose'))
// lib/platform/jobs.ts:10
type JobKind = 'deploy'|'redeploy'|'stop'|'remove'|'expose'|'unexpose'

Extend interface JobSpec (jobs.ts:14-25) — the spec JSONB column needs no migration:

expose?: {
  mode: 'cloudflared' | 'native';   // transport-agnostic from day one
  localAddr: string;                // '127.0.0.1:8000'
  hostname: string;                 // '207.girard.hostssh.app'
  exposureId: string;               // FK to exposures table
  sessionToken?: string;            // one-time, for cloudflared mode
}

New table: exposures (control-plane/db/schema-access.sql, new file, idempotent like schema-jobs.sql)

CREATE TABLE IF NOT EXISTS exposures (
  id            TEXT PRIMARY KEY,          -- 'exp_'+uuid
  license_key   TEXT NOT NULL,
  org_id        UUID,                      -- ties into schema-connections RLS
  fingerprint   TEXT NOT NULL,             -- target box (agents.fingerprint)
  local_addr    TEXT NOT NULL,             -- '127.0.0.1:8000'
  service_hint  TEXT,                      -- 'coolify' | 'app:<name>' | 'web-ssh'
  hostname      TEXT UNIQUE NOT NULL,      -- '<machine>.<tenant>.hostssh.app'
  mode          TEXT NOT NULL CHECK (mode IN ('cloudflared','native')),
  auth_policy   TEXT NOT NULL CHECK (auth_policy IN ('passkey','session','public_link','ip_allow')),
  status        TEXT NOT NULL CHECK (status IN ('pending','active','degraded','revoked')) DEFAULT 'pending',
  created_at    TIMESTAMPTZ DEFAULT now(),
  updated_at    TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_exposures_fp ON exposures (fingerprint, status);
CREATE INDEX IF NOT EXISTS idx_exposures_host ON exposures (hostname);

Tunnel secrets never sit in this table. For cloudflared mode, store the tunnel token via the existing custody machinery: a connections row kind='access', provider='cloudflare' (schema-connections.sql:64,72) + a connection_credentials row with key_custody='agent_local' (schema-connections.sql:101-120) so the control plane holds existence + status + masked hint only. For native mode there is no third-party secret at all — identity is the existing mTLS client cert + ed25519 token. Reuse audit_log (schema-connections.sql:248-257) for access.expose / access.revoke / access.session, and resource_neutralization.presented_hostname for white-label.

Control-plane endpoints — reuse /v1/jobs, add zero agent-facing endpoints

The agent's claim/status/logs trio (jobs/claim, jobs/[id]/status, jobs/[id]/logs) is kind-agnosticclaimJob/setJobState/appendJobLogs (jobs.ts:113-195) and the VALID state list (status/route.ts:10) work unchanged for expose/unexpose. New surfaces are operator-facing only:

EndpointPurposeReuses
exposeAction (server action in lib/platform/actions.ts)enqueue expose job, pinned to a fingerprintenqueueJob(defaultLicenseKey(), spec, {kind:'expose', fingerprint, deploymentId}) (actions.ts:25-44) — wire the currently-null fingerprint arg
unexposeActiontear downsame enqueueJob path
GET /api/v1/fleet/access (NEW, GET, license-scoped)list active exposures for the fleet viewmirrors fleet/servers shape (fleet/servers route)
Edge relay ingress (NEW, native only)the public WSS endpoint the agent dials + per-hostname HTTPS vhostnet-new (see §9)

Reflection: add a reflectAccessStatus(exposureId, jobState) beside reflect.ts's JOB_TO_DEPLOYMENT (reflect.ts:14-37), mapping succeeded→active, failed→degraded, writing back to the exposures row so the dashboard renders live status. Optionally surface tunnel health on the heartbeat by adding an access_health field to the heartbeat body (heartbeat/route.ts:19-32) and agents columns, mirroring the backup_*/drill_* pattern (schema-telemetry.sql:30-33).


5. Agent changes

All changes hang off cmdAgent (cli.go:374-524) and internal/proxy. Net-new code is the tunnel dialer + a reverse-proxy handler; everything else is reuse.

  1. Promote the ssh stub to a real handler. cli.go:137-141 returns exit 3 ("on the roadmap") today; it's already in gatedCommands (cli.go:84) and license-enforced (exit 12). The expose/unexpose job-kinds ride the same gate — add them to the gated set so an unlicensed box cannot open a tunnel.

  2. New internal/access package (mirrors the fleet/jobs client shape). Holds the tunnel dialer and the reverse-proxy handler. Wired into a 4th goroutine in cmdAgent alongside heartbeat (cli.go:390-445), job-claim (cli.go:450-467), and reaper (cli.go:471-477):

    • dials the control-plane edge with secure.Client (mTLS + SPKI pin, secure.go:102-138) over WSS, establishes a yamux session, and re-dials with backoff if the mux drops (the heartbeat already proves liveness separately, so a flapping tunnel never starves telemetry).
    • on each inbound logical stream, reads the target local_addr (carried in the expose job spec the agent claimed) and hands the stream to the proxy handler below.
  3. Extend internal/proxy — today it is only a Traefik label generator (proxy.go:42-79), not a tunnel or HTTP proxy. Add a sibling, e.g. proxy/reverse.go, exposing a ReverseProxy(stream io.ReadWriteCloser, localAddr string) that dials 127.0.0.1:PORT and copies bytes both ways (a stdlib httputil.ReverseProxy for HTTP exposures, raw io.Copy for web-SSH/TCP). Keep the existing ValidHost/sanitizeRouter hardening (proxy.go:16-39) and add a localAddr allow-list so a compromised edge can't pivot the agent to arbitrary host:port. The Traefik labeler is untouched — it stays the public-app routing path; this is a parallel reach-through, exactly the thin-client-over-HTTP precedent mcpkit.HTTPHandler already sets (mcpkit.go:178-255).

  4. Claim/heartbeat loop: no protocol change. The agent claims an expose job on the existing 15s job poll (cli.go:450-467, jobs.Claimjobs.go:84-96), runJob/executeJob (cli.go:646-676) gets a new case "expose" that registers the local_addr→hostname mapping with the access goroutine and reports succeeded via the existing /v1/jobs/{id}/status (jobs.go:101-105). unexpose deregisters. Audit via the existing auditLog hook (cli.go:572-588).

  5. Phase-1 (cloudflared) variant: the expose job case runs cloudflared as a managed child process using the sessionToken from the spec; no new goroutine, no edge relay. The reverse-proxy handler and 4th goroutine land only in Phase 3.


6. One-click UX

Maps onto the god-console fleet/access surfaces that already exist (the access page renders mock data today — access/page.tsx, lib/data.ts:251-252; this replaces the mock with real exposures).

On a connected machine's page (/(god)/... fleet detail, the same view fed by GET /v1/fleet/servers):

  1. "Expose a service" button → drawer.
  2. Pick port / known-service. The drawer auto-detects on-box services from the agent: a probe over the existing local mux (GET /healthz style on 127.0.0.1:8765) reports listening ports; :8000 is labeled "Coolify Admin" (service_hint coolify), app containers labeled by name. Operator picks one or types a custom port.
  3. Choose auth. passkey (WebAuthn step-up, default for admin surfaces), session (existing god-console cookie), ip_allow, or public_link (warned, audit-flagged). This sets exposures.auth_policy.
  4. Operator clicks Expose securelyexposeAction enqueues the expose job pinned to the box's fingerprint. The drawer streams job logs (/v1/jobs/{id}/logs) and reflects status via reflectAccessStatus.
  5. Live URL appears: https://207.girard.hostssh.app with a copy button and an Open button (passkey-gated). Status chip = active/degraded from the reflected job state + optional heartbeat access_health.

For the Girard fleet this means: open .207's page → "Expose a service" → drawer shows Coolify Admin (:8000) pre-selected → choose PasskeyExpose securelyhttps://207.girard.hostssh.app is live over HTTPS, ISP filter and VPN both irrelevant because the path is now a clean public hostname on :443.

Replace the mock toasts in components/access/access-actions.tsx:21-39 ("not connected yet") with the real expose/revoke actions; back the access page with the exposures table instead of getSshKeys/getSshSessions arrays.


7. Cost & ownership analysis

Native (recommended dest., Phase 3)Rent — Cloudflare Tunnel + Access (Phase 1)
Per-machine marginal cost$0$0 (CF Tunnel free)
Per-tenant marginal cost$0$0 ≤ 50 operators; paid Access tier beyond
Data path ownerHostSSH onlyCloudflare edge
Auth ownerOur security spine (RBAC + WebAuthn + audit)Cloudflare Access (capped)
TLS / certOwn ACME wildcard, DNS-01Cloudflare edge cert
Hostname*.<tenant>.hostssh.app (ours)ours via CF DNS
External deps eliminatedcloudflared binary, CF Tunnel control plane, CF Access seat cap — none remain in the pathDNS-01 still uses CLOUDFLARE_DNS_TOKEN (already a dep)
New infra we runedge relay (public ingress)none

Net: Phase 1 is $0 today but parks a third party and a 50-seat ceiling in the trust path. Phase 3 is $0 forever with nobody else in the path, at the cost of running one edge relay — directly serving "fully our platform / consolidated cost." DNS-01 against Cloudflare remains the only Cloudflare touch (bounded to DNS, already accepted per ARCHITECTURE.md:104-105), and is itself swappable (Route53 is matrix-listed).


8. Phased build plan

Phase 1 — Ship fastest safe value (cloudflared orchestration)

Reusable today: jobs pull-model, DNS-01 (lib/fleet/dns.ts), connections/custody schema, RBAC access.ssh, audit. Net-new: expose job case, exposures table, expose UX.

  • schema-jobs.sql:16-17 + jobs.ts:10: add expose/unexpose kinds; extend JobSpec.expose (jobs.ts:14-25).
  • New schema-access.sqlexposures table; apply via psql -f.
  • lib/platform/actions.ts: exposeAction/unexposeAction (wire the fingerprint arg through dispatch, actions.ts:25-44); gate on new RBAC perm access.expose.
  • Agent executeJob (cli.go:646-676): case "expose" runs cloudflared with sessionToken; add expose/unexpose to gatedCommands (cli.go:84).
  • Control plane: provision CF Tunnel + *.hostssh.app CNAME (setARecord, dns.ts:71) + CF Access policy; store token as connections(kind='access',provider='cloudflare') + connection_credentials(key_custody='agent_local').
  • reflectAccessStatus in reflect.ts; back access/page.tsx with exposures; replace mock toasts (access-actions.tsx:21-39).
  • Expose drawer + service auto-detect (Coolify :8000).

Phase 2 — Harden + operator auth (still on CF transport)

  • WebAuthn step-up at the gate (today's MFA is TOTP-only, totp.ts) — net-new, the one missing auth primitive (web-ssh.md:51).
  • Session recording → hash-chained audit (audit.go); ship audit chain-head on the heartbeat (audit.go:15-16 gap) so sessions are off-box tamper-evident.
  • access_health on heartbeat (heartbeat/route.ts:19-32) + agents columns → live status on GET /v1/fleet/access.
  • auth_policy enforcement (ip_allow, public_link warnings), revoke kill-switch via unexpose.

Phase 3 — Full native (retire Cloudflare from the data path)

Net-new: edge relay + tunnel dialer + reverse-proxy handler. Everything else (data model, UX, auth, audit) carries over unchanged — transport swap only.

  • Control-plane edge relay: public WSS ingress + per-hostname HTTPS vhost; own ACME wildcard *.<tenant>.hostssh.app via DNS-01.
  • New agent/internal/access: tunnel dialer (yamux/WSS over secure.Client, secure.go:102-138) as the 4th goroutine in cmdAgent (cli.go:374-524); backoff/redial.
  • agent/internal/proxy/reverse.go: ReverseProxy(stream, localAddr) with localAddr allow-list; keep ValidHost hardening (proxy.go:16-39).
  • Flip expose job mode default cloudflarednative; migrate live exposures; decommission cloudflared sidecars.
  • Web-SSH PTY over the same mux (interactive runtime.Exec replacement — x/crypto/ssh cert-only + creack/pty, the web-ssh.md target stack).

9. Risks & open decisions

  • The edge component (does the control plane need a public ingress?). Yes — Phase 3's blocker. Today the control plane exposes only /v1/* request/response routes and holds no long-lived socket (the whole spine is agent→CP pull). A native reverse-tunnel requires a new always-on public WSS ingress + per-hostname TLS vhost the CP does not currently run. This is the single largest net-new piece; Phase 1 deliberately avoids it by renting Cloudflare's edge.
  • ACME on alt ports. Real public HTTP-01 LE on alt ports is the one unproven mile (PLATFORM.md:113-116). Secure Access sidesteps it entirely by using DNS-01 wildcard at the edge — no inbound :80/:443 on any box, one wildcard cert per tenant, zero per-box cert work. (DNS-01 wildcard itself is still ⬜ in code, PLATFORM-PARITY.md:103 — net-new but well-trodden.)
  • Multi-tenant isolation. exposures.hostname is globally unique and namespaced <machine>.<tenant>.hostssh.app; the edge resolves hostname→org_id+fingerprint and must enforce that the authenticated operator's org_id matches. Reuse the connections RLS model (schema-connections.sql:281-308, GUC-keyed hostssh.org_id) and resource_neutralization.presented_hostname for white-label. A bug here is cross-tenant exposure — RLS + a per-stream org check at the edge are mandatory.
  • Security-spine dependency. Two of the three gate conditions are now MET (commit 92629f7): (1) verify-before-store on activation — license.go:62-80 verifies the ed25519 token via VerifyToken + fingerprint-binding before storing it; (2) transport hardening — all live agent→CP call sites route through secure.Client (mTLS+SPKI pin), with non-TLS rejected by RequireHTTPS (license.go:46, telemetry.go:85, fleet.go:48, jobs.go:64). The one remaining gate condition is per-agent client-cert issuance at activation: today the mTLS client cert is env-supplied only (secure.go:140-150), and /v1/license/activate returns {token, plan, features, expires} — NOT the {token, client_cert} promised in agent-protocol.md:61/91. Nothing on the CP side verifies a client cert yet (neither the existing /v1/* ingress nor the unbuilt Phase 3 edge), so issuance is capability without enforcement until the edge relay exists — co-design issuance with the Phase 3 edge, and land it before the native tunnel carries operator-to-root traffic. Secure Access must not ship its native tunnel on this last fail-open seam. WebAuthn step-up is likewise net-new (TOTP-only today) and is a Phase-2 prerequisite for admin-surface exposure.
  • Open decisions to resolve. (1) SSH-CA topology for web-SSH over the tunnel — per-tenant CA vs fleet-wide, KMS vs HSM (control-panel.md:126, the named "can't defer past Web-SSH phase" decision). (2) Edge relay placement — co-located with the control plane vs a dedicated thin relay box (R2+Worker pattern doesn't fit a long-lived socket). (3) Default auth policy for admin surfaces — recommend passkey mandatory, public_link requiring a second-approver + loud audit.