signal-gateway — the presage Signal-daemon edge
A lightweight Signal daemon edge for brain-server’s Switchboard channel line:
a Rust process that IS a linked Signal device (via
presage — no signal-cli/JVM) and,
optionally, bridges that identity to the kernel over the governed Switchboard
seam. Tool root: tools/signal-gateway/ (README.md, Cargo.toml,
config.example.yaml, src/, tests/).
Era pin: this page is measured against the tree as read (package
signal-gateway0.99.0, tracking the libsignalv0.99.0stack intools/signal-gateway/Cargo.toml/Cargo.lock; Switchboard seam v1.28.43+ perconfig.example.yamlandsrc/main.rs). Correct this page when the tree moves — never the other way round.
What it is
signal-gateway (tools/signal-gateway/src/main.rs) has two subcommands and
nothing else:
signal-gateway link --config config.yaml --device-name signal-gateway
signal-gateway serve --config config.yaml
linkgenerates a secondary-device link URL (SignalHandle:: link_secondary_device): scan it with the primary Signal app to pair this process as a linked device. The identity persists in the presage SQLite store undersignal.data_dir(signal.db).serveloads the linked account (AppState::init_signal), optionally arms the brain adapter (only whenbrain:is configured — otherwise it logsno brain config — running channel-darkand serves the local API only), then serves the local HTTP surface onserver.address.
The presage worker (src/signal/: worker.rs, commands.rs, types.rs)
sends/receives over the live identity’s websocket, with reactions and typing
indicators; the local API (src/api/mod.rs) exposes health, account info,
POST /v2/send, JSON-RPC (POST /api/v1/rpc: sendMessage, sendReaction,
sendTyping, …), recipient-cache seeding (POST /v1/cache/seed), and an SSE
stream (GET /api/v1/events). #![forbid(unsafe_code)] is compile-enforced
(src/main.rs, src/lib.rs, Cargo.toml [lints.rust]).
This is a working edge with a worker, an HTTP surface, a kernel adapter, and
integration tests (tests/s8_01_bind_coupled_auth.rs,
tests/s8_04_rate_limit_wired.rs, tests/s9_02_cache_wiring.rs) — not a
stub, not an experiment. Its ceilings are real anyway; they are listed under
Honest limits.
How it differs from valet-relay and channel-bridge
Three edges, three jobs. Do not substitute one for another:
tools/signal-gateway (this page) | tools/valet-relay | tools/channel-bridge | |
|---|---|---|---|
| Runtime / transport | Rust-native via presage; IS the Signal device (linked secondary) | Zero-dependency Node; drives a signal-cli REST backend it does not own | Rust; speaks Meta Cloud API / Slack Web API / Bot Framework — no Signal at all |
| Documented in | This file; one passing mention in architecture (“the channel bridge, the Signal gateway and the steward harness are separate packages under tools/”) | valet (“Delivery edge”) + tools/valet-relay/README.md | deployment (Caravel/Herald sections) + tools/channel-bridge/README.md; kernel seams in api; pointer in connectors |
| Kernel seam | Switchboard v1.28.43+: POST /webhooks/channel/{kind} (inbound), POST …/drain (outbound crank), POST /workflow/plugins/mount (boot registration) — all Standard-Webhooks HMAC with the shared bridge secret | Valet-era (v1.28.42): alert sink /alert + POST /webhooks/signal | Switchboard: same /webhooks/channel/{kind} + /drain (+ /console for Herald) for kinds whatsapp | slack | teams (kind selected by the config FILENAME segment) |
| Scope | Full-duplex Signal identity: any direct conversation, both directions | Valet ONLY: valet/due (later valet/brief) pings out, owner replies back | Case threads, Relay handover pings, digest-bound approvals in WhatsApp/Slack/Teams |
| Without kernel config | Runs channel-dark: local Signal API only (src/state/mod.rs, src/main.rs) | N/A (relay config is its whole job) | Runs channel-dark (absent config = channel dark) |
Concretely: if you need reminders on Signal, read valet and run the relay. If you need WhatsApp/Slack/Teams case rooms, read the Caravel and Herald sections of deployment and run the bridge. If you need a governed, kernel-attached Signal identity on the Switchboard seam, you are in the right file.
Setup / operation
- Copy
tools/signal-gateway/config.example.yamltoconfig.yamland setchmod 600—Config::load(src/config/mod.rs) refuses any config with group/world bits set, because the file carriesserver.auth_token. - Set
signal.data_dir/attachments_dir(the store holds identity keys and registration data;AppState::newinsrc/state/mod.rstightens the dir to0700andsignal.dbto0600, warning loudly on failure). signal-gateway link --config config.yaml— scan the printed URL with the primary app. Optionally setsignal.display_name(see Privacy below).signal-gateway serve --config config.yaml— serves loopback127.0.0.1:8080by default. A non-loopbackserver.addressis refused unlessSIGNAL_GATEWAY_ALLOW_REMOTE=1is exported at boot, AND a remote bind additionally requiresserver.auth_token— the two halves are the one coupled decision inresolve_api_auth(src/lib.rs), pinned bytests/s8_01_bind_coupled_auth.rs.- For kernel attachment, add the
brain:section (era: Switchboard v1.28.43+):url,bridge_config_path(the SHARED 0600channel-{kind}-{tenant}.jsonthe server also reads from itsBRAIN_CONNECTOR_CONFIG_DIR),drain_interval_secs(default 30, floored to 5 instart_brain_adapter). Omit the section to stay channel-dark — the documented rollback posture.
Request-rate posture (all from src/lib.rs / src/ratelimit.rs, wired in
src/main.rs via apply_rate_limit on the FINISHED router, OUTSIDE auth so
the tokenless loopback arm is bounded too): one global budget of 100
requests per 60 s (API_RATE_LIMIT_MAX_REQUESTS /
API_RATE_LIMIT_WINDOW_SECS, key API_RATE_LIMIT_KEY = "api"); over budget
is a bare 429 with RETRY-AFTER: 60 and an empty body. Distinct from the
send path’s concurrency cap: max_sends_per_second (5 in the example config)
bounds in-flight sends, not request rate — both bounds are live. The 100/60
constants are NOT operator-tunable by design (named constants in the library
target, shared by binary and tests).
Input validation (src/validation.rs): recipients must be UUID, E.164 phone
(+ + 7–14 digits), or ACI (u:<uuid>); messages must be non-empty and
≤ 10000 chars. The recipient cache (src/cache.rs) is bounded (cap 4096,
oldest-quarter eviction; TTL on the phone leg) and never logs operands —
phone numbers and ACIs are identifiers.
Credential posture — what it holds, what it never holds
HOLDS (all 0600-or-tighter, all its own):
- The presage Signal store (
signal.data_dir/signal.db) — the linked identity’s keys and registration data. - Its own
config.yaml— carriesserver.auth_token, hence the 0600 refusal at load. - The SHARED bridge credential file (
channel-{kind}-{tenant}.json:domain+webhook_secret), read frombridge_config_path. Owner-only permissions REQUIRED (BridgeConfig::loadinsrc/brain.rsrefuses otherwise); the filename’schannel-{kind}-{tenant}segments select kind and tenant. One credential copy, read by both sides. - The local API bearer token (
server.auth_token), gating the FULL surface (reads and sends — both are identity-bearing; constant-time compare insrc/api/mod.rs). Empty string counts as NO credential.
NEVER HOLDS (the governed-edge law, stated in tools/signal-gateway/README.md
and src/main.rs, pinned upstream by bridge_holds_no_brain_credentials):
- No brain-server token, no
Authorizationheader toward the kernel, no brain database path. The ONLY kernel credential is the HMACwebhook_secret. The kernel stays channel-free by construction. - Egress discipline mirrors the bridge:
BrainClient(src/brain.rs) uses a 15 s timeout andredirect(Policy::none())— signed webhook headers never ride a cross-origin redirect.
Kernel protocols (src/brain.rs, all HMAC-signed
v1,<base64 hmac-sha256("{id}.{ts}.{body}")>): INBOUND posts each received
direct text message as the normalized envelope projection
{envelope: {conversation_ref, text, external_id}} (sender UUID as
conversation ref; external_id = sender-uuid + platform timestamp, stable
across restarts for the replay cap); OUTBOUND drain crank claims approved
channel/out envelopes only; REGISTRATION posts mount evidence (SHA-256 of
the shared config file bytes, recomputed server-side) to
/workflow/plugins/mount, retried 5× with linear backoff.
Privacy posture (hidden & anonymous, per README.md + src/signal/worker.rs):
set signal.display_name to the Signal username created on the primary app
with number-discovery OFF — every API response, log line, and broadcast
payload then carries the label; unset falls back to masked digits (+63…67,
see present_self_number). Recipient addressing accepts usernames, resolved
server-side via presage lookup_username and cached as ACI
(resolve_via_manager). Ceiling, stated honestly upstream: Signal’s servers
still know the account’s number (protocol truth); anonymity here is from
CONTACTS AND OBSERVERS, not from Signal.
Verification
What exists in-tree (cite only what is real):
signal-gateway servelogs the linkage state at boot (Signal linked/Signal not linked. Use 'link' command to pair.), the auth posture (API auth: bearer token requiredvs loopback-only), and the rate-limit line — read them before sending anything.- Liveness without identity:
GET /v1/health→{"status":"ok","version": "0.99.0"};GET /v1/aboutandGET /api/v1/accountsreport the linked account (masked per the privacy posture).GET /api/v1/eventsopens the SSE stream (refuses unlinked with{"error": "Not linked"}). - Kernel seam:
brain adapter armed for {kind}/{tenant} → {url}plusmount evidence registered for …at boot; inbound posts and drain deliveries are logged per envelope (external_id/event_id). - Test suite in-tree: unit tests in
src/(brain.rssignature-vs-server- scheme, envelope projection, forwardability;lib.rsauth postures;ratelimit.rs;config/mod.rs0600 refusal) plustests/s8_01_*(coupled bind+auth),tests/s8_04_*(limiter behaviour + end-to-end 429s + a structural pin that fails if the wrap is removed),tests/s9_02_*(cache wiring). Run from the tool dir withcargo test(Cargo.tomlnotes CI runs test/clippy with--lockedso the pinned presage/libsignal stack cannot re-resolve under a green build). - Era note on the audit record:
docs/audit8/02-satellites-supply-chain.mdS8-01 (remote bind servable unauthenticated) and S8-04 (rate limiter a dead module) describe the PRE-FIX tree. The currentsrc/lib.rs+src/main.rstests/s8_*show both closed (coupledresolve_api_auth; limiter wrapped outermost). Trust the sources cited here over the finding text if they ever disagree — and re-check before quoting either.
Honest limits (ceilings)
- Direct conversations only.
forwardable(src/brain.rs) admits non-empty text with NO group id; group messages are dropped on the inbound leg today (“group threading rides the line roadmap”). Outbound drain delivers toconversation_refas given. - At-least-once with a loud edge. The drain marks rows delivered
server-side; a Signal send that then fails CANNOT be retried by the crank
—
drain_once(src/state/mod.rs) logsDELIVERY FAILEDat error. Watch the edge logs; the server will not redeliver. - Mount evidence is bounded. Registration retries 5×, then stops with
mount evidence NOT registered after 5 attempts— the loss surfaces as a chain gap upstream, not as silence. Do not assume a quiet edge is a registered edge. - The 100-request burst still reaches Signal. The rate limiter bounds the
HTTP surface, not the network: a full budget spent on
/v2/sendis 100 real sends, and the SSE long-poll on/api/v1/eventsdraws from the same global budget. Size operators’ expectations (and tokens) accordingly. - Pinned crypto stack, deliberately. Package version tracks the libsignal
tag (
0.99.0via presage revf74b96e0…); the stack-policy note inCargo.tomlsays riding presage forward past this rev is a deliberate, reviewed act (re-lock + version bump together), because cargo[patch]cannot re-point same-URL git pins.serde_yamlis held at0.9.34(deprecated upstream; the rename toserde_yml/serde_norwayis behavioural, not a bump). Quote0.99.0with its date, not as “latest”. - Number-less accounts are not supported upstream. Fully self-registering without a phone number is not something presage/Signal offers; the privacy posture hides the number from contacts and observers, never from Signal’s servers.
- Partial API surfaces.
GET /v1/receive/{number}is a stub that answers{"error": "Use /api/v1/events for SSE stream"}(no WebSocket);listGroups/getGroupsanswer{"groups": []};sendReadReceipt/markReadanswernull(no-op).POST /v1/cache/seedis integrity- bearing (a wrong phone→UUID mapping misdelivers) and is therefore logged at WARN with SHA-256 digests, never operands. - Loopback is the only unauthenticated posture. Anything routable demands
SIGNAL_GATEWAY_ALLOW_REMOTE=1AND a token; there is no flag that waives authentication, only one that permits reaching the port.