System design

No domain. No drama.
No inbound ports.

Lillith is a single Rust service and a Windows agent. Agents dial out, the server keeps the durable truth, and the browser talks to the same process that holds the fleet. Here is how the pieces fit.

Topology

Three sites, one control plane.

No sharding, no message broker, no external database. For a fleet of tens to a few hundred Windows machines, a single server is the correct amount of infrastructure.

Site A · B · C

lillith-agent

One binary, two roles: boot-time SYSTEM service and per-user tray. Managed features are Windows-oriented.

Linux · :443

lillithd

Axum web app, WebSocket hub, job queue, inventory cache, SQLite, ACME, and embedded UI.

Admin browser

Web UI

Server-rendered pages and live updates. Local sessions or Microsoft 365 OIDC with role enforcement.

1 persistent socket / machine 20s heartbeat 60s offline threshold 60s reconnect cap
Operating envelope

Constraints that shaped the design.

These are not limitations bolted on afterward. They are the reason the system looks the way it does.

No domain

No GPO, so the agent is the management fabric. Local accounts and per-machine installs, with no dependency on Active Directory.

Clients behind NAT

Every connection is initiated by the agent. Nothing listens on Windows, and no VPN is required for the protocol itself.

~75 machines

A single server is sufficient. No queue infrastructure, no sharding, no distributed state to keep consistent.

Windows clients, Linux server

The agent is cross-compiled for x86_64-pc-windows-gnu; the server is a static Linux binary.

RustDesk for remote access

The agent reports the RustDesk ID. Lillith adds its own ephemeral VNC path for in-browser sessions.

No arbitrary shell

There is no exec(string) anywhere in the protocol. Every operation is a named, typed request/response pair.

Wire protocol

JSON over WebSocket, checked at compile time.

A shared protocol crate defines every message and operation shape, so server and agent cannot drift apart without a build failure.

Capabilities drive the UI

The agent advertises its operations in Hello. The server stores that list per machine and never offers an operation the connected agent did not advertise. That single rule makes incremental agent rollout safe.

Adding optional fields is a minor change. Changing semantics bumps op_version, and the agent registers handlers per operation and version.

Loss of contact is expected. Heartbeats run every 20 seconds; the server marks a machine offline after 60 seconds. Reconnects use exponential backoff with full jitter, capped at 60 seconds, so 75 clients do not stampede after a restart.
crates/protocol · message envelopes
// JSON over WSS. PROTOCOL_VERSION = 1.
pub enum AgentMsg {
    Hello(Hello),
    Heartbeat { at: i64 },
    JobAck { id: String },
    JobProgress {
        id: String,
        phase: String,
        pct: Option<u8>,
        detail: Option<String>,
    },
    JobResult(JobResult),
}

pub enum ServerMsg {
    Welcome(Welcome),
    Job(Job),
    CancelJob { id: String },
    UpdateOffer { version: String, sha256: String },
}

pub struct Hello {
    pub protocol_version: u16,
    pub agent_version: String,
    pub capabilities: Vec<Capability>,
    pub fingerprint: String,
}

Connection lifecycle

Dial out

Agent opens GET /agent/connect with its machine secret as a bearer credential.

Handshake

Hello carries version, capabilities, and fingerprint. The server answers Welcome with the advertised agent version and binary offer.

Register

The live registry maps machine ID to a channel. SQLite remains the source of truth; the registry only answers "who is connected right now."

Operate

Heartbeats every 20s. Jobs stream progress; results close the loop. A 10-second watchdog drives presence events in the UI.

Reconnect

Exponential backoff with full jitter, capped at 60s. Queued jobs send when the host returns; expired ones are marked, not run late.

Job lifecycle

Queued

The job row is written on request, with arguments, requester, and deadline recorded up front.

Sent

Delivered over the host's socket. The timeout clock starts only when the job is actually sent.

Running

JobAck and JobProgress append output and percentages for the live UI.

Closed

ok, error, timeout, cancelled, or expired — with duration and error detail.

Audited

Every transition is written to the audit log. Failed and expired jobs can be retried with their original arguments.

Storage

SQLite is the source of truth.

Machines, inventory snapshots, enrollment tokens, jobs, packages, users, sessions, audit rows, tags, preferences, and retention settings all live in one WAL-mode database.

Backups use VACUUM INTO for a consistent snapshot while the server is running. Restore validates the manifest, checks integrity and schema, rejects unsafe paths, and stages data for the next boot so the live pool is never swapped underneath connections.

  • Numbered SQL migrations, applied at startup.
  • Retention limits for jobs, events, audit rows, and screenshots.
  • Inventory snapshots, not a change stream.
  • Server-side preferences per account.
schema · excerpt
CREATE TABLE machines (
  id                 TEXT PRIMARY KEY,
  fingerprint        TEXT UNIQUE,
  hostname           TEXT,
  site               TEXT,
  agent_version      TEXT,
  protocol_version   INTEGER,
  capabilities_json  TEXT,
  rustdesk_id        TEXT,
  os_name            TEXT,
  os_build           TEXT,
  first_seen         INTEGER,
  last_seen          INTEGER,
  secret_hash        TEXT,  -- Argon2id
  notes              TEXT
);

CREATE TABLE jobs (
  id            TEXT PRIMARY KEY,
  machine_id    TEXT,
  op            TEXT,
  op_version    INTEGER,
  args_json     TEXT,
  status        TEXT,
  requested_by  TEXT,
  requested_at  INTEGER,
  started_at    INTEGER,
  finished_at   INTEGER,
  error         TEXT,
  duration_ms   INTEGER
);
Foreign keys and indexes are added by the migrations in migrations/.
Security model

Powerful by necessity. Narrow by design.

An agent secret is effectively remote code execution on that machine. The architecture treats it that way.

Agent & supply chain

  • Per-machine secrets, Argon2id-hashed at rest.
  • Single-use, site-scoped enrollment tokens, hashed and shown once.
  • Typed operation allowlist; no shell primitive exists.
  • Mandatory SHA-256 for packages and agent updates.
  • DPAPI machine-scope blob protects the agent secret.
  • Agents verify a real TLS WebSocket handshake before switching to HTTPS.

Server & administrative access

  • Argon2id local accounts; Entra ID OIDC with app-role mapping.
  • Admin and viewer roles enforced on every mutating request.
  • CSRF tokens, SameSite=Lax, login rate limiting.
  • In-process ACME TLS, HSTS, and automatic Secure cookies.
  • One-hour default sessions with immediate revocation.
  • Hardened systemd unit and an audit row for every change.
Accepted risk: Authenticode verification for packages and the agent is deliberately deferred. Compensating controls are admin-only upload, a mandatory SHA-256, and a single controlled supply path through lillithd.
Decision log

What was chosen, and why.

The short version of the architecture record. Each decision trades optionality for operational calm.

DecisionChoiceRationale
LanguageRust for agent and serverOne language, a shared protocol crate, and a static Windows executable.
TransportClient-initiated WSSThree sites behind NAT; no inbound ports and no VPN dependency for the protocol.
OperationsTyped catalog, no shellSafety by construction; "disabled" is enforced structurally.
StorageSQLite75 machines. No operational justification for Postgres.
TLSLet's Encrypt DNS-01, no pinningA valid public certificate on a VPN-only host; no fleet-wide cert rotation.
AuthLocal first, Entra OIDC secondDevelopment is not blocked on unknown tenant permissions.
PackagesServed only by lillithdOne controlled, auditable supply path; no raw-URL install primitive.
Make it real

Read the guide, then point it at a box.

Build requirements, deployment steps, environment variables, and the first admin account are all in the docs.