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), theconnections(kind='access', provider='cloudflare')schema (schema-connections.sql:64,72), and thetunnel/web_sshcapability 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."
(c) Hybrid / phased — RECOMMENDED
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.
3. Architecture (recommended native path, end-to-end)
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:
- Operator hits
https://207.girard.hostssh.app. TLS terminates at our edge (not on the box). - Auth gate runs the security spine:
guard('access.ssh')(lib/access.ts:20-26, RBACPERMISSIONS['access.ssh']atrbac.ts:31) plus the per-session WebAuthn step-up specified inweb-ssh.md:51. Session is recorded to the hash-chained audit trail (audit.go, actionaccess.session). - Edge looks up the
exposuresrow for that hostname → resolvesfingerprint+local_addr. It opens a logical stream over the agent's already-open outbound mux and forwards the operator's HTTP(S)/SSH bytes. - Agent receives the stream and hands it to
internal/proxy(extended — see §5) which dials127.0.0.1:8000and 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-agnostic — claimJob/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:
| Endpoint | Purpose | Reuses |
|---|---|---|
exposeAction (server action in lib/platform/actions.ts) | enqueue expose job, pinned to a fingerprint | enqueueJob(defaultLicenseKey(), spec, {kind:'expose', fingerprint, deploymentId}) (actions.ts:25-44) — wire the currently-null fingerprint arg |
unexposeAction | tear down | same enqueueJob path |
GET /api/v1/fleet/access (NEW, GET, license-scoped) | list active exposures for the fleet view | mirrors fleet/servers shape (fleet/servers route) |
| Edge relay ingress (NEW, native only) | the public WSS endpoint the agent dials + per-hostname HTTPS vhost | net-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.
-
Promote the
sshstub to a real handler.cli.go:137-141returns exit 3 ("on the roadmap") today; it's already ingatedCommands(cli.go:84) and license-enforced (exit 12). Theexpose/unexposejob-kinds ride the same gate — add them to the gated set so an unlicensed box cannot open a tunnel. -
New
internal/accesspackage (mirrors thefleet/jobsclient shape). Holds the tunnel dialer and the reverse-proxy handler. Wired into a 4th goroutine incmdAgentalongside 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 theexposejob spec the agent claimed) and hands the stream to the proxy handler below.
- dials the control-plane edge with
-
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 aReverseProxy(stream io.ReadWriteCloser, localAddr string)that dials127.0.0.1:PORTand copies bytes both ways (a stdlibhttputil.ReverseProxyfor HTTP exposures, rawio.Copyfor web-SSH/TCP). Keep the existingValidHost/sanitizeRouterhardening (proxy.go:16-39) and add alocalAddrallow-list so a compromised edge can't pivot the agent to arbitraryhost: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 precedentmcpkit.HTTPHandleralready sets (mcpkit.go:178-255). -
Claim/heartbeat loop: no protocol change. The agent claims an
exposejob on the existing 15s job poll (cli.go:450-467,jobs.Claim→jobs.go:84-96),runJob/executeJob(cli.go:646-676) gets a newcase "expose"that registers thelocal_addr→hostnamemapping with the access goroutine and reportssucceededvia the existing/v1/jobs/{id}/status(jobs.go:101-105).unexposederegisters. Audit via the existingauditLoghook (cli.go:572-588). -
Phase-1 (cloudflared) variant: the
exposejobcaserunscloudflaredas a managed child process using thesessionTokenfrom 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):
- "Expose a service" button → drawer.
- Pick port / known-service. The drawer auto-detects on-box services from the agent: a probe over the existing local mux (
GET /healthzstyle on127.0.0.1:8765) reports listening ports; :8000 is labeled "Coolify Admin" (service_hintcoolify), app containers labeled by name. Operator picks one or types a custom port. - Choose auth.
passkey(WebAuthn step-up, default for admin surfaces),session(existing god-console cookie),ip_allow, orpublic_link(warned, audit-flagged). This setsexposures.auth_policy. - Operator clicks Expose securely →
exposeActionenqueues theexposejob pinned to the box's fingerprint. The drawer streams job logs (/v1/jobs/{id}/logs) and reflects status viareflectAccessStatus. - Live URL appears:
https://207.girard.hostssh.appwith a copy button and an Open button (passkey-gated). Status chip =active/degradedfrom the reflected job state + optional heartbeataccess_health.
For the Girard fleet this means: open .207's page → "Expose a service" → drawer shows Coolify Admin (:8000) pre-selected → choose Passkey → Expose securely → https://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 owner | HostSSH only | Cloudflare edge |
| Auth owner | Our security spine (RBAC + WebAuthn + audit) | Cloudflare Access (capped) |
| TLS / cert | Own ACME wildcard, DNS-01 | Cloudflare edge cert |
| Hostname | *.<tenant>.hostssh.app (ours) | ours via CF DNS |
| External deps eliminated | cloudflared binary, CF Tunnel control plane, CF Access seat cap — none remain in the path | DNS-01 still uses CLOUDFLARE_DNS_TOKEN (already a dep) |
| New infra we run | edge 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: addexpose/unexposekinds; extendJobSpec.expose(jobs.ts:14-25). - New
schema-access.sql→exposurestable; apply viapsql -f. -
lib/platform/actions.ts:exposeAction/unexposeAction(wire thefingerprintarg throughdispatch,actions.ts:25-44); gate on new RBAC permaccess.expose. - Agent
executeJob(cli.go:646-676):case "expose"runscloudflaredwithsessionToken; addexpose/unexposetogatedCommands(cli.go:84). - Control plane: provision CF Tunnel +
*.hostssh.appCNAME (setARecord,dns.ts:71) + CF Access policy; store token asconnections(kind='access',provider='cloudflare')+connection_credentials(key_custody='agent_local'). -
reflectAccessStatusinreflect.ts; backaccess/page.tsxwithexposures; 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-16gap) so sessions are off-box tamper-evident. -
access_healthon heartbeat (heartbeat/route.ts:19-32) +agentscolumns → live status onGET /v1/fleet/access. -
auth_policyenforcement (ip_allow,public_linkwarnings), revoke kill-switch viaunexpose.
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.appvia DNS-01. - New
agent/internal/access: tunnel dialer (yamux/WSS oversecure.Client,secure.go:102-138) as the 4th goroutine incmdAgent(cli.go:374-524); backoff/redial. -
agent/internal/proxy/reverse.go:ReverseProxy(stream, localAddr)withlocalAddrallow-list; keepValidHosthardening (proxy.go:16-39). - Flip
exposejobmodedefaultcloudflared→native; migrate live exposures; decommissioncloudflaredsidecars. - Web-SSH PTY over the same mux (interactive
runtime.Execreplacement —x/crypto/sshcert-only +creack/pty, theweb-ssh.mdtarget 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.hostnameis globally unique and namespaced<machine>.<tenant>.hostssh.app; the edge resolves hostname→org_id+fingerprint and must enforce that the authenticated operator'sorg_idmatches. Reuse theconnectionsRLS model (schema-connections.sql:281-308, GUC-keyedhostssh.org_id) andresource_neutralization.presented_hostnamefor 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-80verifies the ed25519 token viaVerifyToken+ fingerprint-binding before storing it; (2) transport hardening — all live agent→CP call sites route throughsecure.Client(mTLS+SPKI pin), with non-TLS rejected byRequireHTTPS(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/activatereturns{token, plan, features, expires}— NOT the{token, client_cert}promised inagent-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 — recommendpasskeymandatory,public_linkrequiring a second-approver + loud audit.