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:
- An expiry — after this moment the tunnel closes to visitors and stays closed until you explicitly re-open it (Re-arm 24 h, once it’s expired). Expiry always wins over the schedule below, whatever the schedule says.
- A weekly schedule — day-of-week + start/end time slots in a UTC offset you pick (a fixed
offset, not a named timezone — DST is yours to re-select twice a year, not something the edge
tracks for you). A slot whose end isn’t after its start wraps past midnight (
Fri 22:00 → 02:00is open Friday evening through Saturday early morning). A schedule with zero slots is the explicit “closed all week” state — different from having no schedule at all, which means no restriction.
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:
- An access list — add specific email addresses one at a time. Only a logged-in account whose email matches an entry gets through; everyone else lands on a “you’re not on the access list” page.
- Allow any signed-in account — a separate checkbox that, while on, waves through anyone who can complete the login regardless of email — the access list below it is ignored entirely for as long as this stays on. Useful when you’re gating on “has an account on this platform at all” rather than a specific roster, or when you bind identity to something else (your own origin, a downstream broker) once the request reaches you.
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.
Share links — let one person in without your login
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.
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).
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:
- removes the tunnel from the registry (so its routing token stops resolving),
- tells the edge to actively drop the live connection and refuse any re-registration attempt on that token,
- deletes the hostname’s DNS
Arecord, so nothing is left pointing at an address that no longer serves anything.
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.
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.
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.
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.
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.
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.
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.