Aleph
Security

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's WebSocketUpgrade)
  • WS /ws route: 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.resolve also accepts an optional reason)
  • 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:

  1. loopback ⇒ operator (no credential);
  2. device token (aleph-dt-*) — long-lived, bound to one paired device;
  3. bootstrap ticket (aleph-bt-*) — 5-minute single-use, exchanged during the handshake for a fresh device token;
  4. 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_ticket on first pairing (receiving a device_token back), then device_token on every reconnect. token (the legacy shared Gateway token) is accepted as a fallback.

device_id is client-asserted and the devices table shares one namespace with cluster nodes, so the exchange refuses a device_id that already names a non-Panel device (and cluster::admit_node refuses 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)

MethodDescriptionParameters
connectLAN-trust handshake (remote requires credential)device_token / bootstrap_ticket / token
agent.runStart agent executionmessage, session_key, thinking?, model?, exec_tier?
agent.statusGet run statusrun_id
agent.cancel / agent.abortCancel / force-abort runrun_id
session.get / session.list / session.historySession queriessession_key, limit?
session.compact / session.deleteSession managementsession_key
config.get / config.patch / config.apply / config.reloadConfig management
events.subscribe / events.unsubscribe / events.listEvent subscriptionpattern (glob)
exec.approval.list / exec.approval.resolveApproval decisions{outcome, reason?}
memory.store / memory.search / memory.delete / memory.statsLong-term memory
browser.navigate / browser.click / browser.type / browser.screenshot / browser.evaluatePer-action browser entry points
mcp.*start / stop / list / call
gateway.ticket.create / gateway.devices.* / gateway.token.rotateDevice / token management
runs.*list / status / wait / queue
models.* / generation.* / cron.* / plugins.* / skills.* / interfaces.*Per-domain RPC

Event topics

Subscribe with glob patterns:

PatternEvents
stream.*All streaming events
stream.chunkText chunks
stream.agent_traceStructured, loop-originated execution trace
stream.tool_start / stream.tool_endTool execution boundaries
agent.*Agent lifecycle
agent.started / agent.completed / agent.errorAgent 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-loopback host with no native TLS, no trusted proxy, and allow_insecure_remote = false refuses to start with an actionable error. (This is the one intentional breaking change — a previously-working host = "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 with 426 Upgrade Required, even if the boot gate passed on a permissive combo. "Encrypted" means native TLS terminated in-process, or a trusted proxy that set X-Forwarded-Proto: https.

Three ways to satisfy it:

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 / -Proto
your.domain.com {
    reverse_proxy 127.0.0.1:18790
}

Why trusted_proxy is security-critical here, not just cosmetic. The proxy connects to Aleph over loopback, so without trusted_proxy every remote client would appear to Aleph as 127.0.0.1 — i.e., auto-authorized as loopback operator, a full auth bypass. With trusted_proxy = true Aleph reads the real client IP from X-Forwarded-For (spoof-safe: only a peer in trusted_ips is 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 setting trusted_proxy is 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 clients

Restores 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 /ws and performs the Panel-device handshake (device token / bootstrap ticket).
  • The center delegates agent.run to the node.
  • exec.approval.resolve on the node accepts an optional reason parameter (the ApprovalRequester trait now returns outcome + reason; transports that cannot carry one use From<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:

OriginVerdictWhy
absent / emptyallowNative clients (CLI, bots, bridges, tokio-tungstenite) send none; only browsers do.
loopback (127.0.0.0/8, ::1, localhost, *.localhost)allowSame-machine UI.
tauri: schemeallowThe desktop shell's own webview origin, unspoofable by a remote page.
exact allow-list match ([gateway] allowed_origins)allowOperator-configured extra origins for split panel / API deployments.
same-origin (Origin authority == request Host)allow only if the Host is an IP literal or loopbackLAN 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 carries Origin == Host == evil.com and would slip past a naive same-origin check. Aleph closes this by gating the same-origin branch on the Host: same-origin is auto-allowed only when the Host is 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 domain Host no longer passes same-origin — it falls through to deny. The trade-off: a zero-config domain deployment (serving the panel from aleph.example.com with no allowed_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_connections cap.
  • Per-IP concurrent-connection cap (gateway.max_connections_per_ip, default 64, 0 disables, 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 unauthorized connect) 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 /metrics request-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-request elapsed_ms the metrics middleware already measures); exports only aggregate counts (no payloads / secrets), unauthenticated like the probes. Implemented in src/gateway/server/metrics_endpoint.rs + src/gateway/middleware/latency.rs.

See also

On this page