{"uuid": "8ce06223-1877-4d20-9785-6c16a2e49255", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "GHSA-g7r4-m6w7-qqqr", "type": "seen", "source": "https://gist.github.com/xxdesmus/73ee08aabd15760b60f285cb2b32453f", "content": "# Sync \u2014 rsync-over-Cloudflare for non-technical users\n\nA resumable, rsync-backed file sync that a non-technical user drives from a web\nbutton. Cloudflare Workers + Durable Objects are the control plane and web UI;\n`cloudflared` is the transport; a one-time-installed local agent runs `rsync`.\nThe user's entire experience after install: open a URL, click **Resume**.\n\n---\n\n## 1. Goals &amp; non-goals\n\n**Goals**\n- Non-technical user syncs large trees between two of their own machines over\n  the open internet with no port forwarding, no SSH key juggling, no CLI.\n- Resume after network drop, crash, or power cycle \u2014 re-click and continue.\n- All of rsync's benefits underneath: delta transfer, `--partial`, `--append`,\n  `--info=progress2`, permissions/links/xattrs.\n- One-time device pairing via a short-lived token; ongoing auth via Cloudflare\n  Access (browser) and a long-lived device token (agent).\n\n**Non-goals**\n- Multi-tenant SaaS beyond email-keyed per-user isolation. (Add when 2nd user\n  type appears.)\n- Sharing between unrelated users.\n- Mobile agent.\n- Replacing rsync's transport. rsync stays rsync; we wrap it.\n\n---\n\n## 2. Architecture\n\n```\nbrowser (Access-authed) \u2500\u2500HTTPS/WS\u2500\u2500&gt; Worker \u2500\u2500&gt; Room DO (per user email)\n                                          \u2502\n                cloudflared \u2500\u2500WS\u2500\u2500&gt; Room DO &lt;\u2500\u2500WS\u2500\u2500 cloudflared\n                   download agent              source agent\n                   runs rsync client           runs rsync --server (relay mode)\n                                              OR exposes rsync:// (direct mode)\n```\n\n**Room DO = one Durable Object per user email** (the email extracted from the\nverified Access JWT, **lowercased and trimmed** for a stable DO id \u2014 otherwise\n`Alice@` and `alice@` split into two rooms). It holds:\n- device registry (id, token hash, name, online state)\n- job registry (source, dest, flags, last progress, status)\n- live WebSocket hub (browser subscribers + connected agents)\n- relay byte pump (relay-mode only)\n\nBrowser and all of that user's agents join the same DO, so commands and events\nfan out without cross-DO routing.\n\n---\n\n## 3. Data plane \u2014 two modes\n\n### Direct (default)\nDownload agent runs `rsync --partial --append-verify rsync:///module/ ~/dest/`.\nThe source rsync daemon is raw TCP (port 873), **not HTTP**, so a public\nhostname tunnel alone cannot carry it. The source agent exposes the daemon over\ncloudflared **private networking** (or the download agent reaches it via\n`cloudflared access tcp`), so rsync sees a normal TCP endpoint at a private\naddress. **Zero sync bytes pass through the Worker** \u2014 the DO is control-only\n(start/stop/status). This is the cheap, fast path and the default for any source\nthe user controls (NAS, VPS). Requires the agent's cloudflared to be on the\nCloudflare private network (WARP/tunnel private networking), which it runs\nanyway.\n\n### Relay (fallback)\nThe source cannot join the private network (dynamic both ends, no WARP). The\ndownload agent runs rsync with `-e` pointing at a small helper that dials the\nDO as a binary WS pipe (the DO is the \"remote shell\"). The source agent,\nnotified of the `jobId`, execs `rsync --server` and bridges its stdio to the\nsame DO. The DO pumps bytes both ways. This mirrors `rsync -e ssh` exactly, so\nrsync's own multiplexing/framing is preserved and the DO only pipes opaque\nbytes. Long-lived DO WebSocket, real bandwidth through the Worker \u2014 **paid plan\nrequired**, and only chosen when direct is impossible.\n\nMode is chosen per job at `start` time. Direct is the lazy default.\n\n---\n\n## 4. Auth \u2014 two channels\n\n### Browser \u2192 Worker: Cloudflare Access\nAccess gates the Worker's public hostname via a Zero Trust Application with an\nemail-allowlist policy \u2014 **but the policy must exclude the `/agent` path** (see\nbelow). On protected paths Access injects `Cf-Access-Jwt-Assertion` and the\n`CF_Authorization` cookie; the Worker verifies the JWT to learn the user's\nemail; no JWT or invalid \u2192 401, no DO routing.\n\nVerify:\n- Algorithm RS256, `kid` \u2192 JWKS at\n  `https://.cloudflareaccess.com/cdn-cgi/access/certs`.\n- Check `iss` = team URL, `aud` = the Application's AUD tag, `exp` not past.\n- Email from the `email` claim.\n\nJWKS is cached in a module-level variable with TTL re-validation. This is\nisolate-scoped state (cold starts reset it) \u2014 documented in a comment per the\nCF-WORKERS module-level-state rule. Re-validation on TTL expiry keeps it safe.\n\n**Path coverage:**\n- `/`, `/api/*`, `/live` (browser WS) \u2192 behind Access (browser carries the\n  `CF_Authorization` cookie through the WS handshake).\n- `/agent` (agent WS) \u2192 **excluded from Access**. The agent has no browser and\n  no cookie; it authenticates with the device token (first WS frame) and the\n  pair token during onboarding. The Worker still rejects any `/agent` frame\n  that fails `auth`/`pair` validation.\n\n### Agent \u2192 DO: one-time pairing, then long-lived device token\nThe agent has no browser, so Access alone is not enough. Pairing flow:\n\n1. User opens Worker URL (Access-authed as `alice@example.com`) \u2192 **Add device**.\n2. Worker mints `pairToken = crypto.randomUUID()`, stores in DO/KV\n   `{userEmail, pairTokenHash, expires: now+10m, used:false}`. UI shows token + URL.\n3. User on their box: `curl https://sync.example/install.sh | sh` then\n   `agent pair `.\n4. Agent opens WS to DO, sends `{type:\"pair\", pairToken}`. DO verifies the hash\n   (timing-safe), not expired, not used \u2192 marks used, mints\n   `deviceToken = crypto.randomUUID()`, persists\n   `{deviceId, userEmail, deviceTokenHash, name, created}`. Replies\n   `{type:\"paired\", deviceId, deviceToken}`.\n5. Agent stores the device token locally (file mode 0600). All future connects\n   send `{type:\"auth\", deviceToken}` as the first frame.\n\n**Pairing token** = single-use, 10-minute TTL, hashed at rest.\n**Device token** = long-lived, stored by agent, revocable from the UI.\n\n---\n\n## 5. Protocol\n\nJSON frames over WebSocket. One frame per message. Relay mode additionally\nuses binary WS messages for raw rsync stdio (see \u00a73).\n\n### Agent \u2192 DO\n```json\n{\"type\":\"pair\",\"pairToken\":\"...\"}\n{\"type\":\"auth\",\"deviceToken\":\"...\"}\n{\"type\":\"register\",\"role\":\"download\",\"deviceId\":\"d1\"}\n{\"type\":\"progress\",\"jobId\":\"j1\",\"bytesDone\":1234,\"bytesTotal\":9999,\"filesDone\":3,\"filesTotal\":100,\"rate\":1048576,\"eta\":120,\"status\":\"syncing\"}\n{\"type\":\"log\",\"jobId\":\"j1\",\"level\":\"info\",\"msg\":\"...\"}\n{\"type\":\"done\",\"jobId\":\"j1\",\"exitCode\":0,\"error\":null}\n```\n\n### DO \u2192 Agent\n```json\n{\"type\":\"paired\",\"deviceId\":\"d1\",\"deviceToken\":\"...\"}\n{\"type\":\"auth_ok\",\"deviceId\":\"d1\",\"userEmail\":\"alice@example.com\"}\n{\"type\":\"auth_fail\",\"reason\":\"...\"}        // then close 4001\n{\"type\":\"start\",\"jobId\":\"j1\",\"source\":\"rsync://src-tunnel/mod/\",\"dest\":\"~/stuff/\",\"flags\":[\"-aHvz\",\"--partial\",\"--append-verify\"],\"mode\":\"direct\",\"resume\":false}\n{\"type\":\"stop\",\"jobId\":\"j1\"}\n{\"type\":\"relay_open\",\"jobId\":\"j1\",\"role\":\"server\"}   // relay mode: binary frames follow = raw stdio\n```\n\n### Browser \u2192 Worker (REST, Access-authed)\n```\nGET  /api/pair                       -&gt; {pairToken, url, expires}\nPOST /api/jobs        {source,dest,flags,mode}   -&gt; {jobId}\nPOST /api/jobs/:id/resume            -&gt; {jobId}      // re-send start with resume:true\nPOST /api/jobs/:id/stop              -&gt; {jobId}\nGET  /api/jobs                       -&gt; [{jobId,source,dest,status,lastProgress}]\nGET  /api/devices                    -&gt; [{deviceId,name,online,created}]\nPOST /api/devices/:id/revoke         -&gt; {deviceId}\n```\n\n### DO \u2192 Browser (WS `/live`)\nPushes `progress` / `log` / `done` for the authenticated user's jobs.\nProgress throttled to 1/sec; log frames rate-capped.\n\n---\n\n## 6. Resume\n\nDO stores each job as `{source, dest, flags, lastBytes, status}`. **Resume** =\nre-send `start` with the same parameters and `resume:true`. The agent re-execs\nrsync against the **existing dest directory** with `--partial --append-verify`,\nso rsync continues from where it stopped. If the dest dir is gone, rsync does a\nfull re-sync \u2014 no special case. The DO does nothing clever; it just re-fires the\ncommand. All resume intelligence is rsync's.\n\n---\n\n## 7. Durable Object design (`Room`)\n\n- **State (DO storage):** `devices:{deviceId}` \u2192 JSON, `jobs:{jobId}` \u2192 JSON,\n  `pairing:{pairTokenHash}` \u2192 JSON, counters.\n- **Hibernation:** use WebSocket Hibernation API (`ctx.acceptWebSocket(ws)`,\n  `webSocket.serializeAttachment`). Idle DO with no active sync hibernates;\n  a progress/log/done frame wakes it. Reduces wall-clock billing on idle\n  agents that stay connected.\n- **Hub:** on `register`, attach the agent's role + deviceId to the socket.\n  Browser sockets subscribe to all jobs for the user. Broadcast `progress`/\n  `log`/`done` to subscribed browser sockets.\n- **Relay pump (relay mode):** on `relay_open`, pair the two agent sockets by\n  `jobId`; forward binary frames A\u2192B and B\u2192A; on either close, signal the other\n  and record `done`. Validate both peers authed and job owned by user before\n  pumping.\n\n---\n\n## 8. UI (`ui/index.html`)\n\nSingle static HTML page served from the Worker. No framework. Vanilla JS +\nWS to `/live`.\n- Job list: source, dest, status, last progress bar + %, rate, ETA.\n- Buttons: **Start**, **Resume**, **Stop**, **Add device** (shows pairing token\n  + install command), device list with **Revoke**.\n- Live updates over `/live`.\n\n---\n\n## 9. Agent (`agent/main.go`)\n\nSingle static Go binary. Installed by `curl|sh`, registered as a system\nservice (launchd / systemd / Windows service), runs `cloudflared` as a child\nprocess for the tunnel identity.\n\nResponsibilities:\n1. WS client to `wss://sync.example/agent` \u2014 `auth`/`pair` handshake, reconnect\n   with backoff.\n2. On `start` (direct): `exec.Command(\"rsync\", flags..., source, dest)`, parse\n   `--info=progress2` stderr lines \u2192 `progress` frames (throttled 1/s). On\n   `done` \u2192 `done` frame with exit code.\n3. On `start` (relay, role server): notified of `jobId`, exec\n   `rsync --server ...` and pipe stdin/stdout to the DO WS as binary frames.\n   On EOF \u2192 close.\n4. On `start` (relay, role client): run `rsync -e ./helper ... src dest` where\n   `./helper` dials the DO WS for `jobId` and bridges stdio. rsync's own\n   framing flows over the DO pipe unchanged.\n5. On `stop`: kill rsync process; rsync `--partial` leaves resumable state.\n6. `resume:true` = same as start (rsync handles continuity via existing dest).\n\nWhy Go not bash: relay-mode stdio framing + the start/stop/resume state\nmachine + reliable `--info=progress2` parsing + cross-platform service install\nare ugly and fragile in shell. One static binary gives the non-technical user a\nsingle `curl` line and a service that survives reboot.\n\n---\n\n## 10. File layout\n\n```\nsync-worker/\n  src/\n    index.ts       # Worker: REST routes + WS upgrade, Access JWT gate, route to Room DO\n    room.ts        # Durable Object: hub, job/device state, pairing, relay pump, hibernation\n    access.ts      # JWKS fetch+cache (module-level, isolate-scoped), JWT verify, email extract\n    protocol.ts    # frame type defs\n  wrangler.toml    # DO binding ROOM, nodejs_compat, observability, today compat_date\n  ui/index.html    # one page: jobs, add device, start/resume/stop, live progress\nagent/\n  install.sh       # curl|sh: fetch cloudflared + agent bin, register service, prompt token\n  main.go          # ~250 lines\n```\n\n### `wrangler.toml` essentials\n```toml\nname = \"sync\"\nmain = \"src/index.ts\"\ncompatibility_date = \"2026-07-22\"\ncompatibility_flags = [\"nodejs_compat\"]\n\n[observability]\nenabled = true\n\n[[durable_objects.bindings]]\nname = \"ROOM\"\nclass_name = \"Room\"\n\n[[migrations]]\ntag = \"v1\"\nnew_classes = [\"Room\"]\n```\n\n`package.json` overrides must pin `esbuild &gt;= 0.28.1` (wrangler pulls esbuild\ntransitively; CVE GHSA-gv7w-rqvm-qjhr / GHSA-g7r4-m6w7-qqqr).\n\n---\n\n## 11. Security checklist (trust boundaries)\n\n- Agent \u2194 DO: device token, `crypto.subtle.timingSafeEqual` on the stored hash,\n  never `===`.\n- Browser \u2194 Worker: Access JWT verified on **every** protected request;\n  missing/invalid \u2192 401, no DO routing.\n- Access Application policy must **exclude `/agent`** \u2014 the agent authenticates\n  with its device token (or pair token during onboarding), not a browser\n  cookie. The Worker still validates the `auth`/`pair` WS frame before any DO\n  interaction on `/agent`.\n- DO id = email extracted from verified JWT only, lowercased/trimmed \u2014 never\n  user-supplied. An agent can only reach the DO for the email its device token\n  was paired to.\n- Pairing token: single-use, 10-min TTL, hashed at rest, timing-safe compare.\n- Source rsync: `auth users` + secrets file + tunnel ACL. Never an open\n  rsync module \u2014 that is arbitrary file write.\n- All tokens: `crypto.randomUUID()`. No `Math.random` for any security value.\n- No `ctx.passThroughOnException()` in production.\n- Relay binary frames: DO validates `jobId` ownership and that **both** peers\n  are authed before pumping a single byte.\n- Device token file on agent box: mode 0600, user-owned.\n\n---\n\n## 12. Limits &amp; operational notes\n\n- Progress frames throttled to 1/sec; log frames rate-capped to avoid WS\n  flooding.\n- Long relay-mode sync = long-lived DO WebSocket \u2192 paid plan. Hibernate when\n  idle; active byte-pumping per frame is cheap CPU but sessions run for the\n  duration of a large transfer.\n- Relay binary frames chunked \u2264 256 KB (well under the 1 MB free / 100 MB paid\n  WS message ceiling).\n- Agent offline \u2192 UI shows \"device down\". Cannot sync to a powered-off box;\n  this is inherent, not a bug.\n- Direct mode keeps the Worker out of the data path entirely \u2014 preferred for\n  any source with a stable tunnel.\n\n---\n\n## 13. Implementation sequence\n\n1. Worker scaffold: `wrangler.toml`, `index.ts` REST + WS upgrade, `access.ts`\n   JWT verify, `protocol.ts`. Local `wrangler dev`.\n2. `Room` DO: pairing, device registry, auth handshake, job state, hub\n   broadcast. Unit-check with two WS clients in dev.\n3. UI page wired to REST + `/live`.\n4. Direct-mode path end-to-end: agent (Go) `start` \u2192 rsync \u2192 progress \u2192 UI.\n5. Resume: re-click \u2192 `--partial` continuation.\n6. Relay mode + relay pump (only if a real source needs it).\n7. Agent installer + service registration (macOS/Linux/Windows).\n8. Hardening pass: rate limits, revoke, JWT aud/iss checks, esbuild override.\n9. Deploy + Access Application config in Zero Trust.\n\n---\n\n## 14. Open questions\n\n- **Source ownership model:** is the source always the user's own second\n  machine (same DO, relay or direct), or do we need a curated \"source\n  provider\" pattern (e.g. a server we run that many users pull from)? Affects\n  whether source agents pair per-user or live in a shared DO.\n- **Bandwidth/abuse:** Access email-allowlist bounds who can pair, but a paired\n  agent can sync unbounded data in direct mode (Worker not in path, so cheap)\n  and large data in relay mode (Worker in path, costly). Need per-user relay\n  byte quota before multi-user.\n- **Dest path UX:** non-technical user picking `~/dest/` on their box \u2014 do we\n  force a fixed `~/Sync/` and hide the path, or expose it? Fixed default is\n  more \"non-technical.\"", "creation_timestamp": "2026-07-22T18:18:22.178035Z"}