#202

Run and supervise a named Cloudflare tunnel from the environment server

Opencalledforthwants to merge intofrom

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 first cloudflared on PATH.
  • Origin. The listener stays on 127.0.0.1. The hostname joins the Host allowlist automatically. /local-owner still answers 404 through the tunnel.
  • Supervisor (apps/server/src/tunnel.ts). After the server listens, it starts cloudflared 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.
  • 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 connected only if this process's own listener receives that nonce. The response is not trusted, so a host that echoes the public /bootstrap fails.
    • /tunnel-check gives 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 separate tunnel_check budget.
  • Per-device budgets (apps/server/src/budget-key.ts). Behind the tunnel every request comes from 127.0.0.1.
    • For a loopback request whose Host is the tunnel hostname, the auth_failure (socket and uploads), pairing and tunnel_check budgets are keyed by CF-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.
  • States. starting, checking, connected, self_check_failed, down, binary_missing, token_missing, stopped, each with a typed reason.
    • They are logged on change, written to <data-dir>/tunnel-status.json, and shown by service status (and tunnel in --json).
    • A failing tunnel never stops the server.
  • Recovery.
    • Crash: restart with backoff from 1 s doubling to 60 s, reset after a minute connected.
    • Lost connections: down after 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_TOKEN goes 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 absolute cloudflared path it found.

Design calls

  • Token never on argv, never logged. It reaches cloudflared only as TUNNEL_TOKEN, and there is no token flag. The server removes OPENMANAGER_TUNNEL_TOKEN from 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_TOKEN plus 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 at debug cloudflared logs request headers, and the socket credential travels in one.
  • Pinned --config. Without it cloudflared reads ~/.cloudflared/config.yml. I checked with the real binary that a leftover url: 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 /config back 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.create reply, 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 a process.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 ends cloudflared when the server process ends. Measured on the real binary, this covers a hand-started server, the logon-task supervisor and node --watch alike. 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 be detached, which is what would break it, and a test pins it. Left: on Linux, a foreground server killed with kill -9 or 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 fake cloudflared selected 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 status output.
  • Against the real cloudflared 2026.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:
    • Host arrives as the public hostname; without --allowed-host the request gets 403.
    • /local-owner through 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.
    • /ready and /config shapes 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;
    • --config is accepted;
    • stopping via --exit-with-parent ends cloudflared and writes stopped.
  • The connector dies with the server, real binary on Windows (server on port 8791, throwaway data dir, a well-formed fake token so cloudflared stays up retrying tunnel_rejected):
    • Stop-Process -Id <server> -Force: cloudflared gone 86 ms later; no child of the server left.
    • uncaught exception in the server (an --import probe that throws): server exits 1, cloudflared gone 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) and node --watch (132 ms): the "orphan" described earlier did not happen.

Waits for the domain / the owner

  • A real cloudflared tunnel run with a dashboard token and hostname, end to end through the arrival check to connected. That includes confirming that the pinned --config leaves 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 install with 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

Activity

33
greptile-apps[bot]24d ago


[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.

  • Since the last review, only a test comment and shutdown documentation changed. No executable code changed.
  • All six earlier findings are addressed in the current code: private token replacement, checks after connector replacement, absolute binary paths, refreshed routing warnings, required configuration writes, and accepting valid check nonces before applying rate limits.
  • No new actionable issues found.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Server listens on loopback] --> B[Start cloudflared with token in environment]
  B --> C[Probe connector readiness]
  C --> D[Send nonce to public hostname]
  D --> E{Nonce reaches this listener?}
  E -->|Yes| F[Report connected]
  E -->|No| G[Report failed check and retry]
  C --> H[Restart exited or stale connector]
  H --> B
  F --> D

Reviews (7) · Last reviewed commit: "docs(server): name the one case where th..." · Reviewed by Greptile

greptile-apps[bot]reviewed24d ago
greptile-apps[bot]reviewed24d ago
greptile-apps[bot]reviewed24d ago
greptile-apps[bot]reviewed24d ago
greptile-apps[bot]reviewed24d ago
calledforthreviewed24d ago
calledforthreviewed24d ago
greptile-apps[bot]reviewed23d ago
greptile-apps[bot]reviewed23d ago
calledforthreviewed23d ago
calledforthreviewed23d ago