Remote daemon & pairing
The daemon exposes the otherwise single-process engine as an HTTP JSON + SSE
service. It is built into the shipped zlogic binary — zlogic daemon starts
it. The same startup implementation also builds as a standalone zlogic-daemon
binary, for hosts that want the service without the TUI.
Start locally
zlogic daemon
By default the daemon listens on 127.0.0.1 on a random free port (no fixed
port is occupied; pass --listen 127.0.0.1:7331 to pin one). The startup log
prints the actual port. When no explicit port is given, the last successful port
is saved in <state>/daemon/listen.json and reused on later starts, so paired
clients keep working across restarts.
Local native clients can access the daemon without a token. Requests that carry a
browser Origin are rejected, so an arbitrary web page cannot drive your local
daemon.
Start remotely and pair a device
zlogic daemon \
--listen 0.0.0.0:7331 \
--public-url https://192.168.1.10:7331 \
--pair
Remote mode:
- generates and persists a self-signed TLS certificate under
<state>/daemon/tls/; - prints the certificate's SHA-256 fingerprint, a one-time secret valid for 5 minutes, and a QR code;
- accepts
/apiand SSE requests only from clients holding a paired device token.
The QR code encodes a zlogic://pair?… URI carrying:
url— the daemon's HTTPS address;secret— a 256-bit, single-use pairing secret;fingerprint— SHA-256 of the server certificate's DER.
A client must pin the TLS certificate to fingerprint, then claim the secret:
POST /pairings/claim
Content-Type: application/json
{"secret":"<one-time-secret>","device_name":"Alice's phone"}
The returned token appears exactly once. The server stores only its SHA-256;
the client should keep the plaintext in its OS keyring. Subsequent requests use:
Authorization: Bearer <device-token>
Wrong secrets are rate-limited and can never destroy a still-valid offer for the real user; a correct secret is consumed before anything is written to disk, so it cannot be replayed.
Remote first start requires
--pair: the daemon refuses to listen externally with no paired devices unless you pass--pair.
Managing paired devices
zlogic daemon devices # list paired devices
zlogic daemon revoke <device-id> # revoke one
zlogic daemon doctor # diagnostics: state dir writable, device count, port, protocol
The remote API also offers:
GET /api/devicesDELETE /api/devices/{device_id}POST /api/pairing-offers— issue a new offer while the daemon is running (body{"scopes":["read"]},{"scopes":["read","write"]}, or{"scopes":["full_control"]})
Every request is checked against the device scope: queries need read, ordinary
mutations write, configuration/credentials/extensions/device management admin;
full_control alone stands for everything. Devices live for 90 days; expired
tokens fail closed.
The daemon options
| Option | Default | Description |
|---|---|---|
--listen <IP:PORT> |
127.0.0.1 + random port |
listen address; the port is saved after the first start and reused |
--public-url <URL> |
— | address advertised in the QR code; required and must be HTTPS when listening remotely |
--pair |
off | generate a one-time secret and QR code (valid 5 minutes) |
--tls-cert <PEM> / --tls-key <PEM> |
— | use your own TLS certificate instead of the auto-generated one (both required together) |
--public-certificate-sha256 <HEX> |
— | reverse-proxy certificate fingerprint to embed in the pairing QR |
--max-upload-mib <MIB> |
2048 |
per-attachment upload limit (1..=1048576) |
--dangerously-skip-permissions |
off | skip engine permission confirmations |
HTTP protocol
- Health:
GET /health - Control plane:
/api/… - Object upload:
POST /api/objects(raw body; uploads can also be chunked and resumed via/api/uploads) - Session turn stream:
GET /api/sessions/{session_id}/events(SSE; resumable via standardLast-Event-ID, replaying bufferedStreamEvents before live) - Global notices:
GET /api/events/notices - Workspace initialization progress:
GET /api/events/workspace-initializations - Liveness / readiness:
GET /livez/GET /readyz(503while draining or when the device store / upload dir is broken) - Metrics:
GET /api/metrics(Prometheus text; paired devices only)
A full engine API is available homomorphically at POST /api/rpc/{engine_method},
and protocol Commands can be sent verbatim to POST /api/commands.
Pointing the desktop app at a daemon
Without a configured daemon the desktop runs the engine in-process. To switch to remote, open Settings → Run engine and pick Remote daemon, then add the daemon:
| Field | What to put |
|---|---|
| Name | display name, e.g. home-server |
| URL | https://192.168.1.10:7331 (plain http://127.0.0.1:7331 is only allowed for loopback) |
| TLS certificate SHA-256 | the fingerprint printed by zlogic daemon --pair (comma/space separated list — pinning, not CA trust) |
| Device name | shown on the daemon side (client <name> connected) |
| One-time pairing secret | pasted only when connecting the first time |
The pairing secret is write-only and never persisted: it is passed to the
native layer once when you click Connect, used to claim a token, and the
token then lands in the OS keyring (keyring:desktop-daemon-<url-hash> — derived
from the URL, no config needed). Test does a read-only health + token check —
it never pairs and never changes state. Startup never pairs either; an unpaired
profile only pairs when you click Connect.
When the daemon rejects a stored token (revoked device, 90-day expiry, reset daemon), the desktop asks you to re-pair — it does not silently claim again with a stale secret. And if an explicitly configured remote daemon is unreachable, the desktop fails closed instead of quietly falling back to the local engine.
Under the hood the same settings live in ~/.config/zlogic/desktop.yaml
(backend: block: mode, active, daemons[].url/name/certificate_sha256/ device_name). Prefer the UI; if you hand-edit the file, keep field names
snake_case and never put a pairing_secret in it — it is ignored.
Operations notes
- A remote-first start with a self-signed cert prints its TLS SHA-256; clients must verify the fingerprint embedded in the QR code.
- Behind a reverse proxy: keep the daemon on loopback, set
--public-urlto the proxy's HTTPS URL and--public-certificate-sha256to the proxy cert fingerprint; device tokens are still enforced. - On SIGINT/SIGTERM the daemon drains: it rejects new writes, cancels live turns, then gives in-flight requests up to 30 seconds to finish.
- Chunked network uploads are capped at 16 MiB per chunk regardless of
--max-upload-mib.