What
The environment server can now run a named Cloudflare tunnel itself and keep it up. Other devices reach the environment at https://<hostname>, and nothing listens on a public address. This implements the runtime half of the named-tunnel decision (docs/decisions/cloudflare-tunnel.md, PR #201). There are two inputs, a tunnel token and its public hostname. The owner creates them today; managed provisioning creates them later.
Owner guide and full behaviour: docs/cloudflare-tunnel.md.
How it works
- Inputs.
- Hostname:
--tunnel-hostname/OPENMANAGER_TUNNEL_HOSTNAME. No tunnel without one. - Token:
OPENMANAGER_TUNNEL_TOKEN, or a token file (--tunnel-token-file, default<data-dir>/tunnel-token). The file is re-read before every connector start. - Binary:
--cloudflared/OPENMANAGER_CLOUDFLARED, else the firstcloudflaredonPATH.
- Hostname:
- Origin. The listener stays on
127.0.0.1. The hostname joins theHostallowlist automatically./local-ownerstill answers 404 through the tunnel. - Supervisor (
apps/server/src/tunnel.ts). After the server listens, it startscloudflared tunnel --config <data-dir>/cloudflared.yml --no-autoupdate --output json --loglevel info --metrics 127.0.0.1:0 --management-diagnostics=false run.- Readiness comes from the connector's
/ready. - The connector's JSON log gives the metrics port, a rejected tunnel, an unreadable token and configuration updates.
- Readiness comes from the connector's
- Arrival check. Once the connector is connected, and every 10 minutes after:
- The server sends a fresh single-use nonce to
https://<hostname>/tunnel-check?nonce=…, with no credential on the probe. - The tunnel is
connectedonly if this process's own listener receives that nonce. The response is not trusted, so a host that echoes the public/bootstrapfails. /tunnel-checkgives an empty 204 for a pending nonce on the tunnel host and the same 404 as an unknown path for anything else. Misses spend a separatetunnel_checkbudget.
- The server sends a fresh single-use nonce to
- Per-device budgets (
apps/server/src/budget-key.ts). Behind the tunnel every request comes from127.0.0.1.- For a loopback request whose
Hostis the tunnel hostname, theauth_failure(socket and uploads),pairingandtunnel_checkbudgets are keyed byCF-Connecting-IP, with IPv6 grouped by /64. - Without a usable header, the request goes into one tunnel bucket, kept apart from local traffic.
- The header is never identity.
- For a loopback request whose
- States.
starting,checking,connected,self_check_failed,down,binary_missing,token_missing,stopped, each with a typedreason.- They are logged on change, written to
<data-dir>/tunnel-status.json, and shown byservice status(andtunnelin--json). - A failing tunnel never stops the server.
- They are logged on change, written to
- Recovery.
- Crash: restart with backoff from 1 s doubling to 60 s, reset after a minute connected.
- Lost connections:
downafter two failed probes. Replaced after 60 s if cloudflared has not reconnected by itself. - Never connected after 90 s: replaced.
- Sleep: a late 5 s tick triggers an immediate probe and check, and checks begun before the sleep are discarded. A connector that says it is connected but gets Cloudflare's 530 on the first check after a wake is replaced once.
- Missing binary, token or writable config: retried every 60 s.
- Service install.
OPENMANAGER_TUNNEL_TOKENgoes to the token file as a new 0600 file renamed into place, never into the logon task XML or the systemd unit.- Install refuses without a token or without
cloudflared, and bakes in the absolutecloudflaredpath it found.
Design calls
- Token never on argv, never logged. It reaches
cloudflaredonly asTUNNEL_TOKEN, and there is no token flag. The server removesOPENMANAGER_TUNNEL_TOKENfrom its own environment after reading it, so provider CLIs, agents and terminals don't inherit it. Connector log lines containing it are redacted. A hand-saved token file is narrowed to 0600 on POSIX. - Connector environment is an allowlist.
- The child gets
TUNNEL_TOKENplus only what it needs for home and temp folders, proxies and certificates (PATH,HOME/USERPROFILE,TEMP,SystemRoot,HTTPS_PROXY,SSL_CERT_FILE,GODEBUG,LC_*, …). - Provider keys and every
TUNNEL_*setting stay out. - Log level is pinned to
info, because atdebugcloudflared logs request headers, and the socket credential travels in one.
- The child gets
- Pinned
--config. Without it cloudflared reads~/.cloudflared/config.yml. I checked with the real binary that a leftoverurl:there gets applied. The server writes its own file and never starts the connector without it. - Routing cannot be pinned. A token tunnel's ingress is pushed from the dashboard and replaces the local configuration: the connector reports local config as version
-1, and any pushed config has a higher version. So "local origin is the environment server only" is documented as the owner's responsibility in v1. The server reads/configback and warns (sharedIngress) when anything else is routed. This is not verified against a live dashboard tunnel. The decision record makes pinning a condition for managed provisioning. - No aggregate per-tunnel budget ceiling. It would bring back the shared lockout. A local process forging the header gains fresh budgets but cannot guess a credential.
- No protocol change. Reporting the tunnel route to clients (server-reported routes, or the
pairing.createreply, so a local browser makes links with the tunnel route) is a protocol change with client merge rules. Filed as a follow-up. - The connector dies with the server, however the server ends. A leftover connector answers every device with 502 and muddies the "can't reach the laptop" state, so nothing is allowed to outlive the server, and there is no "kill leftovers at the next start" backup. A normal stop ends it as before. A crash (uncaught exception, unhandled rejection,
process.exit) kills it synchronously from aprocess.on('exit')hook. On Windows a server killed outright (End task,Stop-Process -Force,taskkill /F) already takes it along: Node starts non-detached children inside a kill-on-close job object, so Windows endscloudflaredwhen the server process ends. Measured on the real binary, this covers a hand-started server, the logon-task supervisor andnode --watchalike. So no watchdog process is needed: it would add a second process, PID-reuse guards and restart pairing for a case the OS already handles. The spawn is commented never to bedetached, which is what would break it, and a test pins it. Left: on Linux, a foreground server killed withkill -9or aborted by the runtime leaves the connector (there is no job object; systemd ends it in service mode). Documented.
Verified
- Unit and integration tests (
apps/server/tests/tunnel.test.ts,budget-key.test.ts) use a fakecloudflaredselected through the path override. They cover:- start →
connected; - the token only in the child's env (not argv, not other variables) and redacted from logs;
- crash restart, readiness loss and recovery, a connector that stays down or never connects;
- an impostor echoing
/bootstrap(fails the arrival check), and the check route's 404/429 behaviour; - per-IP, per-/64, header-less and local budgets kept apart;
- missing binary, missing or malformed token picked up later, unwritable config;
- invalid and rejected tokens, shared-ingress warning and dashboard change;
- sleep with a stale connector, replacement during a pending check, stop during backoff.
- the connector ending with a server run as its own process, through a preload that hands it the fake: when the server crashes (all platforms), and when it is killed outright (Windows). Both tests were checked to fail when the connector is spawned
detached, the crash one also without the exit hook. - Service tests cover the token staying out of the task XML, install refusals and
statusoutput.
- start →
- Against the real
cloudflared2026.10.0 on Windows, with a throwaway quick tunnel run by hand and the server on port 8791 with a throwaway data dir and--allowed-host:Hostarrives as the public hostname; without--allowed-hostthe request gets 403./local-ownerthrough the tunnel: 404.- Unauthenticated and bad-credential upgrades get 401. An authenticated socket handshakes and stays open idle for 70 s with heartbeats every 15 s.
- Server killed: 502. Connector killed: 502, then 530.
/readyand/configshapes match what the supervisor reads.
- Supervisor with the real binary (no real token):
- an unknown tunnel gives
starting/tunnel_rejected; - a garbage token gives
down/token_invalid; - neither the command line nor the log holds the token;
--configis accepted;- stopping via
--exit-with-parentendscloudflaredand writesstopped.
- an unknown tunnel gives
- The connector dies with the server, real binary on Windows (server on port 8791, throwaway data dir, a well-formed fake token so
cloudflaredstays up retryingtunnel_rejected):Stop-Process -Id <server> -Force:cloudflaredgone 86 ms later; no child of the server left.- uncaught exception in the server (an
--importprobe that throws): server exits 1,cloudflaredgone 7 ms later. - before this change, the same check also passed for
taskkill /F(9 ms), the logon-task supervisor (--supervise, server force-killed: 35 ms) andnode --watch(132 ms): the "orphan" described earlier did not happen.
Waits for the domain / the owner
- A real
cloudflared tunnel runwith a dashboard token and hostname, end to end through the arrival check toconnected. That includes confirming that the pinned--configleaves the dashboard routing in charge. - Whether a dashboard tunnel's routing can be pinned locally (expected: no).
- Sleep/resume and network drop on the laptop with a real tunnel (needs the owner present).
service installwith a tunnel on the real logon task / systemd unit.
CI note
Validate desktop fails on two app-core tests (command-palette.test.tsx, sidebar-drafts.test.tsx) that expect the sidebar layout and theme list from before #200. main fails the same way since #200, and #203 has the fix. This branch touches no app-core code and will be rebased once #203 merges.
Follow-ups filed
- Report the tunnel route and status to authenticated clients (server-reported routes, admin status, the tunnel route in pairing links).
- Stop a device whose credential is refused from redialing forever (a browser sees a refused upgrade as 1006 and keeps retrying).
🤖 Generated with Claude Code
[High risk] Adds Cloudflare tunnel supervision and rate-limit keying.
The PR appears safe to merge; no blocking findings remain.
Summary
The server now runs and supervises a named Cloudflare tunnel. It checks that the public hostname reaches its own listener, keeps failed-attempt budgets separate by address, and reports tunnel status.
Diagram
Reviews (7) · Last reviewed commit: "docs(server): name the one case where th..." · Reviewed by Greptile