feat(discovery): gateway bonjour + node pairing bridge
This commit is contained in:
106
docs/discovery.md
Normal file
106
docs/discovery.md
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
summary: "Node discovery and transports (Bonjour, Tailscale, SSH) for finding the master gateway"
|
||||
read_when:
|
||||
- Implementing or changing Bonjour discovery/advertising
|
||||
- Adjusting remote connection modes (direct vs SSH)
|
||||
- Designing bridge + pairing for remote nodes
|
||||
---
|
||||
# Discovery & transports
|
||||
|
||||
Clawdis has two distinct problems that look similar on the surface:
|
||||
|
||||
1) **Operator remote control**: the macOS menu bar app controlling a “master” gateway running elsewhere.
|
||||
2) **Node pairing**: Iris/iOS (and future nodes) finding a gateway and pairing securely.
|
||||
|
||||
The design goal is to keep all network discovery/advertising in the **Node Gateway** (`clawd` / `clawdis gateway`) and keep clients (mac app, iOS) as consumers.
|
||||
|
||||
## Terms
|
||||
|
||||
- **Master gateway**: the single, long-running gateway process that owns state (sessions, pairing, node registry) and runs providers.
|
||||
- **Gateway WS (loopback)**: the existing gateway WebSocket control endpoint on `127.0.0.1:18789`.
|
||||
- **Bridge (direct transport)**: a LAN/tailnet-facing endpoint owned by the gateway that allows authenticated clients/nodes to call a scoped subset of gateway methods. The bridge exists so the gateway can remain loopback-only.
|
||||
- **SSH transport (fallback)**: remote control by forwarding `127.0.0.1:18789` over SSH.
|
||||
|
||||
## Why we keep both “direct” and SSH
|
||||
|
||||
- **Direct bridge** is the best UX on the same network and within a tailnet:
|
||||
- auto-discovery on LAN via Bonjour
|
||||
- pairing tokens + ACLs owned by the gateway
|
||||
- no shell access required; protocol surface can stay tight and auditable
|
||||
- **SSH** remains the universal fallback:
|
||||
- works anywhere you have SSH access (even across unrelated networks)
|
||||
- survives multicast/mDNS issues
|
||||
- requires no new inbound ports besides SSH
|
||||
|
||||
## Discovery inputs (how clients learn where the master is)
|
||||
|
||||
### 1) Bonjour / mDNS (LAN only)
|
||||
|
||||
Bonjour is best-effort and does not cross networks. It is only used for “same LAN” convenience.
|
||||
|
||||
Target direction:
|
||||
- The **gateway** advertises itself (and/or its bridge) via Bonjour.
|
||||
- Clients browse and show a “pick a master” list, then store the chosen endpoint.
|
||||
|
||||
#### Current implementation
|
||||
|
||||
- Service types:
|
||||
- `_clawdis-master._tcp` (gateway “master” beacon)
|
||||
- `_clawdis-bridge._tcp` (optional; bridge transport beacon)
|
||||
- TXT keys (non-secret):
|
||||
- `role=master`
|
||||
- `lanHost=<hostname>.local`
|
||||
- `sshPort=22` (or whatever is advertised)
|
||||
- `gatewayPort=18789` (loopback WS port; informational)
|
||||
- `bridgePort=18790` (when bridge is enabled)
|
||||
- `tailnetDns=<magicdns>` (optional hint)
|
||||
|
||||
Disable/override:
|
||||
- `CLAWDIS_DISABLE_BONJOUR=1` disables advertising.
|
||||
- `CLAWDIS_BRIDGE_ENABLED=0` disables the bridge listener.
|
||||
- `CLAWDIS_BRIDGE_HOST` / `CLAWDIS_BRIDGE_PORT` control bind/port.
|
||||
|
||||
### 2) Tailnet (cross-network)
|
||||
|
||||
For London/Vienna style setups, Bonjour won’t help. The recommended “direct” target is:
|
||||
- Tailscale MagicDNS name (preferred) or a stable tailnet IP.
|
||||
|
||||
If the gateway can detect it is running under Tailscale, it can publish `tailnetDns` as an optional hint for clients.
|
||||
|
||||
### 3) Manual / SSH target
|
||||
|
||||
When there is no direct route (or direct is disabled), clients can always connect via SSH by forwarding the loopback gateway port.
|
||||
|
||||
See `docs/remote.md`.
|
||||
|
||||
## Transport selection (client policy)
|
||||
|
||||
Recommended client behavior:
|
||||
|
||||
1) If a paired direct endpoint is configured and reachable, use it.
|
||||
2) Else, if Bonjour finds a master on LAN, offer a one-tap “Use this master” choice and save it as the direct endpoint.
|
||||
3) Else, if a tailnet DNS/IP is configured, try direct.
|
||||
4) Else, fall back to SSH.
|
||||
|
||||
## Pairing + auth (direct transport)
|
||||
|
||||
The gateway is the source of truth for node/client admission.
|
||||
|
||||
- Pairing requests are created/approved/rejected in the gateway (see `docs/gateway/pairing.md`).
|
||||
- The bridge enforces:
|
||||
- auth (token / keypair)
|
||||
- scopes/ACLs (bridge is not a raw proxy to every gateway method)
|
||||
- rate limits
|
||||
|
||||
## Where the code lives (target architecture)
|
||||
|
||||
- Node gateway:
|
||||
- advertises discovery beacons (Bonjour)
|
||||
- owns pairing storage + decisions
|
||||
- runs the bridge listener (direct transport)
|
||||
- macOS app:
|
||||
- UI for picking a master, showing pairing prompts, and troubleshooting
|
||||
- SSH tunneling only for the fallback path
|
||||
- iOS node:
|
||||
- browses Bonjour (LAN) as a convenience only
|
||||
- uses direct transport + pairing to connect to the gateway
|
||||
@@ -17,7 +17,7 @@ This enables:
|
||||
## Concepts
|
||||
- **Pending request**: a node asked to join; requires explicit approve/reject.
|
||||
- **Paired node**: node is allowed; gateway returns an auth token for subsequent connects.
|
||||
- **Bridge**: LAN transport that forwards between node ↔ gateway. The bridge does not decide membership.
|
||||
- **Bridge**: direct transport endpoint owned by the gateway. The bridge does not decide membership.
|
||||
|
||||
## API surface (gateway protocol)
|
||||
These are conceptual method names; wire them into `src/gateway/protocol/schema.ts` and regenerate Swift types.
|
||||
@@ -46,20 +46,24 @@ These are conceptual method names; wire them into `src/gateway/protocol/schema.t
|
||||
- Creates (or returns) a pending request.
|
||||
- Params: node metadata (same shape as `node.pair.requested` payload, minus `requestId`/`ts`).
|
||||
- Result:
|
||||
- `requestId`
|
||||
- `status` ("pending" | "alreadyPaired")
|
||||
- If already paired: may include `token` directly to allow fast path.
|
||||
- `status` ("pending")
|
||||
- `created` (boolean) — whether this call created the pending request
|
||||
- `request` (pending request object), including `isRepair` when the node was already paired
|
||||
- Security: **never returns an existing token**. If a paired node “lost” its token, it must be approved again (token rotation).
|
||||
- `node.pair.list`
|
||||
- Returns:
|
||||
- `pending[]` (pending requests)
|
||||
- `paired[]` (paired node records)
|
||||
- `node.pair.approve`
|
||||
- Params: `{ requestId }`
|
||||
- Result: `{ nodeId, token }`
|
||||
- Result: `{ requestId, node: { nodeId, token, ... } }`
|
||||
- Must be idempotent (first decision wins).
|
||||
- `node.pair.reject`
|
||||
- Params: `{ requestId }`
|
||||
- Result: `{ nodeId }`
|
||||
- Result: `{ requestId, nodeId }`
|
||||
- `node.pair.verify`
|
||||
- Params: `{ nodeId, token }`
|
||||
- Result: `{ ok: boolean, node?: { nodeId, ... } }`
|
||||
|
||||
## CLI flows
|
||||
CLI must be able to fully operate without any GUI:
|
||||
@@ -80,10 +84,9 @@ Notes:
|
||||
- Pending entries should have a TTL (e.g. 5 minutes) and expire automatically.
|
||||
|
||||
## Bridge integration
|
||||
The macOS Bridge is responsible for:
|
||||
- Surfacing the pairing request to the gateway (`node.pair.request`).
|
||||
- Waiting for the decision (`node.pair.approve`/`reject`) and completing the on-wire pairing handshake to the node.
|
||||
- Enforcing ACLs on what the node can call, even after paired.
|
||||
Target direction:
|
||||
- The gateway runs the bridge listener (LAN/tailnet-facing) and advertises discovery beacons (Bonjour).
|
||||
- The bridge is transport only; it forwards/scopes requests and enforces ACLs, but pairing decisions are made by the gateway.
|
||||
|
||||
The macOS UI (Swift) can:
|
||||
- Subscribe to `node.pair.requested`, show an alert, and call `node.pair.approve` or `node.pair.reject`.
|
||||
@@ -91,5 +94,4 @@ The macOS UI (Swift) can:
|
||||
|
||||
## Implementation note
|
||||
If the bridge is only provided by the macOS app, then “no Swift app running” cannot work end-to-end.
|
||||
To support headless pairing, also add a `clawdis bridge` CLI mode that provides the Bonjour bridge service and forwards to the local gateway.
|
||||
|
||||
The long-term goal is to move bridge hosting + Bonjour advertising into the Node gateway so headless pairing works by default.
|
||||
|
||||
@@ -31,10 +31,10 @@ Non-goals (v1):
|
||||
- macOS “Canvas” exists today, but is **mac-only** and controlled via mac app IPC (`clawdis-mac canvas ...`) rather than the Gateway protocol (`docs/mac/canvas.md`).
|
||||
- Voice wake forwards via `GatewayChannel` to Gateway `agent` (mac app: `VoiceWakeForwarder` → `AgentRPC`).
|
||||
|
||||
## Recommended topology (B): macOS Bridge + loopback Gateway
|
||||
Keep the Node gateway loopback-only; expose a dedicated **macOS bridge** to the LAN.
|
||||
## Recommended topology (B): Gateway-owned Bridge + loopback Gateway
|
||||
Keep the Node gateway loopback-only; expose a dedicated **gateway-owned bridge** to the LAN/tailnet.
|
||||
|
||||
**iOS App** ⇄ (TLS + pairing) ⇄ **macOS Bridge** ⇄ (loopback) ⇄ **Gateway WS** (`ws://127.0.0.1:18789`)
|
||||
**iOS App** ⇄ (TLS + pairing) ⇄ **Bridge (in gateway)** ⇄ (loopback) ⇄ **Gateway WS** (`ws://127.0.0.1:18789`)
|
||||
|
||||
Why:
|
||||
- Preserves current threat model: Gateway remains local-only.
|
||||
@@ -71,6 +71,11 @@ Desired behavior:
|
||||
|
||||
See `docs/gateway/pairing.md` for the API/events and storage.
|
||||
|
||||
CLI (headless approvals):
|
||||
- `clawdis nodes pending`
|
||||
- `clawdis nodes approve <requestId>`
|
||||
- `clawdis nodes reject <requestId>`
|
||||
|
||||
### Authorization / scope control (bridge-side ACL)
|
||||
The bridge must not be a raw proxy to every gateway method.
|
||||
|
||||
@@ -183,8 +188,8 @@ open ClawdisNode.xcodeproj
|
||||
- Keep current Canvas root (already implemented):
|
||||
- `~/Library/Application Support/Clawdis/canvas/<session>/...`
|
||||
- Bridge state:
|
||||
- `~/Library/Application Support/Clawdis/bridge/paired-nodes.json`
|
||||
- `~/Library/Application Support/Clawdis/bridge/keys/...`
|
||||
- No local pairing store (pairing is gateway-owned).
|
||||
- Any local bridge-only state should remain private under Application Support.
|
||||
|
||||
### Gateway (node)
|
||||
- Pairing (source of truth):
|
||||
|
||||
@@ -7,6 +7,9 @@ read_when:
|
||||
|
||||
This repo supports “remote over SSH” by keeping a single gateway (the master) running on a host (e.g., your Mac Studio) and connecting one or more macOS menu bar clients to it. The menu app no longer shells out to `pnpm clawdis …`; it talks to the gateway over a persistent control channel that is tunneled through SSH.
|
||||
|
||||
Remote mode is the SSH fallback transport. As Clawdis adds a direct “bridge” transport for LAN/tailnet setups, SSH remains supported for universal reach.
|
||||
See `docs/discovery.md` for how clients choose between direct vs SSH.
|
||||
|
||||
## Topology
|
||||
- Master: runs the gateway + control server on `127.0.0.1:18789` (in-process TCP server).
|
||||
- Clients: when “Remote over SSH” is selected, the app opens one SSH tunnel:
|
||||
|
||||
Reference in New Issue
Block a user