IPC Protocol
Gateway WebSocket control plane + JSON-RPC 2.0 + device-ticket / device-token handshake
Overview
Aleph's client ↔ Gateway communication is a single WebSocket control
plane carrying JSON-RPC 2.0. Approvals, file IO, tool calls, config
updates, memory queries, model selection — everything goes over /ws.
There used to be a Unix-socket UI protocol for approvals
(src/exec/socket.rs documents this) — it was never wired into the
server and has been removed. The only wire vocabulary in use today is
WebSocket + JSON-RPC.
Source locations:
- WS server + handler:
src/gateway/server/handler.rs(axum'sWebSocketUpgrade) - WS
/wsroute:src/gateway/server/(handler / per_client_buffer / flood_guard / metrics_endpoint) - JSON-RPC protocol:
src/gateway/protocol.rs - Credential resolution:
src/gateway/handlers/connect.rs::resolve_connect_auth - Device tickets / tokens:
src/gateway/security/device_token_manager.rs - Cluster reverse RPC:
src/gateway/cluster/(exec.approval.resolvealso accepts an optionalreason) - Audit:
src/security/audit.rs
Architecture
┌────────────────────────────────────────────────────────────────┐
│ Gateway Server │
│ ws://127.0.0.1:18790/ws │
│ (remote: wss://..., TLS self-signed or proxy-terminated) │
├────────────────────────────────────────────────────────────────┤
│ Inbound Router ─► Handler Registry ─► Outbound Emitter │
│ • connect handshake • agent.run • stream.chunk │
│ • per-IP rate-limit • chat.send • agent.trace │
│ • flood guard • exec.approval • tool_start / end │
│ • tools.* • session.* │
│ • mcp.* • events.subscribe │
│ • gateway.* │
│ • runs.* │
└────────────────────────────────────────────────────────────────┘Trust boundary
The trust boundary is the network boundary. Loopback is always
credential-free; remote connections must present a valid credential,
resolved by src/gateway/handlers/connect.rs::resolve_connect_auth in
priority order:
- loopback ⇒ operator (no credential);
- device token (
aleph-dt-*) — long-lived, bound to one paired device; - bootstrap ticket (
aleph-bt-*) — 5-minute single-use, exchanged during the handshake for a fresh device token; - legacy shared Gateway token (
aleph-<uuid>,SharedTokenManager) — HMAC-hashed, constant-time verified.
A valid credential = full operator authority (identical to local); a
missing / invalid one is walled (the WS dispatch refuses every method
but connect).
The WS Origin check (src/gateway/origin_policy.rs) additionally
blocks public web pages from cross-origin-driving the local daemon as a
last guardrail.
Connect handshake
The first frame on a /ws connection must be connect. Loopback omits
the credential fields entirely; remote connections present one in
connect params:
{
"method": "connect",
"params": {
"minProtocol": 1,
"maxProtocol": 1,
"client": {
"id": "macos-app",
"version": "1.0.0",
"platform": "macos"
},
"device_token": "aleph-dt-…",
"bootstrap_ticket": "aleph-bt-…"
}
}resolve_connect_auth stamps the resolved role (operator when
authorized, else guest) onto the connection state, and the response
echoes role / authorized / needs_token. A bootstrap-ticket exchange
also returns a freshly minted device_token the client persists for
subsequent reconnects. A rejected remote connect is recorded in the
security audit log (AuditEventType::AuthFailure, bounded by the
Auth-scope rate limiter).
Loopback clients omit the credential fields entirely. A remote client sends
bootstrap_ticketon first pairing (receiving adevice_tokenback), thendevice_tokenon every reconnect.token(the legacy shared Gateway token) is accepted as a fallback.
device_idis client-asserted and thedevicestable shares one namespace with cluster nodes, so the exchange refuses adevice_idthat already names a non-Panel device (andcluster::admit_noderefuses the mirror case). Without that guard, one ticket buys an operator token the Panel roster cannot see and no revoke path can reach.
JSON-RPC protocol
Request format (Client → Gateway)
{
"jsonrpc": "2.0",
"id": "uuid-xxx",
"method": "agent.run",
"params": {
"message": "Hello",
"session_key": "agent:main:main"
}
}Response format (Gateway → Client)
{
"jsonrpc": "2.0",
"id": "uuid-xxx",
"result": {
"run_id": "run-123",
"status": "running"
}
}Event format (Gateway → Client)
{
"jsonrpc": "2.0",
"method": "event",
"params": {
"topic": "stream.chunk",
"data": {
"run_id": "run-123",
"content": "Hello! How can I help you?"
}
}
}RPC methods (selection)
| Method | Description | Parameters |
|---|---|---|
connect | LAN-trust handshake (remote requires credential) | device_token / bootstrap_ticket / token |
agent.run | Start agent execution | message, session_key, thinking?, model?, exec_tier? |
agent.status | Get run status | run_id |
agent.cancel / agent.abort | Cancel / force-abort run | run_id |
session.get / session.list / session.history | Session queries | session_key, limit? |
session.compact / session.delete | Session management | session_key |
config.get / config.patch / config.apply / config.reload | Config management | — |
events.subscribe / events.unsubscribe / events.list | Event subscription | pattern (glob) |
exec.approval.list / exec.approval.resolve | Approval decisions | {outcome, reason?} |
memory.store / memory.search / memory.delete / memory.stats | Long-term memory | — |
browser.navigate / browser.click / browser.type / browser.screenshot / browser.evaluate | Per-action browser entry points | — |
mcp.* | start / stop / list / call | — |
gateway.ticket.create / gateway.devices.* / gateway.token.rotate | Device / token management | — |
runs.* | list / status / wait / queue | — |
models.* / generation.* / cron.* / plugins.* / skills.* / interfaces.* | Per-domain RPC | — |
Event topics
Subscribe with glob patterns:
| Pattern | Events |
|---|---|
stream.* | All streaming events |
stream.chunk | Text chunks |
stream.agent_trace | Structured, loop-originated execution trace |
stream.tool_start / stream.tool_end | Tool execution boundaries |
agent.* | Agent lifecycle |
agent.started / agent.completed / agent.error | Agent state |
session.* / config.* | Session + config events |
Remote transport encryption (TLS)
Reachability controls who can open the socket; TLS controls whether
the bytes on the wire are readable. The two are independent: the
Gateway token authenticates, TLS encrypts. A host = "0.0.0.0" deployment
without TLS ships the token and every message in cleartext, sniffable on
any hop between client and server.
Enforcement (off-by-default; loopback always exempt). Loopback
(127.0.0.1 / ::1) stays plaintext ws:// — the zero-config desktop /
CLI / same-host-proxy hop is unchanged. For a non-loopback bind Aleph
is now fail-closed:
- Boot gate (
check_network_exposure): a config that binds a non-loopbackhostwith no native TLS, no trusted proxy, andallow_insecure_remote = falserefuses to start with an actionable error. (This is the one intentional breaking change — a previously-workinghost = "0.0.0.0"plaintext config now must pick a remedy below.) - Per-connect gate (
refuse_insecure_remote): a remote client whose leg is unencrypted is rejected at the WS upgrade with426 Upgrade Required, even if the boot gate passed on a permissive combo. "Encrypted" means native TLS terminated in-process, or a trusted proxy that setX-Forwarded-Proto: https.
Three ways to satisfy it:
Tier ① — TLS reverse proxy (recommended; needs a domain)
Aleph stays bound to loopback; a same-host Caddy / nginx terminates TLS and forwards to it. Keep the proxy config trivial — all the robustness lives in Aleph.
[gateway]
host = "127.0.0.1" # aleph stays loopback; the proxy is same-host
allowed_origins = ["https://your.domain.com"]
[gateway.trusted_proxy]
enabled = true # honor the proxy's X-Forwarded-For / -Protoyour.domain.com {
reverse_proxy 127.0.0.1:18790
}Why
trusted_proxyis security-critical here, not just cosmetic. The proxy connects to Aleph over loopback, so withouttrusted_proxyevery remote client would appear to Aleph as127.0.0.1— i.e., auto-authorized as loopback operator, a full auth bypass. Withtrusted_proxy = trueAleph reads the real client IP fromX-Forwarded-For(spoof-safe: only a peer intrusted_ipsis believed), so a remote client is correctly seen as remote and must present a Gateway-token credential, and per-IP rate-limit / cap / audit key on the real client. Enabling the proxy without settingtrusted_proxyis a mistake.
Tier ② — Native self-signed TLS (no domain; weaker)
No domain, no proxy — Aleph generates and persists a self-signed cert
to ~/.aleph/data/tls/ on first boot and logs its SHA-256 fingerprint.
Its SAN auto-covers loopback plus every non-loopback interface IP of
the box (e.g., a VPS public IP on eth0), so connecting by that IP
passes TLS hostname validation; add hostnames or a NAT'd public IP via
[gateway.tls] san = [...]. Clients still get a browser cert warning
(accept-once, or pin the fingerprint) — encryption is real, the trust
anchor is manual. A newly-appearing address regenerates the cert (new
fingerprint ⇒ re-trust once); a sans.txt sidecar tracks coverage so
churn stays minimal. The SAN enumerates every local interface IP —
including private / LAN and Docker-bridge addresses — so anyone who
inspects the cert learns the box's interface map; harmless for a
personal server, but list only what you need via san + a loopback bind
if that matters.
[gateway]
host = "0.0.0.0"
[gateway.tls]
enabled = true # empty cert/key paths ⇒ auto self-signed
# san = ["vps.example.com"]The Panel hard-codes wss:// for any non-loopback host and refuses a
plaintext socket to a remote gateway — remote Panels connect over TLS
automatically.
Tier ③ — Native TLS with a real cert (domain, no proxy)
Point Aleph at operator-provided PEM files (e.g., certbot output). Aleph terminates TLS itself.
[gateway]
host = "0.0.0.0"
[gateway.tls]
enabled = true
cert_path = "/etc/letsencrypt/live/your.domain.com/fullchain.pem"
key_path = "/etc/letsencrypt/live/your.domain.com/privkey.pem"Escape hatch — allow_insecure_remote
[gateway]
host = "0.0.0.0"
allow_insecure_remote = true # DANGER: plaintext to remote clientsRestores pre-hardening LAN-plaintext behavior (boot gate + per-connect gate both stand down). Only for a trusted, isolated LAN where you knowingly accept cleartext. Never on a public interface.
Reverse RPC for cluster nodes
Cluster nodes (aleph-server node --center ws://...) communicate with
the center over reverse WS RPC:
- The node connects to the center's
/wsand performs the Panel-device handshake (device token / bootstrap ticket). - The center delegates
agent.runto the node. exec.approval.resolveon the node accepts an optionalreasonparameter (theApprovalRequestertrait now returnsoutcome + reason; transports that cannot carry one useFrom<ApprovalOutcome>) — older nodes ignore it.
Real client IP behind a trusted proxy
The IP-keyed abuse protections (per-IP connection cap, rate limiter,
Auth-scope lockout) key off the raw socket peer address
(peer_addr.ip(), verbatim).
X-Forwarded-For / trusted-proxy resolution was removed with the
LAN-trust revert and is not reinstated — a trusted_proxies key in
config is a silently-ignored legacy field, and there is no
src/gateway/trusted_proxy.rs. Keeping the loopback check on the raw
peer is deliberate: it means is_loopback (the zero-config-operator
grant) can never be forged by a spoofed X-Forwarded-For header.
The trade-off: when the gateway is fronted by a reverse proxy, every
client collapses to the proxy's socket address, so the per-IP
protections bound the proxy rather than individual clients. Terminate
client-identity trust upstream (the proxy) if you need per-client limits,
and treat the Gateway token as the transport auth. (Restoring
fail-closed, allowlist-gated trusted-proxy XFF resolution — never
letting a forwarded header influence is_loopback — is tracked as a
future enhancement.)
WS Origin check (last guardrail)
Browsers attach an unforgeable Origin header to every WebSocket
upgrade and cross-origin fetch. A malicious public web page the user
happens to visit can still reach ws://127.0.0.1:18790/ws (loopback
is not firewalled from the browser), so without an origin check it
could open a control channel to the local daemon — the
cross-origin-WebSocket confused-deputy (and its DNS-rebinding variant).
The origin gate (src/gateway/origin_policy.rs) is therefore retained
as the only validation on the browser surface. It guards against
the public internet, not against LAN neighbours.
OriginPolicy::is_allowed decides:
| Origin | Verdict | Why |
|---|---|---|
| absent / empty | allow | Native clients (CLI, bots, bridges, tokio-tungstenite) send none; only browsers do. |
loopback (127.0.0.0/8, ::1, localhost, *.localhost) | allow | Same-machine UI. |
tauri: scheme | allow | The desktop shell's own webview origin, unspoofable by a remote page. |
exact allow-list match ([gateway] allowed_origins) | allow | Operator-configured extra origins for split panel / API deployments. |
same-origin (Origin authority == request Host) | allow only if the Host is an IP literal or loopback | LAN deployments reached by IP (http://10.10.10.6:18790) work without config; an IP literal cannot be DNS-rebound. A domain Host is not auto-allowed — the deployment must add its origin to allowed_origins (see DNS-rebinding note below). |
| anything else (public web domain) | deny |
DNS-rebinding — defended. A classic DNS-rebinding attack rebinds a domain (
evil.com) to the gateway's own address so the victim's page carriesOrigin == Host == evil.comand would slip past a naive same-origin check. Aleph closes this by gating the same-origin branch on theHost: same-origin is auto-allowed only when theHostis an IP literal or loopback (127.0.0.0/8,::1,localhost,*.localhost, or a bare IPv4 / IPv6 address). A rebinding attack must use a domain name (the A record is what gets rebound), and a domainHostno longer passes same-origin — it falls through to deny. The trade-off: a zero-config domain deployment (serving the panel fromaleph.example.comwith noallowed_origins) is now rejected and must add its origin to[gateway] allowed_origins. LAN deployments reached by IP and loopback access are unaffected.
Escape hatch — allow_any_origin. Set [gateway] allow_any_origin = true to trust every Origin unconditionally
(OriginPolicy::allow_any). Intended only for deployments that front
the gateway with their own reverse proxy / auth layer; it leaves the
agent drivable by any web page the user's browser visits, so keep it
false unless you know why.
Rate limiting and abuse protection
- Global
max_connectionscap. - Per-IP concurrent-connection cap (
gateway.max_connections_per_ip, default 64,0disables, loopback exempt) — bounds slot exhaustion by a remote peer opening many idle sockets. - Flood guard (
src/gateway/server/flood_guard.rs) — a remote connection that keeps probing (repeated unauthorizedconnect) is closed. - Per-scope rate limiter (
Auth-scope and others).
Connections closed for AuthFailure / RateLimited are recorded in the
security audit log (src/security/audit.rs).
Audit and trace correlation
Each JSON-RPC request resolves a
W3C traceparent: an inbound
params.traceparent is honoured (its trace id adopted), otherwise a
fresh 128-bit root trace is minted. The dispatch chokepoint opens a
tracing span carrying trace_id / span_id, and the response echoes a
traceparent naming the server's span as the parent so a multi-hop call
graph stitches together. This is a lightweight propagation layer
(src/gateway/trace_context.rs), not an OpenTelemetry integration —
the OTel SDK would violate core minimalism (R3) for what is, given
Aleph's own trace persistence and tracing logging, a correlation
feature.
The JSON-RPC middleware chain is built once at server construction and cloned per connection. Building it per connection previously reinstalled the global request-state registry on every connect, zeroing the
/metricsrequest-lifecycle counters and undercounting in-flight requests.
Metrics + health
Gateway exposes:
GET /health— liveness probe.GET /ready— readiness probe.GET /metrics— Prometheus text exposition (v0.0.4) of request-lifecycle counters, connection gauges, rate-limiter pressure, and a request-duration histogram (aleph_gateway_request_duration_ms, fed by the per-requestelapsed_msthe metrics middleware already measures); exports only aggregate counts (no payloads / secrets), unauthenticated like the probes. Implemented insrc/gateway/server/metrics_endpoint.rs+src/gateway/middleware/latency.rs.
See also
- Security Overview — network boundary + Gateway token
- Execution Approval — routed via the
exec.approval.*RPC - Pairing — bootstrap tickets + device tokens
- Sandboxing — OS-level enforcement