CADS-Tunnel docs
How-to

Manage your tunnel from the portal

Everything below is the portal’s own dashboard, not ct-agent — no terminal needed for any of this.

View your tunnels

bunsenbrenner.org/portal/tunnels (after signing in) lists every tunnel you own, plus its hostname and current certificate tier (🔴/🟡/🟢 — see Certificate tiers explained). Standard tier gives you exactly one, auto-provisioned the moment your account exists — see Your first tunnel.

Each row also shows live status pulled directly from the edge, not just what the control plane’s own database last recorded: a 🟢 Connected / ⚪ Not connected badge, and — once at least one byte has actually moved — a ↓ received · ↑ sent line (human-scaled, e.g. 3.4 KB/1.2 GB, never more than one decimal past the first unit boundary). Both are best-effort: if the edge is unreachable, or this particular self-hosted deployment hasn’t configured its portal-to-edge admin connection at all, the badge and byte line are simply absent from that row rather than showing something misleading like “offline.” A tunnel that’s connected but has never actually relayed anything (e.g. right after ct-agent onboard, before any client has reached it) shows the Connected badge with no byte line yet — that’s expected, not a bug.

Install on a tunnel’s row takes you back to the same join-token page from onboarding — useful if you need to re-run setup on a second machine or after a full local reset (see Install ct-agent’s “starting over” section).

Connection history, uptime & a public status badge

Each tunnel row also has a Connection history disclosure — expand it for uptime over the last 24 hours / 7 days / 30 days and a table of recent sessions (start time UTC, duration or “open”, transport, bytes in/out, disconnect reason), newest first. Like the Connected badge above, this is fed live from the edge and simply says “no sessions recorded yet” rather than showing anything misleading when there’s no history to show.

For more room, each row also links to a dedicated Uptime & usage page (/portal/tunnels/:id/uptime) with the same three uptime windows, the longest outage in the last 30 days, 30-day session/byte totals, and the full session table (up to 200 rows — a tunnel that’s flapped more than that in 30 days under-counts its oldest sessions, noted on the page itself).

That page also has an opt-in public status badge: enabling it mints a shields-style SVG at https://bunsenbrenner.org/badge/<64-hex>.svg — green at ≥99% 7-day uptime, yellow at ≥95%, red below, grey “n/a” with no history yet — along with the URL and a ready-to-paste Markdown snippet. The badge is deliberately anonymous: no hostname, tunnel id, or routing token appears in it or its URL, and disabling it 404s the old link from the very next request. Owner-scoped like every other control on this page.

See usage across every tunnel you own

bunsenbrenner.org/portal/usage (“Usage” in the portal nav) is the account-wide view of the same 30-day figures — uptime, sessions, and bytes in/out for every tunnel you own, plus a totals row. A tunnel whose edge doesn’t answer shows “n/a” for that row rather than blocking the rest of the page. /portal/usage.csv exports the same table as a downloadable CSV (one row per tunnel, raw numbers) if you want it in a spreadsheet.

Dead-man alert — a webhook when a tunnel goes down

Each tunnel’s card has an alert block: a webhook URL and a threshold in minutes (1 minute to 7 days). A background check every minute asks the edge whether the tunnel is reachable; once it’s been down for longer than your threshold, the portal POSTs a signed tunnel.down to your webhook, then a tunnel.up the moment it recovers. There’s no new notification system behind this — the receiver is whatever you already run (a pager bridge, a chat webhook, your own script).

Saving the form shows your webhook secret once — copy it then, it’s never shown again. Every delivery carries X-CT-Timestamp (unix seconds) and X-CT-Signature: sha256=<hex>, an HMAC-SHA256 over the string "<X-CT-Timestamp>.<raw request body>" keyed with that secret — the same "<timestamp>.<body>" convention this platform’s own payment webhooks use inbound, so a Stripe-style verifier works unmodified. The JSON body:

{
  "event": "tunnel.down",
  "tunnel_id": "<portal tunnel id>",
  "name": "<tunnel display name>",
  "since": 1735689600,
  "threshold_secs": 300,
  "sent_at": 1735689600
}

event is "tunnel.down", "tunnel.up", or "tunnel.test" (from the Test button, which sends one immediately). since is when the current state began — the outage start for down, the recovery moment for up. A failed delivery retries twice more in the same check, 2s then 8s apart, and every attempt shows in the card’s last-5-deliveries log regardless of outcome. Deliveries are capped at 20 per account per hour — past that, a check is logged as “skipped” and the tunnel’s state doesn’t advance, so you won’t miss the eventual tunnel.down/tunnel.up once the budget frees up. Remove deletes the alert; webhook URLs must be https:// (plain http:// only to 127.0.0.1/localhost, for testing a local receiver).

Fleet view — every tunnel in one table

bunsenbrenner.org/portal/fleet (“Fleet” in the portal nav) is one row per tunnel you own: online state, transport + 7-day uptime, Agent bridge mode and sidecar presence, cached agent version, and readiness chips (things like “no registry”, “no login”, “no docker”, or “all ok”) from the last successful bridge probe. Nothing on this page dials your agent when it loads — the online/uptime/presence columns are the same fail-open edge lookups the tunnels page and the Agent bridges page already make, and the version/readiness columns come from whatever the last bridge/status/bridge/config call happened to cache; a never-probed agent shows “unknown”/”not probed” rather than “offline”. A Probe now button appears for any tunnel with a bridge grant — it’s the same bridge/config call the Agent bridges page’s own refresh button makes, just from here. A summary line at the top counts tunnels / online / bridges served / readiness gaps, and if more than one cached agent version shows up across your fleet, a “Version drift” hint calls it out (the edge doesn’t currently learn an agent’s version from its own registration — only from a probe — so this is necessarily best-effort, not a live inventory).

Access windows — auto-expiring or scheduled exposure

Each tunnel’s card has an “Access window” block for closing it off automatically instead of manually revoking and re-onboarding. Two independent pieces, either or both:

Enforcement happens locally at the edge, not via a per-request call back to the control plane — your policy is pushed to the edge the moment you save it, and rehydrated automatically if the edge itself ever restarts. Outside the window, a browser (Gelb) visitor gets a real 503 with a Retry-After header and a page naming when it reopens (or that no reopening is scheduled, for an expired policy with no schedule); a Grün/passthrough hostname’s TLS connection is simply closed after the handshake starts. Either way, your ct-agent stays connected the whole time — this closes the door to visitors, it doesn’t disconnect your tunnel.

Clear returns a tunnel to unrestricted (always open) — the default for every tunnel that’s never had a policy set.

Login gate — protect a tunnel behind a Keycloak login

Each tunnel’s card has a Require login checkbox: once on, every visitor to that Gelb (edge-terminated) hostname is sent through a login (the same Keycloak realm the portal itself uses — there’s no separate identity provider to set up per tunnel) before Caddy’s forward_auth lets the request through to your origin. One shared platform-wide login backend covers every tunnel; what’s per-tunnel is only who’s allowed in once they’ve logged in.

Two independent ways to control that, both below the checkbox once it’s on:

A visitor who hits the access-list wall isn’t just stuck: the refusal page has a Request access link that lets them leave their email and an optional note. That shows up on your card as a pending request with one-click Grant (adds them to the list and clears the request) or Dismiss — turns a dead end into something you can act on instead of a support email out of band.

Your origin sees the visitor’s verified email in the X-Gate-Email header — set by the platform after login, never by the visitor’s own request, so your origin can trust it without a second check. This only ever applies to Gelb hostnames; a Grün/passthrough hostname’s TLS terminates at your own ct-agent, which the login gate never sees — ct-agent local-auth link (CLI commands) is the agent-side way to admit a specific person to one of those instead.

Turning on the Agent bridge toggle below force-enables Require login in the same action — the bridge’s own admission always runs through this same gate, so the two can’t be out of sync.

This is a DIFFERENT feature from Sharing a tunnel — visible, not usable yet below, which is about full-account access grants and remains not exposed on any tier. Share links are a working, live feature today.

When your tunnel has Require login on (see Login gate above), a “Share links (N active)” block on the card lets you mint a URL that admits one visitor without them needing an account at all: a link good for 1 hour, 24 hours, or 7 days, optionally single-use, with an optional label so you remember who it’s for. Minting shows the full URL exactly once — copy it then. Opening it (GET /gate/share?host=...) sets a session cookie scoped to that one hostname only, valid for whatever’s left of the link’s TTL; a single-use link’s URL stops working after the first open, but the cookie it already set keeps the visitor’s session going until it expires. Revoke ends both the link and any live session it already granted, immediately. Up to 50 active links per tunnel.

This covers exactly what Require login covers: any Gelb (edge-terminated) hostname. It cannot share a Grün/passthrough hostname — see ct-agent local-auth link (CLI commands) for the agent-side equivalent that covers those instead.

Signed receipts — a tamper-evident record for your own audit trail

The Uptime & usage page has a Signed receipts section with a Download receipts link (/portal/tunnels/:id/receipts.jsonl). Every time your tunnel’s session opens, closes, or reaches an hourly byte-count checkpoint, the edge appends one receipt: a hash-chained, ed25519-signed record of that event’s metadata (never payload — the edge relays ciphertext it can’t read in the first place). Each receipt’s hash covers its own content plus the previous receipt’s hash, so removing, reordering, or editing anything in the middle of an exported chain breaks verification from that point on.

Precisely stated, because it matters for what you can and can't rely on this for: a verified chain proves that this edge attested these metadata events — session opens/closes, byte volumes, transports, close reasons — in this order, each stamped with the edge's own clock. It proves nothing about payload contents, nothing about wall-clock accuracy beyond what the edge itself believed, and nothing about events the edge never saw (an edge that's down emits nothing — silence is only evidence together with the surrounding receipts staying sequential, which the verifier does check).

Verify a downloaded file offline with the verify_receipts tool from ct-agent-tools:

verify_receipts receipts.jsonl

It checks the chain against the public key named in the file’s own header (or one you pass with --pubkey, if you don’t trust the file to name its own key), prints a summary (edge id, sequence range, time span, sessions, bytes), and exits 0 on a clean chain, 1 on a verification failure, 2 for a usage/file error.

Rename a tunnel

Each row has a Rename form — it only changes the display label shown here and in the portal’s other tunnel pickers (e.g. the topology editor’s tunnel dropdown), not the hostname or routing token, so renaming never breaks anything already pointing at your tunnel. Owner-scoped like every other action on this page; a blank name is rejected.

If your certificate offer lapses: automatic requeue, or stay on the shared certificate

When a tunnel is queued for its own Grün certificate and the 48-hour claim window closes before ct-agent certificate completes the order (see the admission queue), it no longer dead-ends — the tunnel is automatically requeued at the back of the line with a fresh position, no click needed (#758, live). Get ct-agent certificate running again (or restart it if it’s still running) before the next offer arrives, since requeuing doesn’t retry the ACME order for you.

Every non-offered Gelb row also has a checkbox: “Bleib dauerhaft auf dem gemeinsamen Zertifikat (kein eigenes Grün)”. Check it to opt out of the Grün queue entirely — your tunnel stays on the shared Gelb certificate indefinitely and is never reconsidered for its own Grün slot until you uncheck it again (owner-scoped like every other action on this page).

An older manual Erneut anfragen ("request again") button still appears for any tunnel that was already stuck in the pre-#758 lapsed state before this shipped — a one-time legacy case, not something a newly-lapsed claim reaches anymore. It's a no-op on any tunnel that isn't actually in that state.

Revoke a tunnel

The Revoke button on your tunnel’s row is a full, server-side teardown — not just a local reset. One click does all of the following:

There’s no confirmation dialog and no undo — a revoked tunnel needs a brand-new Install/onboard cycle to come back, with a new token. This is also the only way to fully retire a tunnel: killing your local ct-agent process, or clearing CT_AGENT_STATE_DIR, only ever touches your side (see Install ct-agent) — the tunnel keeps existing on the platform, ready to be reconnected to, until you Revoke it here.

Agent bridge — the registry toggle for real remote control

Each row also has an Agent bridge dropdown (off / ephemeral / permanent). Turning it on lists this tunnel on the portal’s Agent bridges page — permanent always shows it there (even offline), ephemeral only while the tunnel is actually connected. Turning it on also force-enables Require login in the same action, atomically — a bridge-listed tunnel is never reachable without an authenticated session.

Updated — this used to be registry-only ("lists you, doesn't do anything more"); the dialer behind it is now real and live. This toggle still only *lists* the tunnel — it doesn't by itself grant the portal access to your agent's channel. To actually make it callable: mint a grant from your own agent admitting the platform's bridge identity (`ct-agent channel grant`, `CT_GRANT_DIRECTION=initiate`) and paste the channel id + grant hex into the Agent bridges page itself, which shows the exact pubkey to grant and the paste form. Once granted, the portal can call the read-only tools (bridge/status, bridge/config, bridge/channel-members, bridge/allowlist-list, bridge/manifest-list) via one-click refresh buttons, and now also the mutating ones through real per-action controls: an email field for allow-list add/remove, and manifest-location/project-name fields for manifest install (an "Advanced" fallback with the original generic tool-call form is still there for anything not covered by a dedicated control). Manifest install can be disabled independently — see the callout below.
If every call fails with "closed stream" — the portal shows channel session error: closed stream and, at the same moment, your own channel --serve log shows serve session ended with error (#200): edge relay park expired with no partner within the park window (#21), with admission itself succeeding and the edge log showing no trace of the channel at all — that was #745, a bug in this dialer, not in your setup. It performed only the rendezvous hop on the broker port (:4435), whose contract is "ack, then close", and then tried to run the Noise session on that already-closed stream, while your relay-only agent had parked on the relay port (:4436) waiting for a relay join that never came. Since #749 (2026-09-03) the dialer performs the same two hops ct-agent's own relay-only initiator does — rendezvous on :4435, then a separate connection to the relay on :4436 that carries the session. If you still see exactly this pair of messages, the control plane you're talking to predates the fix. If the call instead pairs and then times out — the portal shows timed out after almost exactly 20 seconds, while your channel --serve log shows the session being accepted (plane-brokered Accept … persistent serve) and then nothing but its idle heartbeat — that was the second half of the same issue, fixed by #760 (2026-09-04). The dialer sent the JSON-RPC request into the Noise session without the length prefix your agent's request loop reads first, so the agent waited for a message that never completed and logged nothing. Again a dialer bug, not your setup, and again it takes effect with the control plane that carries the fix — no ct-agent update is needed. Nothing changes on your side: the relay-only acceptor from Set up a broker-mediated channel (CT_CHANNEL_RELAY_ONLY=1, CT_CHANNEL_SERVE=1, CT_CHANNEL_BROKER + CT_CHANNEL_RELAY) is exactly what the dialer expects. For a self-hosted control plane the dialer now needs a relay address as well as the broker: it uses CT_CHANNEL_RELAY (host:port, the same variable the installer already emits for ct-agent) if set, otherwise the broker's host on CT_CP_CHANNEL_RELAY_PORT (default 4436 — the port GET /network-info already reports). An unresolvable relay disables the dialer with a boot-time warning, same as an unresolvable broker; the boot log line Agent bridges dialer enabled (holder=…, broker=…, relay=…) shows which it picked. The hosted deployment needed no configuration change.
Easy to miss: granting the bridge into your channel is necessary but not sufficient. Your own channel --serve process only registers the bridge/* tools at all when it's started with CT_CHANNEL_BRIDGE_PEER set to the bridge's own Noise pubkey — a separate value from the holder pubkey used to grant. The Agent bridges page now publishes both ("This deployment's bridge holder pubkey" and "...Noise pubkey", each with its own copy button) — see [Environment variables (channels, cards, offers)](/reference/channel-environment-variables/) for what the variable does. Without it, every bridge tool call fails with "caller is not this agent's configured bridge peer" even though the grant itself is valid.
Opting out of just the manifest-install capability (ct-agent v0.7.23+): you don't have to accept manifest-install just because you've otherwise granted the bridge — set CT_CHANNEL_BRIDGE_DISABLE_MANIFEST_INSTALL on your own channel --serve process and it refuses bridge/manifest-install unconditionally, for every caller including the bridge itself, while leaving the rest of the tranche (status, config, channel members, allow-list, manifest listing) working normally.
Updated (CADS-Tunnel#763): a granted bridge card now checks whether your channel --serve sidecar is actually reachable at the edge before offering a call — it reads Sidecar: serving (seen N s ago) when it recently saw an admission, or Sidecar: not connected when it hasn't, and disables the call buttons and manifest form in the latter case (an "Advanced: call anyway" option still lets you force it). This replaces what used to be an always-on 45-second blocking dial that, if your sidecar wasn't actually running, just ended in a raw connection error.
Updated (CADS-Tunnel#763/ct-agent#164): results are now rendered, not raw JSON: bridge/config shows each feature's Feature / State / How-to-enable, naming the exact missing sidecar setting (registry URL, login, trust allow-list, work dir, docker, and so on); bridge/manifest-list ("Registry manifests") is a table with one inline **Install** form per entry instead of a location you'd copy by hand. A genuine error from your own agent (a malformed manifest, a disabled capability) now shows as "the agent refused the call" with a message and a targeted hint, instead of the older generic "malformed reply from peer". Raw JSON is still there for every tool, just behind a disclosure instead of being the only view.

Sharing a tunnel — visible, not usable yet

You’ll see a Share button next to Install/Revoke — it’s disabled. The portal shows it so you know the capability exists, but as of this writing it’s a planned paid-tier feature: Standard tier ships one tunnel per account with single-owner access, not shared access. The API surface behind it (GET/POST /portal/tunnels/:id/grants, POST /portal/tunnels/:id/grants/:grantee/delete) is already built and tested server-side — grants are keyed by the other account’s opaque OIDC subject (visible on their own Account page, not their email) — but there’s no tier that currently exposes a clickable path to it. If you’re testing against a self-hosted deployment without the tier gate, the routes work exactly as the UI’s disabled state implies they eventually will; on the hosted platform, treat this as “coming soon,” not “broken.”

Your account

Account in the top nav (/portal/account) shows your OIDC Subject (the opaque ID above), your internal Account ID, and your credit balance — plus a link out to your identity provider’s own Account Console for password changes, session review, or deleting your account entirely (none of that is reimplemented in CADS-Tunnel itself). Buying credits from this page starts the same admin-gated intent/webhook flow described in API endpoints’s billing section — the button just fills in your own account for you.

Found an error, or something that didn't work as documented? Open an issue →