Vervain (botanical): An old herb of altars, said to grant clear sight. Mascot: a long-eared hare at a slim purple stalk. Tagline: See clearly.
  • Rust 97.6%
  • Just 1.6%
  • Dockerfile 0.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
David d420ab6c79
Some checks failed
Publish crates / cargo publish (Forgejo Cargo registry) (push) Successful in 13s
Check / fmt + clippy + tests (push) Successful in 24m50s
Build Linux package / Build vervain-agent and publish generic package (push) Successful in 21m48s
Build Windows agent / Cross-compile, sign, and publish vervain-agent.exe (push) Successful in 32m56s
Build macOS package / Build vervain-agent (macOS) and publish generic package (push) Has been cancelled
Check macOS features / cargo check --features agent-gui (aarch64-apple-darwin) (push) Has been cancelled
Check macOS features / cargo check --features agent-gui (x86_64-apple-darwin) (push) Has been cancelled
Merge pull request 'Release v0.5.0' (#158) from release/v0.5.0 into main
Reviewed-on: #158
2026-08-10 05:36:45 +02:00
.cargo feat(cargo): publish crates to public psa-systems registry 2026-07-01 12:10:20 -04:00
.forgejo/workflows fix(ci): run publish-crates on the dev runner image 2026-08-08 12:50:12 -04:00
.idea chore(rebrand): replace mesh with vervain (VA-28) 2026-05-20 07:07:39 -04:00
crates feat(protocol): advertise VNC and MCP capability bits 2026-07-28 06:51:14 -04:00
docs feat(protocol): native macOS arm64 support, Vervain-native AgentId 2026-07-27 22:44:34 -04:00
oci-build feat(ci): sign and publish the Windows agent 2026-07-07 22:04:34 -04:00
.dockerignore feat(gui): native assistance-mode GUI in egui, Linux first (VA-24) 2026-06-13 15:53:55 +02:00
.env.example feat(dev): mTLS enrollment dev loop; retire dev-insecure plain-WS 2026-06-28 09:32:35 -04:00
.gitignore feat(dev): mTLS enrollment dev loop; retire dev-insecure plain-WS 2026-06-28 09:32:35 -04:00
Cargo.lock Release v0.5.0 2026-08-09 14:55:25 -04:00
Cargo.toml Release v0.5.0 2026-08-09 14:55:25 -04:00
CLAUDE.md fix(release): make create-release re-runnable 2026-08-09 15:12:52 -04:00
compose.yml feat(dev): mTLS enrollment dev loop; retire dev-insecure plain-WS 2026-06-28 09:32:35 -04:00
Dockerfile feat(windows): host the agent as a Windows service (SCM) 2026-06-12 20:20:02 +02:00
justfile fix(release): guard the hoisted git remote get-url call 2026-08-09 15:21:07 -04:00
README.md feat(agent): attach to active X11 session for the desktop tab 2026-06-30 16:41:21 -04:00
ROADMAP.md fix(package): stable tracks SemVer releases, latest tracks main 2026-06-28 22:09:27 -04:00
rust-toolchain.toml ci: build from rust-builder-* (rust 1.94) 2026-05-10 08:07:18 -04:00

Vervain Agent (Rust)

A Rust agent for the Vervain remote-management platform. Single-language, async, memory-safe; ships as one static binary.

Status

Phase 1 (control-channel MVP) is functional against a stub server. See ROADMAP.md for the phased plan.

What works today:

  • Encrypted local persistence (VA-68): config + identity + the enrolled mTLS cert/key + server CA in two SQLCipher (encrypted SQLite) databases, unlocked by the VERVAIN_DB_KEY environment variable set in the service unit. See docs/local-settings-store.md.
  • Wire-protocol type definitions and binary handshake encode/decode (vervain-agent-protocol), with round-trip tests against the documented packed-struct layouts.
  • vervain-agent --check validates the config database (read-only: never creates a database) and exits.
  • vervain-agent config subcommand to inspect and edit the config database: show [--reveal], get <key>, set <key> <value>, and import --from <file>. See Configuration.
  • Persistent identity: RSA-3072 self-signed root cert generated and stored in the encrypted SQLite data database.
  • TLS plus WebSocket transport with the secondary handshake state machine: AgentCommitDate, HostInfo, ServerId, AuthRequest, AuthVerify, AuthInfo, AuthConfirm. Server SPKI is pinned from the ServerID key in the settings store; the agent verifies the server's RSA-PKCS1v1.5 signature against that pin.
  • Idle loop: post-auth, the agent answers CoreModuleHash (11) with a 4-byte "no core" reply and logs other server traffic. CoreModule (10) and CompressedCoreModule (20) pushes are explicitly ignored (see "No JS core" decision below).
  • Self-update receiver (Phase 2 scaffold): AgentHash (12) replies with the SHA-384 of the running binary, with any config block embedded in the executable (the appended-config sentinel, distinct from the on-disk settings store) stripped before hashing. AgentUpdate (13) and AgentUpdateBlock (14) frames stage an .update file beside the agent and verify its hash on End. The disableUpdate, forceUpdate, and logUpdate flags from the settings store are honored. Atomic binary swap and restart is not yet implemented; verified payloads stay staged on disk for now.
  • Integration tests in crates/vervain-agent/tests/handshake.rs: spin up a stub TLS plus WebSocket server and drive the agent through the full handshake. Four scenarios: happy path, ServerID pin mismatch, bad server signature, and AgentHash query/response after auth.

What does not work yet: applying a verified self-update (swap and restart), JSON command dispatch, host info collection beyond hostname/OS/arch, KVM, terminal, file transfer. Running vervain-agent against a real Vervain server is not supported until the paired vervain-server repo lands its control-channel implementation.

Build and test

cargo build --workspace
cargo test --workspace
cargo clippy --workspace --all-targets -- --deny warnings
cargo fmt --all --check

The toolchain is pinned via rust-toolchain.toml to match what CI will eventually use. Override with RUSTUP_TOOLCHAIN=... only when intentionally testing a different version.

Local development (mTLS enrollment)

just dev brings a hot-reloading agent online against the local vervain-server dev stack. The agent enrolls over the server's mTLS port with a server-issued client certificate (VA-67); there is no plain-WebSocket path.

just dev-enroll-bundle   # mint a one-time enrollment bundle into run/ (server must be up)
just dev                 # build + run the container agent; auto-mints on first run

The agent enrolls on the first run and reuses the identity stored in run/vervain-agent.db afterward. Full flow, prerequisites, and the native just dev-host path: docs/dev-enrollment.md.

Configuration

The agent reads its runtime configuration from an encrypted SQLite database, not a plaintext file. Full design and threat model: docs/local-settings-store.md.

The agent keeps two SQLCipher (encrypted SQLite) databases, both unlocked by the VERVAIN_DB_KEY environment variable (set in the service unit at install):

  • config.db under the config directory (chosen with --settings <dir>, default ~/.config/vervain-agent): the flat settings table.
  • vervain-agent.db under the data directory (chosen with --data-dir <dir>, default ~/.local/share/vervain-agent): the agent identity, the enrolled mTLS cert/key + server CA, and runtime keys.

The config database carries the same keys the .vagent did, e.g. the required ServerURL, ServerID, GroupID, plus optional tunables (enableMdns, disableUpdate, backoffInitialMs, idleTimeoutSecs, ...).

Bootstrap or reconfigure from a plaintext .vagent-format seed file:

vervain-agent config import --from server-issued.vagent --settings /etc/vervain-agent

Inspect and edit (credential-bearing values are masked unless --reveal):

vervain-agent config show --settings /etc/vervain-agent
vervain-agent config get ServerURL --settings /etc/vervain-agent
vervain-agent config set enableMdns 0 --settings /etc/vervain-agent

Bootstrap is by import, not migration: on first run against a config directory that holds a vervain-agent.vagent seed but an empty config database, the agent imports the seed into the encrypted config.db. There is no migration from the old sled / .vss stores. A missing or wrong VERVAIN_DB_KEY makes the databases unreadable; greenfield, the agent then re-enrolls. --check is read-only and never creates a database.

vervain-agent install --from <seed> imports the seed into the encrypted config.db, generates a fresh VERVAIN_DB_KEY, and writes a systemd unit that sets it via Environment= and runs with --settings. The install target is chosen with --install-type {user|system}; it defaults to system.

--install-type system is a system-wide install: the binary lands at /usr/local/sbin/vervain-agent, the config database under /etc/vervain-agent/, and /etc/systemd/system/vervain-agent.service running as root that survives reboots. Running as root (with no ProtectHome/ProtectSystem sandbox and no dedicated user) lets the agent attach to the active user's graphical session, which is what the desktop tab (KVM, over Wayland or X11) needs to drive the already-logged-in desktop regardless of who is logged in. This needs root, so run it under sudo; invoked without root it exits non-zero and tells you to either re-run with sudo or pass --install-type user, rather than silently downgrading to a session unit. A locked-down least-privilege unit is out of scope: script your own unit file if you need one.

--install-type user is a per-user install (no root) at ~/.config/systemd/user/vervain-agent.service. The user unit runs inside the calling user's session and drives only that user's desktop. Reach for it when you do not want a root service; prefer the system default everywhere else. vervain-agent uninstall mirrors the same default (--install-type system unless you pass --install-type user).

Server URL and cert pinning under TLS offload (VA-66)

ServerURL (and the server-issued GroupServer) target the API server on the /api/v1/agent control path, e.g. wss://api.vervain.a8n.systems/api/v1/agent (dev connects over mTLS to dev-vervain-server-app-dev:15443; see docs/dev-enrollment.md). Agents connect straight to the API host, never the browser frontend (parent epic VS-54).

The handshake has two independent pins, so it stays correct when a reverse proxy terminates TLS in front of the server (VERVAIN_TLS_MODE=offload):

  • ServerID (signing identity). The agent pins the SHA-384 of the server's signing SPKI from the ServerID key in its store and verifies the server's RSA-PKCS1v1.5 signature against it. This is the server's own identity, not the TLS certificate, so it is unaffected by who terminates TLS.
  • TLS leaf hash. The agent's TLS verifier accepts the presented leaf but captures its SHA-384; the server signs over SHA384(leaf_hash || agent_nonce || server_nonce), binding the proven identity to the exact certificate the agent saw. Under offload the agent sees the proxy's certificate, so the server must sign that same hash. It does: configure VERVAIN_CERT_URL on the server to point at the terminating proxy (VS-27) and the server fetches the proxy cert at startup and advertises its hash, so the agent's observed hash matches and the pin passes. Without VERVAIN_CERT_URL the server would sign its own web-cert hash while the agent sees the proxy's, and the handshake fails with ServerCertMismatch - the expected, fail-closed behavior. The cached server_tls_cert_hash is only a reconnect fast-path and does not weaken either pin.

Runtime dependencies (optional, per tunnel mode)

The agent itself ships as a single static binary. A few tunnel modes shell out to host-installed helpers; install them on the agent host if you intend to use the mode:

  • VNC bridge (USAGE_VNC = 6, see crates/vervain-agent/src/host/vnc.rs): x11vnc on X11 sessions, wayvnc on wlroots / KDE Wayland. The helper is selected at tunnel-open time from $WAYLAND_DISPLAY / $DISPLAY and is bound to 127.0.0.1 only.
  • RDP bridge (USAGE_RDP = 7, see crates/vervain-agent/src/host/rdp.rs): xrdp + xrdp-sesman running as a host service on 127.0.0.1:3389. The agent does not spawn or supervise the daemon; the host's init system (systemd / etc.) owns its lifetime. Install via your distribution's package manager (apt install xrdp, dnf install xrdp).
  • MCP server (USAGE_MCP = 8, see crates/vervain-agent/src/host/mcp.rs, crates/vervain-agent-mcp/): no host-side dependency. The agent runs an in-process MCP (Model Context Protocol) server speaking JSON-RPC 2.0 over the tunnel; one Text frame per JSON-RPC message. v1 (VA-26) exposes 17 read-only host inventory tools (osinfo, process_list, file_read, log_tail with streaming progress, etc.). v2 (VA-27, default-on mutating-tools feature) adds 9 mutating tools (exec, file_write, file_delete, services start/stop/restart, power_reboot, packages_install, process_kill) gated through the agent's consent provider (zenity / kdialog dialog by default, with per-tool argument scoping for file_write). Strictly read-only deployment: cargo build --no-default-features --features vervain-core.
  • Native RFB / VNC server (USAGE_VNC_NATIVE = 9, see crates/vervain-agent/src/host/vnc_native/): no helper binary. The agent speaks RFB 003.008 in-process, reusing the vervain-agent-kvm capture pipeline (X11 + Wayland-via-grim) and XTEST / uinput input. VeNCrypt-TLS is offered with a fresh self-signed cert per session; rustls + rcgen are pulled from existing workspace deps. Tested against TigerVNC; viewers that cannot accept a self-signed cert prompt should fall back to the plain "None" security path that this server also advertises.

Layout

.
├── Cargo.toml                          workspace manifest
├── rust-toolchain.toml                 pinned toolchain
├── ROADMAP.md                          phased migration plan
├── crates/
│   ├── vervain-agent/                  binary entry point (CLI, runtime bootstrap)
│   ├── vervain-agent-config/           encrypted settings store + required-key validation
│   ├── vervain-agent-kvm/              KVM (display, input, capture) primitives
│   ├── vervain-agent-mcp/              MCP (Model Context Protocol) server adapter
│   └── vervain-agent-protocol/         binary command codes and handshake structs

Today the vervain-agent crate has both a binary (src/main.rs, the CLI) and a library (src/lib.rs) that exposes hostinfo, identity, net, settings (config/data DB resolution and load), and store (the encrypted SQLite key-value data store) modules. Integration tests in crates/vervain-agent/tests/ drive that library directly.

Future crates (planned, not yet split out): vervain-agent-store (replacement for the data store), vervain-agent-net (TLS plus WebSocket transport plus secondary-cert handshake), vervain-agent-identity, vervain-agent-hostinfo, vervain-agent-update, vervain-agent-core (post-auth dispatcher). They live as modules inside vervain-agent for now and will be promoted to their own crates once each gains enough surface area to justify the boundary.

Design decisions

  • No embedded JavaScript runtime. Every behavior is implemented as a Rust module. The agent will not accept CoreModule or CompressedCoreModule payloads from the server and will not advertise Capabilities::JAVASCRIPT in AuthInfo.
  • Tokio (current-thread by default). Multi-threaded workloads (KVM tile encoding, large file transfers) move to spawn_blocking or dedicated rayon pools where they belong.
  • rustls plus webpki, not OpenSSL. The secondary cert pinning in the handshake is implemented above the TLS layer, so this doesn't affect protocol behavior.
  • Cert-pin under TLS offload. The pin is on the application-layer ServerID (SHA-384 of the server's agent-root cert SPKI), verified in the secondary handshake via the server's RSA-PKCS1v1.5 signature, not on the TLS transport cert. The TLS verifier (CapturingNoopVerifier in crates/vervain-agent/src/net.rs) accepts any transport cert and only captures the leaf for inspection. Consequently, when production terminates TLS at Traefik for api.vervain.a8n.systems (the server advertises a matching transport hash via VERVAIN_CERT_URL, see VS-27), the offload is transparent to the agent: it accepts the Traefik proxy cert at the transport layer, then the secondary handshake proves the backend server's ServerID end to end. The pin passes regardless of where TLS terminates because the pinned identity is the backend ServerID, never the proxy's transport cert.
  • tokio-tungstenite for WebSocket.
  • Encrypted SQLite (SQLCipher) for both config and data (VA-68). Two whole-database-encrypted SQLite files, unlocked by the VERVAIN_DB_KEY env var: the data vervain-agent.db holds the agent identity (RSA key + cert), node id, cached server cert hash, and the enrolled mTLS cert/key + server CA; the config config.db holds the flat settings table managed by vervain-agent-config (see Configuration and docs/local-settings-store.md). Replaces the former sled data store and the whole-file .vss settings blob.
  • Conditional compilation, not separate per-arch trees. cfg(target_os) and cfg(target_arch) gate platform code. Cross-compilation is cargo build --target=....

What is intentionally out of scope (initially)

  • Windows service hosting.
  • KVM Windows backend.
  • WebRTC. The agent will start as WebSocket-only; WebRTC data channels come back via webrtc-rs in a later phase.
  • Intel AMT/LMS.

These will come back, but pretending they're "v1" features makes v1 infinite. ROADMAP.md tracks when each rejoins.