Playbook — Fleet Backup SSH via Cloudflare Tunnel
Status: ACTIVE Scope: Secondary SSH access path for every server in the fleet, independent of each server’s public IP.
What this is
Every server already has primary SSH via its public IP + key (docs/playbooks/server-ssh-connectivity.md).
This playbook adds a secondary, outbound-only path via a per-server Cloudflare Tunnel — useful
if a server’s public IP becomes unreachable (provider network issue, accidental firewall lockout,
IP change) without requiring any inbound port to be open for it. This is the same mechanism already
used for lap1 and wsl1 (hosts with no public IP at all), extended to the 6 servers that do have
one, purely as a backup.
This does not enable, change, or affect worker dispatch on any host. It is SSH access only —
no dev_r_server_capacity rows were touched for bms-1/2/3/4, vps-i1, or vps-h1. vps-i1 in
particular runs the production monitoring stack; this addition was scoped deliberately narrow
(install cloudflared, one systemd service, nothing else) to avoid any risk to it.
Hostname map
| Server | Primary (public IP) | Backup (CF Tunnel) | SSH user |
|---|---|---|---|
| bms-1 | 94.23.26.113 | bms1-ssh.zintegrowana.online | root |
| bms-2 | 145.239.133.104 | bms2-ssh.zintegrowana.online | root |
| bms-3 | 51.68.155.224 | bms3-ssh.zintegrowana.online | ubuntu |
| bms-4 | 54.36.123.110 | bms4-ssh.zintegrowana.online | ubuntu |
| vps-i1 | 217.154.82.162 | i1-ssh.zintegrowana.online | root |
| vps-h1 | 72.60.32.61 | h1-ssh.zintegrowana.online | root |
| lap1 | — (no public IP) | lap1-ssh.zintegrowana.online | root |
| wsl1 | — (no public IP) | wsl1-ssh.zintegrowana.online | root |
All hostnames are single-level subdomains of zintegrowana.online — not
X.tunnel.zintegrowana.online. This is load-bearing: the zone’s free Universal SSL wildcard
(*.zintegrowana.online) covers exactly one subdomain level. A two-level hostname
(X.tunnel.zintegrowana.online, the original naming used for wsl1/lap1 before 2026-08-11)
is outside that coverage — Cloudflare’s edge resets the TLS handshake before completing it, and
fixing that requires either a paid Advanced Certificate Pack or renaming to a single level. See
docs/environments/wsl1.md §Resolved Issues for the full diagnosis. Never add a new CF Tunnel
hostname under a grouping subdomain in this zone — always <name>.zintegrowana.online directly.
What was provisioned per server (2026-08-11)
cloudflaredinstalled (.debon Ubuntu hosts,.rpmon vps-i1/AlmaLinux — vps-i1 already had a stale 2026.3.0 binary from an earlier, unrelated setup; upgraded to 2026.7.3 to match)- A dedicated Cloudflare Tunnel per server (
config_src: cloudflare, remotely-managed), ingressservice: ssh://localhost:22— confirmedstatus: healthyfor all 8 (6 new + lap1 + wsl1) - DNS CNAME
<hostname>→<tunnel-id>.cfargotunnel.com(proxied) - A Cloudflare Access application (type
ssh) + policy<name>-owner-only(allowradieu@gmail.com) per hostname — first connection from any client machine needs a one-time browser login (email OTP), cached for 24h afterward. This is normal Access behavior. cloudflaredinstalled as a systemd service via connector token (cloudflared service install <token>)- Connector run-tokens stored in
secrets/administration.env.sopsasCF_TUNNEL_<LABEL>_TOKEN(CF_TUNNEL_BMS1_TOKEN…CF_TUNNEL_H1_TOKEN), same pattern as the existingCF_TUNNEL_LAP1_TOKEN/CF_TUNNEL_WSL1_TOKEN - SSH config aliases on the dev workstation (
C:\Users\konar\.ssh\config):bms1-tunnel,bms2-tunnel,bms3-tunnel,bms4-tunnel,i1-tunnel,h1-tunnel(existingwsl1/lap1aliases unchanged) — eachProxyCommand cloudflared access ssh --hostname %h
Using the backup channel
ssh bms4-tunnel # instead of ssh bms-4 / ssh root@54.36.123.110
ssh i1-tunnelFirst use per client machine triggers a Cloudflare Access browser login (email OTP to
radieu@gmail.com) — complete it once, cached for 24h.
Verifying a tunnel is healthy
$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
# retrieve CF_GLOBAL_API_KEY via the safe extraction pattern, then:
Invoke-RestMethod -Uri "https://api.cloudflare.com/client/v4/accounts/acc5b2c956dd488265003364e2c7f84a/cfd_tunnel?is_deleted=false" `
-Headers @{"X-Auth-Email"="radieu@gmail.com"; "X-Auth-Key"=$globalKey} |
Select-Object -ExpandProperty result | Where-Object { $_.name -match "-ssh$" } |
Select-Object name, statusOr directly on the server: systemctl is-active cloudflared (expect active).
Known gotcha — one alias at a time only (org-level token lock)
cloudflared.exe’s Access token cache (%USERPROFILE%\.cloudflared\) uses a single shared
org-level lock file (p24-infra-team.cloudflareaccess.com-org-token.lock), not a per-hostname
lock. Consequences, found 2026-08-11 while validating this rollout:
- Running
ssh <label>-tunnelfor a second alias while a first alias’s session/login is still active hangs or fails withwebsocket: bad handshake— the second process blocks on (or loses a race for) the shared lock. - Force-killing
cloudflared.exe(Stop-Process -Force) mid-operation can leave orphaned*-token.lockfiles behind, and in one case deleted the actual*-tokenfile while leaving its.lock/.urlsentinels — silently breaking a previously-working alias (wsl1regressed this way after unrelated batch operations on the other 6 hosts).
Fix / working pattern: use one alias at a time. Close the SSH session (exit) before starting
the next ssh <label>-tunnel. If something looks stuck, clear the whole cache and start fresh
rather than half-fixing it:
Get-Process cloudflared -ErrorAction SilentlyContinue | Stop-Process -Force
Remove-Item "$env:USERPROFILE\.cloudflared\*-token*" -Force -ErrorAction SilentlyContinue
# then retry ssh <label>-tunnel — triggers a fresh browser loginKnown gotcha — mid-session CF token 401/403 → check for a concurrent rotation FIRST (#7005)
Provisioning steps here decrypt CF_API_TOKEN (secrets/monitoring.env.sops) via the
sops-invoke.ps1 broker and make a burst of CF API calls (verify → list tunnels → list Access
apps/service-tokens → create). If the first few calls succeed and then every call — including
/user/tokens/verify — flips to 403/401, the most likely cause is not a WAF/bot heuristic or
a per-token IP allowlist. It is a concurrent incident-rotation deleting the token out from under
your session:
CF_API_TOKEN is a Tier-1 auto-rotating credential (rotate_cf_api_token in
scripts/rotate-credentials.py, run by .github/workflows/credential-rotation.yml). A rotation
mints the new token and DELETES the old one at Cloudflare via API immediately, then opens a
separate PR to commit the new value to secrets/monitoring.env.sops. So there is a window where:
- the old token is already dead at Cloudflare (every call 401/403), but
secrets/monitoring.env.sopsonmainstill holds the old value — until the auto-rotate PR merges andsecrets-sync.ymlredistributes.
Any consumer that decrypted the token before the rotation keeps using the now-deleted value.
This is exactly what happened to lap2 on 2026-09-14 (#7005): the #7002 exposure triggered a
rotation at 14:18:13Z, the GH Secret was rewritten at 14:18:46Z, and the lap2 failure was filed
at 14:23:49Z — ~5 min after the old token was killed. Checking git log for a SOPS file commit
in the session window does not rule this out, because the token dies at rotation time while the
SOPS commit lands later (auto-rotate PR #7004 was still open at that point).
Triage order when a CF token 401/403s mid-session:
# 1. Was there a rotation run around your failure window?
gh run list --repo radieu/p24-infra --workflow credential-rotation.yml --limit 5 \
--json createdAt,conclusion --jq '.[] | "\(.createdAt) \(.conclusion)"'
# 2. Is an auto-rotate SOPS PR still open (new value not yet on main)?
gh pr list --repo radieu/p24-infra --search 'auto-rotate credentials in:title' --state open \
--json number,title,createdAt
# 3. GH Secret timestamp (metadata only) — did the value change under you?
gh secret list --repo radieu/p24-infra | grep '^CF_API_TOKEN'Fix: do NOT open a CF-dashboard investigation or add an IP allowlist. Wait for the auto-rotate PR
to merge and secrets-sync.yml to redistribute, then re-decrypt CF_API_TOKEN fresh (via the
sops-invoke.ps1 broker) and retry. The token is healthy; you were holding a stale value. Only if
steps 1–3 show no concurrent rotation should you suspect a genuine WAF/IP-filter block and
escalate to the CF dashboard.
Dispatcher-side pinned cloudflared (bms-4) — cloudflared#1673 regression workaround (#7072)
The queue dispatcher (scripts/queue-dispatcher-loop.py) reaches CF-Tunnel-backed hosts
(windows-dev, wsl1, lap1, lap2) by wrapping SSH in a cloudflared access ssh --hostname %h
ProxyCommand and authenticating non-interactively with a CF Access Service Token (the
tunnel’s non_identity policy). cloudflared 2026.6.0+ silently ignores the Service Token for
access ssh (upstream cloudflare/cloudflared#1673) — the connection fails at the Access layer
with websocket: bad handshake before SSH auth even starts. bms-4’s system cloudflared
(/usr/bin/cloudflared, 2026.7.x) is on the broken side of that regression.
The dispatcher therefore pins a known-good older binary via DISPATCHER_CF_TUNNEL_PROXYCOMMAND
(in secrets/n8n-bms4.env.sops, deployed to /opt/p24-infra/bms-4/.env), which points at:
/opt/p24-infra/bin/cloudflared-2026.5.1 # sha256 3c6a5ba995a258dbe90f98e5fdb2c2620b7be72c3ca761614f6eb52aee252cea
If that binary is missing the ProxyCommand fails with No such file or directory →
kex_exchange_identification: Connection closed by remote host, and every automated
SSH-through-tunnel dispatch breaks (root cause of #7072 — the pin was referenced by the env var but
never actually placed on bms-4). This is not a per-host lap2 problem: the env var is shared config
for all CF-Tunnel dispatch targets.
Restore / verify the pin (idempotent, safe to re-run):
# no-op when already correct; downloads + checksum-verifies + installs the official release if not
/opt/p24-infra/scripts/ensure-dispatcher-cloudflared-pin.sh
/opt/p24-infra/scripts/ensure-dispatcher-cloudflared-pin.sh --check # verify only, exit 1 if bad/opt/p24-infra/bin/ is group-writable by claude-runner (setgid), so the dispatcher’s worker
accounts can restore it without root.
Self-healing mechanisms (#7898) — the pin now survives across all deploy and restart paths:
| Trigger | Mechanism | Behaviour on failure |
|---|---|---|
Every p24-queue-dispatcher.service start | ExecStartPre=-/opt/p24-infra/scripts/ensure-dispatcher-cloudflared-pin.sh (the - prefix makes it non-blocking) | Dispatcher still starts; missing-binary Discord alert fires from queue-dispatcher.sh |
Every scripts/** deploy to bms-4 | deploy-vps-scripts.yml runs ensure-dispatcher-cloudflared-pin.sh after git reset --hard origin/main | CI step fails if download fails (non-best-effort — pin must survive scripts deploys) |
Every Ansible bms4-workers role apply | ansible/roles/bms4-workers/tasks/main.yml runs the script with failed_when: rc != 0 | Ansible apply fails if binary cannot be restored (provisioning gate) |
| Pin missing after ExecStartPre | queue-dispatcher.sh section 1a checks DISPATCHER_CF_TUNNEL_PROXYCOMMAND’s first word | Discord alert via P24_DISCORD_INFRA_SCRIPTS_ERRORS_WEBHOOK_URL, rate-limited to once per hour (sentinel /tmp/.dispatcher-cf-pin-alert) |
Quick confidence check that the binary gap is closed (does not need the Service Token):
timeout 25 /opt/p24-infra/bin/cloudflared-2026.5.1 access ssh \
--hostname lap2-ssh.zintegrowana.online < /dev/null 2>&1 | tail -1
# GOOD: reaches the Access layer (e.g. `websocket: bad handshake` when no token is present).
# BAD: `No such file or directory` — the pin is missing again; run the restore script.Full end-to-end SSH-through-tunnel to a given host ALSO requires that host’s CF Access Service Token to be deployed into the dispatcher env. For lap2 the keys are
CF_ACCESS_LAP2_CLIENT_ID/CF_ACCESS_LAP2_CLIENT_SECRET(secrets/n8n-bms4.env.sops). Until those land onmainandsecrets-sync.ymlredistributes them to/opt/p24-infra/bms-4/.env, the probe above stays atwebsocket: bad handshakeeven with the pin present — that is the token-not-deployed state, not the binary state. (lap2’s token was provisioned on the still-open PR #7071 at the time of #7072; see that PR +docs/adr/006-wsl-worker-host-class.md.) Note also that_cf_tunnel_service_token_env()currently resolves a single global token-var name (CF_ACCESS_SERVICE_TOKEN_ID_VAR, defaultCF_ACCESS_WINDOWS_DEV_CLIENT_ID) — per-host token selection for lap2 is tracked with the lap2 onboarding work, not here.
Escalation
If a server’s primary public IP fails and the CF Tunnel backup is also unreachable, fall back to
the provider console (see docs/playbooks/p24-network-admin.md §Emergency Procedures — IONOS /
Hostinger / OVH KVM per server).
Related
docs/environments/wsl1.md§Resolved Issues — the SSL-coverage bug this rollout avoided by designdocs/environments/lap1.md§Remote Access — the original single-host precedentdocs/playbooks/p24-network-admin.md— CF Tunnel operational patternsdocs/playbooks/server-ssh-connectivity.md— primary (public IP) SSH access