alextangson/dsh-dispatch--packages-relay0

dsh-dispatch-relay

用于 dsh-dispatch 的零知识 WebSocket 中继服务:在 DSH 机器与手机之间路由端到端加密帧,并在手机离线时分发 Web 推送通知。

AI 分析

核心用途是作为中转站安全地传输加密的远程控制指令和推送通知。适合需要自建中继服务以保障 dsh-dispatch 远程连接隐私与稳定性的用户。

套件
dsh-dispatch-relay
版本
0.1.0
授權
MIT
最近更新
2026年8月23日

安裝

此插件尚未提供可驗證的 bundle,或相容性檢查未通過。請先閱讀倉庫說明。 閱讀完整 README ↗

dsh-dispatch-relay

Zero-knowledge WebSocket router for dsh-dispatch. It connects a dsh machine (the plugin) to a phone (the PWA), forwards end-to-end encrypted frames between them, and — when no phone is connected — fans the machine's notification out over Web Push.

It never sees a key or a plaintext. Implements docs/PROTOCOL.md v1.

Run it

# node (from a checkout)
pnpm --filter dsh-dispatch-relay build
node packages/relay/dist/index.js

# npx (published)
npx dsh-dispatch-relay

# docker (build context is the repo root)
docker build -f packages/relay/Dockerfile -t dsh-dispatch-relay .
docker run -p 8787:8787 -v dsh-relay-data:/data dsh-dispatch-relay

Behind a TLS terminator (Caddy/nginx/Cloudflare), proxy /ws with upgrade headers and point the plugin at wss://your-host/ws. Phones require wss:// — browsers refuse Web Push and service workers on plaintext origins.

Health check: GET /healthz200 {"ok":true}.

Environment

VariableDefaultMeaning
PORT8787HTTP + WebSocket port. Invalid values abort startup rather than silently falling back.
DATA_DIR./relay-dataDirectory for push-subs.json, the only thing the relay persists.
VAPID_PUBLIC_KEYWeb Push VAPID public key.
VAPID_PRIVATE_KEYWeb Push VAPID private key.
VAPID_SUBJECTmailto:you@example.com or an https:// contact URL.
LOG_LEVELinfosilent | error | warn | info.

All three VAPID values are required to enable push. With any of them missing the relay logs one line — web push disabled: VAPID env not set — and keeps serving everything else; GET /vapid then answers 404 {"error":"push-disabled"} so the PWA can say "push unavailable on this relay" instead of failing an opaque subscribe call.

Generate a key pair with npx web-push generate-vapid-keys.

Endpoints

RouteResponse
GET /healthz200 {"ok":true}
GET /vapid200 {"publicKey":"…"}, or 404 {"error":"push-disabled"}
GET /ws426 {"error":"upgrade-required"} — this path is the WebSocket endpoint

Wire behaviour

  • The first frame on a socket must be a valid hello; anything else gets {"kind":"error","code":"bad-hello"} and the socket is closed (1008).
  • After that the socket is never closed for a bad frame. Unknown kinds and malformed frames get {"kind":"error","code":"bad-frame"} and the connection stays up — forward compatibility beats strictness here.
  • msg frames are forwarded verbatim (the original bytes, unknown fields included) to every socket of the other role in the room. Frames larger than 16KB are rejected with {"kind":"error","code":"too-large"} and are not forwarded.
  • One socket can hello into many rooms — that is how a phone controls N machines over a single connection. Joining the same room twice with different roles is refused.
  • presence goes to the other role on join, and on the leave of the last socket of a role (a second phone disconnecting does not report the phone as offline).
  • WebSocket ping every 25s; a socket that misses the following pong is terminated.

Push

A machine msg carrying a push hint triggers Web Push only when the room has zero phone sockets. The relay sends the machine-supplied ciphertext with TTL: 60 and, when the tag is a valid RFC 8030 topic (≤32 chars of [A-Za-z0-9_-]), collapses notifications by it. Subscriptions reported as 404/410 are pruned from the store.

Push failures are reported back to the machine rather than swallowed, so the plugin can surface them (PRODUCT.md "失败可见"):

CodeMeaning
push-disabledthis relay has no VAPID keys configured
push-no-subscribersno phone online and no stored subscription for the room
push-failedevery delivery attempt failed or was expired (counts in message)

Security note

The relay is zero-knowledge for content. Pairing secrets are exchanged out of band (QR / pairing code) between plugin and phone; the relay never receives one, so every payload it routes is an opaque base64(nonce ‖ secretbox) blob it cannot open — including push payloads, which are handed to the push service still encrypted. It links no crypto library at all.

What it does see is metadata: room ids (derived hashes, not secrets), which role is connected when, message sizes and timing, and Web Push endpoint URLs of subscribed phones. That metadata is the whole threat surface of a relay compromise: an attacker learns that something happened in a room and can withhold delivery, but cannot read or forge traffic — frames fail to decrypt without the pairing secret. Logs carry only the first 8 characters of a room id and never a payload, ciphertext or push body. The only persisted state is DATA_DIR/push-subs.json; delete it to drop all push subscriptions.

Self-host it: an operator you trust running this on a $5 VPS sees strictly less than a hosted service would.

Development

pnpm --filter dsh-dispatch-relay dev           # watch mode
pnpm --filter dsh-dispatch-relay test          # vitest, real sockets on port 0
pnpm --filter dsh-dispatch-relay typecheck
pnpm --filter dsh-dispatch-relay fake-machine  # simulated dsh machine, prints a pairing code

fake-machine is the end-to-end harness: it generates a secret, prints a pairing code plus a http://localhost:5173/#pair=… link for the PWA, joins the derived room as a machine, and then answers sessions.get, accepts dispatch.request (idempotently), raises an approval five seconds later and closes it when the phone responds. Point it elsewhere with RELAY_URL, PWA_URL and MACHINE_NAME.