guide

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:

  1. generates and persists a self-signed TLS certificate under <state>/daemon/tls/;
  2. prints the certificate's SHA-256 fingerprint, a one-time secret valid for 5 minutes, and a QR code;
  3. accepts /api and 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/devices
  • DELETE /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 standard Last-Event-ID, replaying buffered StreamEvents before live)
  • Global notices: GET /api/events/notices
  • Workspace initialization progress: GET /api/events/workspace-initializations
  • Liveness / readiness: GET /livez / GET /readyz (503 while 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-url to the proxy's HTTPS URL and --public-certificate-sha256 to 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.