Claude ↔ NAS Channel — How It Works, How to Revoke It¶
Operational note. This documents the channel Claude uses to read, write, and execute on the Synology NAS (
Rooftop, DSM 7) from a Claude Code Web session. Set up during T-053a (GCP billing statements backfill); kept long-term so future operators can audit, extend, or tear it down.
Why this exists¶
Claude Code Web sessions run in an Anthropic-managed sandbox with a deliberately narrow egress policy:
- Only outbound HTTPS (:443) leaves the sandbox. Direct SSH (:22), WireGuard, OpenVPN, and other non-HTTPS ports are firewall-blocked.
- All outbound HTTPS goes through Anthropic's "Egress Gateway" — a TLS-MITM proxy. Standard HTTPS passes cleanly, but custom transport protocols don't. This rules out Tailscale (its control-plane noise handshake gets rejected) and rules out anything that needs an L3 tunnel.
- Custom MCP connectors are not supported in Claude Code Web (the feature exists in Claude.ai chat and Claude Code CLI, but Anthropic closed the feature request for Code Web as "not planned" — anthropics/claude-code#22726).
- The NAS sits behind CGNAT (the network's WAN is a mobile/4G modem with a private 10.x WAN address), so inbound port forwarding from the public internet to the NAS is impossible.
The only path that survives all four constraints is an outbound-only HTTPS tunnel from the NAS to a public edge, exposing a normal public HTTPS URL the sandbox can hit through its MITM proxy. Cloudflare Tunnel fits exactly.
Architecture¶
┌──────────────────────────────────────┐ ┌────────────────────────┐
│ Claude Code Web sandbox │ │ Cloudflare edge │
│ (Anthropic-managed, root, no SSH) │ │ (public HTTPS) │
│ │ HTTPS:443 │ │
│ python urllib / curl / requests │ ─────────────► │ nas.theestablishers.com│
│ ▲ │ │ (DNS: CNAME → tunnel) │
│ │ DSM Web API (JSON over HTTPS) │ └──────────┬─────────────┘
└──┼───────────────────────────────────┘ │
│ │ encrypted
│ all session creds + URL in env var DSM │ outbound-only
│ │ QUIC/HTTP2
▼ │
┌──────────────────────────────────────────────────────────────────┴──────────┐
│ Synology DS723+ "Rooftop" (LAN 192.168.68.64, no public IP, CGNAT) │
│ │
│ cloudflared docker container ◄────────────────────────── opens connection │
│ • tunnel name: roofkeeper-nas │
│ • TUNNEL_TOKEN env (Cloudflare-issued) │
│ • TUNNEL_TRANSPORT_PROTOCOL=http2 (QUIC was flapping on small UDP buf) │
│ • --network host (so localhost = NAS host) │
│ • auto-restart on │
│ │
│ Routes (Cloudflare → cloudflared → here): │
│ nas.theestablishers.com → http://localhost:5000 (DSM Web UI + Web API) │
│ │
│ DSM Web API at /webapi/* — used for: │
│ • SYNO.API.Auth (session sid + SynoToken CSRF) │
│ • SYNO.FileStation.{List, Upload, Download, Delete} (file ops) │
│ • SYNO.Docker.{Container, Image} (read-only inspection) │
│ • SYNO.Core.TaskScheduler — list/get/run, but NOT create/set/delete │
│ (Synology hardens those endpoints — Web API can't author tasks even │
│ as admin; only triggering pre-existing ones is allowed.) │
│ │
│ User "Claude" — admin group, no 2FA. Password rotates as needed. │
│ │
│ ┌────────────────────────────── Root exec channel ──────────────────────┐ │
│ │ Task Scheduler id=4 "t053a-gcp-statements-backfill" (owner=root) │ │
│ │ Script: /volume1/docker/workspace-billing/run-gcp-statements.sh │ │
│ │ │ │
│ │ That script has a dispatcher branch at the top: │ │
│ │ if [ -f /volume1/docker/workspace-billing/_cmd ]; then │ │
│ │ sh /volume1/docker/workspace-billing/_cmd > _cmd.out 2>&1 │ │
│ │ rm -f _cmd; exit 0 │ │
│ │ fi │ │
│ │ # ... falls through to the actual backfill ... │ │
│ │ │ │
│ │ Flow: │ │
│ │ 1. Claude PUTs a shell script to .../workspace-billing/_cmd │ │
│ │ via FileStation.Upload. │ │
│ │ 2. Claude triggers task id=4 via TaskScheduler.run. │ │
│ │ 3. DSM Task Scheduler runs run-gcp-statements.sh as root. │ │
│ │ 4. Dispatcher executes _cmd, captures stdout+stderr+rc in _cmd.out, │ │
│ │ deletes _cmd, exits. │ │
│ │ 5. Claude polls .../workspace-billing/_cmd.out via FileStation. │ │
│ │ Download until it contains "=== rc=". │ │
│ └───────────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────────┘
What this gives Claude, concretely¶
Claude can do anything a root shell on the NAS can do, by writing
a _cmd file and triggering task id=4. In practice that has meant:
- Read/write any file (FileStation API direct, no
_cmdneeded) docker build,docker run,docker images, container inspection- Execute arbitrary scripts as root (via
_cmd) - Read DSM logs, Docker container logs, image metadata
- Trigger any pre-existing scheduled task (e.g. nightly scrapes)
Claude cannot:
- Create new scheduled tasks (DSM platform-level block — error 105 on
SYNO.Core.TaskScheduler.create/set/delete, regardless of admin rights). Workaround: pre-create the task once in the DSM UI, Claude triggers it forever after viarun. - Interact with the NAS over anything except HTTPS:443. No SMB, no AFP, no SSH (and not because of the NAS — because of the Anthropic sandbox's egress).
Authentication: where the credentials live¶
The DSM credentials + URL are passed to the Claude Code session as an
environment variable named DSM, formatted as a single string:
(The URL fragment is legacy from the QuickConnect attempt — only the
Claude username + password matter today; the real URL is
https://nas.theestablishers.com/ and Claude has it hard-coded.)
The session has no other credential to the NAS. Rotating Claude's
password in DSM Control Panel and updating the env var rotates Claude
out of the NAS in one step.
How to revoke access¶
In priority order — pick the first one that's enough for your need:
- Stop the cloudflared container (DSM → Container Manager →
cloudflare-cloudflared→ Stop). The tunnel closes within seconds;nas.theestablishers.comstarts returning Cloudflare error 1033/530. Claude immediately loses all NAS access — file, API, root-exec. - Delete the Cloudflare Tunnel (Cloudflare Zero Trust → Networks
→ Tunnels →
roofkeeper-nas→ Delete). Permanent equivalent of (1) — also revokes the tunnel token so a stopped container can't reconnect. - Disable the
Claudeuser (DSM Control Panel → User & Group → Claude → uncheck "Allow this user to log on"). Tunnel stays up (e.g. for other tooling) but Claude's auth fails. - Rotate
Claude's password — same effect as (3) but lets you re-enable later by sharing a new password. - Disable the root-exec channel only (keep file ops):
- Delete
/volume1/docker/workspace-billing/run-gcp-statements.sh(Claude can re-upload it, but Claude can also no longer trigger it via id=4 because the script wouldn't exist), OR - Edit
run-gcp-statements.shto drop the dispatcher branch at the top and keep only the T-053a backfill body. - Disable Task Scheduler id=4 (uncheck "Enabled" in the UI).
- Disable cloudflared auto-restart (Container Manager → edit → General → uncheck Enable auto-restart) — so a one-off stop sticks across reboots without container-level deletion.
How to audit what Claude has done¶
- Cloudflare: Zero Trust → Logs → Access shows every request that
hit
nas.theestablishers.com(timestamp, source IP, path, status). - DSM: Control Panel → Log Center shows every DSM API
authentication and file operation under user
Claude. - Task Scheduler: id=4 task's run history (right-click → View Result) — but currently empty because the task doesn't have a save folder configured. The dispatcher mitigates this by capturing each invocation's command + output into the readable share path (see below).
- The dispatcher itself:
/volume1/docker/workspace-billing/_cmd.outalways contains the most recent_cmdrun's full output. Prior runs are overwritten — set up acron-side append to~/_cmd-history.logif you want a durable trail.
Files involved on the NAS¶
| Path | Role |
|---|---|
/volume1/docker/workspace-billing/run-gcp-statements.sh |
The dispatcher + the real T-053a backfill body |
/volume1/docker/workspace-billing/_cmd |
Transient — script Claude wants to run as root. Deleted on each invocation. |
/volume1/docker/workspace-billing/_cmd.out |
Output of the most recent _cmd execution (stdout+stderr+rc) |
/volume1/docker/workspace-billing/T053A_GO_LIVE |
Sentinel for T-053a only — gates LIVE-vs-DRY of the actual backfill body (not the dispatcher). |
/volume1/docker/workspace-csv-scraper/ |
Scraper Docker build context (Dockerfile + .mjs). Claude rebuilds via docker build issued through _cmd. |
Files involved on the repo¶
| Path | Role |
|---|---|
docs/eop-tasks/runbooks/claude-nas-channel.md |
This file |
docs/eop-tasks/runbooks/T-053a-gcp-statements.md |
The original T-053a runbook (assumes manual NAS deployment; superseded by the automated path described here, but kept for context). |
services/workspace-csv-scraper/gcp-invoices.mjs |
Source-of-truth for the scraper. Claude keeps the NAS copy in sync via FileStation; manual rebuilds from nightly should produce the same image. |
Known issues and limitations¶
- QUIC instability. Cloudflared defaults to QUIC over UDP, but
Synology has small default UDP receive buffers. Symptom: the tunnel
flaps every minute or two, my requests get 502s/530s in bursts.
Mitigation:
TUNNEL_TRANSPORT_PROTOCOL=http2env var on the container forces TCP-based HTTP/2 transport, which is rock-solid. This is set already; don't remove it unless you've bumped/proc/sys/net/core/{r,w}mem_max. - DSM Web API: TaskScheduler write blocked. As above — pre-create tasks in the UI; Claude can only trigger them.
- TLS to DSM is plain HTTP loopback. The tunnel terminates at
http://localhost:5000(not 5001/HTTPS). Cloudflare's published hostname leg is fully HTTPS; the loopback leg runs on the same machine as cloudflared so there's no over-the-wire exposure. If you re-enable "Automatically redirect HTTP connections to HTTPS for DSM desktop" (DSM → Login Portal), this breaks — keep that off. - Single-host channel. Everything described here is specific to
the
RooftopNAS. Reproducing on another host = new tunnel + new hostname + new user; the code paths are general (the helpers in the session use only standard DSM Web API).
Setting this up from scratch (if it's ever blown away)¶
- DSM: Create user
Claude(admins group, 2FA off, restricted to the shares Claude needs). - DSM: Login Portal → ensure "Automatically redirect HTTP → HTTPS"
is off for DSM desktop. DSM should serve plain HTTP on
:5000. - DSM: Task Scheduler → Create the dispatcher task (owner=root, enabled, schedule=past date so it never auto-runs), script field: Note the task id (the number shown in the URL or in the API list).
- DSM: Create
/volume1/docker/workspace-billing/run-gcp-statements.shcontaining the dispatcher branch at the top (see Architecture section above) followed by whatever real backfill body you want;chmod +x. - Cloudflare: Zero Trust → Networks → Tunnels → Create tunnel
named e.g.
roofkeeper-nas. Copy theTUNNEL_TOKEN. - DSM: Container Manager → pull
cloudflare/cloudflared:latest→ create containercloudflare-cloudflared: - Network: same as Docker host
- Auto-restart: on
- Env:
TUNNEL_TOKEN=<…>,TUNNEL_TRANSPORT_PROTOCOL=http2 - Execution Command:
tunnel --no-autoupdate run - Cloudflare: Tunnel → Public Hostname → add:
- Subdomain:
nas· Domain:theestablishers.com - Service:
http://localhost:5000 - Browser smoke test:
https://nas.theestablishers.com/→ DSM login page with valid TLS. - Hand the session: set env var
DSMto<anything-the-session-already-knows>, account=Claude, password=<pwd>and update the new-session bootstrap to usehttps://nas.theestablishers.com/as the API base.
Honest security note¶
The combination of (a) admin user (b) the root-exec dispatcher
(c) the publicly-routable Cloudflare hostname means anyone holding
the session's DSM env var has root on the NAS via standard HTTPS.
That's the capability the owner explicitly asked for; treat it as
sensitive accordingly. Revocation is one button per the section above.
If at any point you want to demote this to "Claude can read/write
files but cannot execute arbitrary commands," delete the dispatcher
branch from run-gcp-statements.sh (the file ops still work via
FileStation, but _cmd becomes a no-op file the dispatcher never
acts on).