No domain
No GPO, so the agent is the management fabric. Local accounts and per-machine installs, with no dependency on Active Directory.
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.
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.
One binary, two roles: boot-time SYSTEM service and per-user tray. Managed features are Windows-oriented.
Axum web app, WebSocket hub, job queue, inventory cache, SQLite, ACME, and embedded UI.
Server-rendered pages and live updates. Local sessions or Microsoft 365 OIDC with role enforcement.
These are not limitations bolted on afterward. They are the reason the system looks the way it does.
No GPO, so the agent is the management fabric. Local accounts and per-machine installs, with no dependency on Active Directory.
Every connection is initiated by the agent. Nothing listens on Windows, and no VPN is required for the protocol itself.
A single server is sufficient. No queue infrastructure, no sharding, no distributed state to keep consistent.
The agent is cross-compiled for x86_64-pc-windows-gnu; the server is a static Linux binary.
The agent reports the RustDesk ID. Lillith adds its own ephemeral VNC path for in-browser sessions.
There is no exec(string) anywhere in the protocol. Every operation is a named, typed request/response pair.
A shared protocol crate defines every message and operation shape, so server and agent cannot drift apart without a build failure.
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.
// 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,
}
Agent opens GET /agent/connect with its machine secret as a bearer credential.
Hello carries version, capabilities, and fingerprint. The server answers Welcome with the advertised agent version and binary offer.
The live registry maps machine ID to a channel. SQLite remains the source of truth; the registry only answers "who is connected right now."
Heartbeats every 20s. Jobs stream progress; results close the loop. A 10-second watchdog drives presence events in the UI.
Exponential backoff with full jitter, capped at 60s. Queued jobs send when the host returns; expired ones are marked, not run late.
The job row is written on request, with arguments, requester, and deadline recorded up front.
Delivered over the host's socket. The timeout clock starts only when the job is actually sent.
JobAck and JobProgress append output and percentages for the live UI.
ok, error, timeout, cancelled, or expired — with duration and error detail.
Every transition is written to the audit log. Failed and expired jobs can be retried with their original arguments.
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.
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
);
migrations/.
An agent secret is effectively remote code execution on that machine. The architecture treats it that way.
SameSite=Lax, login rate limiting.lillithd.
The short version of the architecture record. Each decision trades optionality for operational calm.
| Decision | Choice | Rationale |
|---|---|---|
| Language | Rust for agent and server | One language, a shared protocol crate, and a static Windows executable. |
| Transport | Client-initiated WSS | Three sites behind NAT; no inbound ports and no VPN dependency for the protocol. |
| Operations | Typed catalog, no shell | Safety by construction; "disabled" is enforced structurally. |
| Storage | SQLite | 75 machines. No operational justification for Postgres. |
| TLS | Let's Encrypt DNS-01, no pinning | A valid public certificate on a VPN-only host; no fleet-wide cert rotation. |
| Auth | Local first, Entra OIDC second | Development is not blocked on unknown tenant permissions. |
| Packages | Served only by lillithd | One controlled, auditable supply path; no raw-URL install primitive. |
Build requirements, deployment steps, environment variables, and the first admin account are all in the docs.