- Rust 99.6%
- Nushell 0.2%
- Dockerfile 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
All checks were successful
Check / check (push) Successful in 8m16s
Reviewed-on: #118 |
||
| .forgejo/workflows | ||
| .idea | ||
| common@4de5dfa607 | ||
| crates | ||
| oci-build | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| .gitmodules | ||
| Cargo.lock | ||
| Cargo.toml | ||
| compose.dev.yml | ||
| compose.yml | ||
| Dockerfile | ||
| justfile | ||
| README.md | ||
| RMM.md | ||
| ROADMAP.md | ||
| rust-toolchain.toml | ||
Vervain Server
This repository holds Vervain, an in-progress server written in Rust.
It boots end-to-end and exercises most subsystems (auth, agent connect,
tunnels/KVM, relay, plugins, admin UI), but it is pre-1.0 and not yet
production-blessed. See ROADMAP.md for the subsystem map, current status
snapshot, and the remaining-work list.
Inspired by, and originally derived from, MeshCentral by Ylianst and maintained by Simon (si458).
All crates use the vervain- name prefix; the product, repo, and sibling
agent are branded Vervain. The native agent lives in the sibling
vervain-agent repository. Wire-protocol compatibility with the original
Node.js server is an explicit non-goal: the rewrite is free to evolve any
internal contract.
Why a rewrite
Quoting the task that started this work: many problems in the Node.js
server are hard to diagnose because the runtime, dynamic dispatch over
config-driven module loading, and the implicit parent-shared global
object obscure data flow. The goal of this project is a server whose
control flow can be read top-down from main, with explicit types on
every message that crosses a boundary.
Layout
vervain-server/
Cargo.toml workspace (30 crates under crates/)
rust-toolchain.toml pinned to 1.94
ROADMAP.md JS-file -> Rust-crate map and phase plan
crates/
vervain/ binary; orchestrator entrypoint
vervain-config/ config.json + vervain_* env loading
vervain-agent-proto/ agent control channel wire types
vervain-auth/ password hashing (pass.js compat),
WebAuthn / TOTP / OIDC / Duo
vervain-db/ sqlite/postgres-pluggable database layer
vervain-pki/ root CA + leaf cert generation
(replaces certoperations.js)
vervain-web/ axum HTTPS server, agent + relay WS,
user control channel, MCP gateway
vervain-admin/ Leptos admin UI (server-rendered view layer;
Tauri desktop/mobile shells are follow-ups)
Only a representative slice of the 30 crates is shown; ROADMAP.md covers
the wired subsystems and the remaining-work list across the rest.
Task runner
The justfile inherits every recipe from the shared psa-systems/common
runner, vendored as the common git submodule. After cloning, initialise it
so the import resolves:
git submodule update --init
To pull a newer common, move the submodule to the commit you want and commit
the new gitlink:
git submodule update --remote common
git add common
git commit --message "chore: bump common submodule"
just --list shows the inherited recipes. The justfile shadows nothing; it
only sets app, test_args, release_layout, and dev_extra_volumes. See
common/README.md for what each variable controls.
Build
Builds run inside the dev container (the host has no cargo on PATH):
just dev-local # build + run, watch-rebuild, http://localhost:4302
docker compose --file compose.dev.yml run --rm --no-deps app cargo build --workspace
docker compose --file compose.dev.yml run --rm --no-deps app cargo test --workspace
The toolchain is pinned to Rust 1.94 (rust-toolchain.toml); the workspace
MSRV is 1.93 (Cargo.toml).
Deploy (production)
compose.yml at the repo root is the canonical production deployment. It pulls
the published image and runs it with the image's own entrypoint, so the binary
name lives only in the Dockerfile. Do not add a command: or entrypoint:
override naming the binary: a stale command: meshcentral left over from the
rename is what crash-looped deployments after the upgrade (VS-43). Bring it up
with docker compose pull && docker compose up -d. The agent-binary fetcher and
its secret are optional; uncomment the VERVAIN_AGENT_FETCHER_* block (see the
overlay/fetcher sections below for the full variable list).
Coordination with the agent
The agent rewrite in the sibling vervain-agent repository defines the
wire protocol. This server's vervain-agent-proto crate must produce
and consume the same bytes. Until both projects publish a shared protocol
crate, keep the two in lockstep manually and call out any wire-level change
in the commit message of both repos (for example the /api/v1/* path
rename below, whose agent-side half ships under VA-28).
Agent wire paths (VS-24)
The two agent-facing WebSocket endpoints were renamed off the legacy
.ashx-suffixed handler names to versioned, vervain-branded
paths: the agent control channel is now /api/v1/agent and the per-tunnel
relay is /api/v1/relay (per-domain mounts prepend /<domain>). The relay
query parameters were renamed in the same pass: p became usage and
nodeid became agent_id (id, rauth, auth are unchanged). The old
paths were deleted outright with no compatibility alias; the canonical
strings live in vervain-web/src/wire_paths.rs, and the paired
agent-side change ships under VA-28. The /api/v1/ prefix lets a future
protocol revision ship under /api/v2/ without breaking v1 agents.
WebRTC ICE (STUN/TURN) configuration (VS-82)
The desktop tab upgrades its relayed WebSocket tunnel to a WebRTC data channel
for low-latency KVM traffic. When the agent and the browser sit on different
networks, host candidates cannot route (and the browser's mDNS .local
candidates never resolve across subnets), so both peers need a STUN server to
discover their server-reflexive (public) address, and a TURN server to relay
media for symmetric-NAT peers that STUN alone cannot traverse.
The server is the single source of truth for the ICE server list and hands the same list to both peers:
- to each agent, as an
{"action":"webrtcconfig","iceServers":[...]}control frame pushed on the control-channel handshake (vervain-web/src/agent.rs::push_webrtc_config); the agent forwards it into itsRTCConfiguration.ice_servers; - to the browser, inside the
serverinfoframe'swebrtcconfigfield (vervain-web/src/user_session.rs::ServerInfoBody::from_domain), which the desktop-tab SPA passes straight tonew RTCPeerConnection(...).
Default: with no configuration, the server ships the public Google STUN set
(stun:stun.l.google.com:19302 plus stun1..stun4 as backups), defined once
in vervain-config as default_ice_servers(). This connects whenever both NATs
are STUN-traversable (full-cone / restricted-cone) and needs no deployment
setup.
Override / TURN: set iceservers on a domain in config.json to a raw
array of RTCIceServer objects; it is pushed verbatim to both peers, so this is
where a self-hosted STUN or a TURN server (for example
coturn) with time-limited credentials goes.
Deploying the TURN server itself is an operational step outside this repository;
once it is reachable by the browser and all agents, add its turn: URLs here:
{
"domains": {
"": {
"iceservers": [
{ "urls": ["stun:stun.l.google.com:19302"] },
{
"urls": ["turn:turn.example.com:3478?transport=udp"],
"username": "<time-limited-user>",
"credential": "<time-limited-secret>"
}
]
}
}
}
An explicit empty array ("iceservers": []) disables ICE servers entirely and
falls back to host-candidate-only gathering (the pre-VS-82 behavior). The
effective value for a domain is resolved by Domain::ice_servers_or_default().
Connection configuration (settings.tls)
How the server relates to TLS is configured in the settings.tls section of
config.json, with env overrides for deployments that do not have a
config.json yet (for example during setup mode). Env overrides config.
{
"settings": {
"tls": {
"mode": "offload",
"insecureSkipHashCheck": false,
"trustedProxies": ["10.200.0.0/16", "127.0.0.1/32"]
}
}
}
The server binds two listeners with independent TLS modes (VS-62): a
browser listener (the SPA, the browser API, and the admin
enrollment-cert mint endpoint POST /api/v1/enroll-tokens) and a dedicated
agent mTLS listener (the agent control + relay channels, plus agent
enrollment redemption POST /api/v1/enroll). Agents speak only to the mTLS
listener: they present a single-use bootstrap cert from the dedicated
enrollment sub-CA (VS-68) and enroll their long-lived cert over that channel,
never touching the browser/HTTPS path.
mode(env overrideVERVAIN_TLS_MODE) applies to the browser listener:terminate(default) makes it terminate TLS itself with its own certificate chain.offloadmakes it serve plain HTTP because a reverse proxy (Traefik, Caddy, nginx) terminates TLS in front of it; point the proxy's upstream athttp://<host>:15080with no TLS skip-verify needed on the proxy. Unlike dev mode (VERVAIN_ENV=development), offload mode keepsSecurecookies because browsers still reach the server over https.- The agent mTLS listener (
--agent-bind/VERVAIN_AGENT_BIND, default0.0.0.0:15443) always terminates TLS with vervain's own server certificate and requires + verifies a client certificate chaining to the dedicated agent CA (VS-63). Agents reach it directly (no proxy) and present the mTLS client certificate they were issued at enrollment, so there is no proxy-certificate to recognize and noserversTransport/insecureSkipVerifyre-encrypt workaround. The listener is started alongside the browser listener whenever its address differs from the browser bind (the typical offload deployment runs both: browser http:15080+ agent mTLS:15443). When the two binds are equal (the default terminate-mode single port) the agent routes stay on the main listener and no separate mTLS listener is started. insecureSkipHashCheck: whentrue, the legacy agent handshake on the browser listener accepts ANY agent-reported server cert hash. Agent signature verification stays real, but server-cert pinning is gone. Debugging aid; keepfalsein production.trustedProxies(env overrideVERVAIN_TRUSTED_PROXIES, comma-separated; VS-83): CIDR ranges (or bare IPs, treated as host routes) of the reverse proxies permitted to setX-Forwarded-For. Inoffloadmode the browser listener's immediate TCP peer is the proxy, not the browser, so without this every proxied client collapses to the proxy IP in logs and in any IP-derived logic. When the immediate peer falls inside one of these ranges, the server resolves the real client IP fromX-Forwarded-Forusing the rightmost- untrusted rule (walk the header right-to-left, skipping addresses that are themselves trusted; the first untrusted hop is the client) at every client-IP site (group relay, MCP tunnel, enrollment-bundle rate limit, CrowdSec bouncer, login rate limit, control channel). A request from a peer outside every trusted range has itsX-Forwarded-Forignored entirely, so a client cannot spoof its source IP. Precedence is env > config > default empty, and an empty list preserves today's socket-peer behavior byte-for-byte. This is operator-only system config: likemode, it is read once at startup and is never exposed or editable through the web UI or REST API (it is not part of the runtime-editable settings overlay), because whoever sets it controls client-IP attribution.external_base_url(env overrideVERVAIN_BASE_URL, VS-55): the single host stamped into every agent-facing URL the server mints (the.vagentGroupServer=line, agent core/update push URLs, and the email verification + invite links). SetVERVAIN_BASE_URLto a full URL with scheme (https://host[:port]); a bare domain is rejected because the code would have to guess http vs https. Any trailing slash is trimmed. Precedence issettings.cert>settings.external_base_url>VERVAIN_BASE_URL, so an explicitconfig.jsonstill wins while the env var makes a config-less deployment correct. Without any of them the server falls back towss://localhostand the frontend loopback-rewrite then points agents at the wrong origin.
Runtime-editable settings overlay (VS-37)
Most of config.json is read once at boot and is immutable for the life of the
process. A small allowlist of settings.<section> keys is the exception: a site
admin can edit them from the web UI and have the change apply without a server
restart. The first such section is agentbinaryfetcher.
How it works:
- Persistence. Edits are stored as a single overlay document in
vervain-db(themaintable,type="settings", idsettings/overlay). Its body holds onesettingsobject ofsettings.<section>overrides. No schema migration is involved; it rides the generic document store. - Boot merge. At boot the server deep-merges the overlay over the
config.jsonsettingsobject before any subsystem reads its section, so an admin's edits are the effective configuration and survive restarts. Overlay values win; an overlaynullclears a key; arrays replace wholesale. - Live apply. The admin control channel exposes two site-admin-only actions:
settingsget(read the effective value of one section, with secrets redacted) andsettingsupdate(validate, persist, and apply one section live). Saving a validagentbinaryfetcherrebuilds the fetcher and runs an immediate refresh, so the new agent binaries serve from/vervainagents/:idwith no restart. Invalid input (bad age recipient, malformed source) is rejected before anything is persisted, leaving the running fetcher untouched. - Allowlist. Only sections on
vervain_config::RUNTIME_EDITABLE_SETTINGS_KEYSmay be written; any other key is rejected. The list starts with justagentbinaryfetcherand is meant to grow as more sections gain a verified live-apply path.
Adding a new runtime-editable section: add its key to
RUNTIME_EDITABLE_SETTINGS_KEYS, then add a live-apply arm in
vervain_web::settings_overlay::apply_update that validates the section and
swaps in whatever in-memory handle owns it. Persistence, key normalization,
allowlist gating, and the secret-redaction round-trip are already handled by the
settings_overlay module; see its rustdoc for the full contract.
Seeding the agent fetcher from the environment (VS-38)
A brand-new deploy has no config.json and an empty overlay, so
settings.agentbinaryfetcher is absent and the agent catalog is empty until an
admin configures the fetcher by hand. To let a Docker-only deployment fetch
agents on the very first boot, the agentbinaryfetcher section can be seeded
from VERVAIN_AGENT_FETCHER_* environment variables. VERVAIN_AGENT_FETCHER_BASE_URL
is the trigger: when it is unset, nothing is seeded and boot proceeds unchanged.
Precedence and hygiene:
- The env seed fills the
agentbinaryfetchersection ONLY when the persisted overlay has no such section. The moment a site admin saves the section through the runtime settings UI, that persisted overlay exists and always wins; env changes are then ignored until the operator clears the section. - The seed is held in memory for that boot and is never written to the database, so the token stays only in the environment or secret file it came from. Nothing in the seed path logs the token value.
Variables (the trigger plus four required fields, the rest optional):
VERVAIN_AGENT_FETCHER_BASE_URL: Forgejo base URL, e.g.https://dev.a8n.run(trigger; required).VERVAIN_AGENT_FETCHER_OWNER: package owner / org (required).VERVAIN_AGENT_FETCHER_PACKAGE: package name (required).VERVAIN_AGENT_FETCHER_VERSION: package version / tag (required).VERVAIN_AGENT_FETCHER_PUBLIC_KEY: theagerecipient (age1...) whose secret signed the binaries (required).VERVAIN_AGENT_FETCHER_PUBLIC_KEY_FILEreads it from a file instead.VERVAIN_AGENT_FETCHER_TOKEN: Forgejo API token for a private package (optional).VERVAIN_AGENT_FETCHER_TOKEN_FILEreads it from a mounted secret file (the Docker*_FILEconvention) so the token never lands in the process environment or a log line.VERVAIN_AGENT_FETCHER_AGENT_IDS: comma/space-separated numeric agent ids to fetch (optional; defaults to the built-in set).VERVAIN_AGENT_FETCHER_REFRESH_INTERVAL_SECS: periodic refresh interval in seconds (optional;0or unset means boot-only).
For any field with a *_FILE variant, setting both the plain var and the *_FILE
var is a hard error. A misconfigured seed (missing required field, unreadable
token file, bad value) does not block boot: it surfaces as an admin warning and
the server starts without the fetcher.
Compose snippet (production, Forgejo token mounted as a Docker secret):
services:
vervain-server:
image: dev.a8n.run/psa-systems/vervain-server:latest
container_name: vervain-server
restart: unless-stopped
ports:
https: "443:15443"
environment:
VERVAIN_AGENT_FETCHER_BASE_URL: https://dev.a8n.run
VERVAIN_AGENT_FETCHER_OWNER: psa-systems-private
VERVAIN_AGENT_FETCHER_PACKAGE: vervain-agent
VERVAIN_AGENT_FETCHER_VERSION: stable
VERVAIN_AGENT_FETCHER_PUBLIC_KEY: age1...your-age-recipient...
VERVAIN_AGENT_FETCHER_TOKEN_FILE: /run/secrets/agent_fetcher_token
VERVAIN_AGENT_FETCHER_REFRESH_INTERVAL_SECS: "3600"
secrets:
agent_fetcher_token: {}
volumes:
vervain-data: /data
networks:
vervain-private: {}
secrets:
agent_fetcher_token:
file: ./secrets/agent_fetcher_token
volumes:
vervain-data:
name: vervain-data
networks:
vervain-private:
name: vervain-private
Setup mode (VS-26)
First-user onboarding. Setup is active only when ALL of these hold: (1)
VERVAIN_SETUP_ENABLE=true, (2) config.json does not exist, (3) no user
account exists yet. When active, GET /api/setup reports
{"setup": true, "needs_first_user": true} and POST /api/setup/register
creates the first user as a full site admin and auto-logs them in. When any
condition fails, both routes return 404, so the registration surface closes
itself the moment the first account is created, a config.json is written, or
the env var is cleared.
The dev-only ADMIN_USERNAME/ADMIN_PASSWORD first-run bootstrap is compiled
out of release builds (VS-28), so in production the first admin is created
through setup mode (above) or the vervain create-account subcommand.
Setup listener overrides
A fresh deployment has no config.json to put settings.tls in, so four env
vars shape the listener for the setup walk. They are honored only while setup
mode is active and ignored (with an info log) otherwise; once a config.json
exists, config plus the long-term env overrides (VERVAIN_TLS_MODE etc.) own
the listener.
VERVAIN_SETUP_SCHEME:https(default) orhttp. Withhttpthe setup listener serves plain HTTP, so a reverse proxy can point its upstream straight at it and a direct browse does not have to trust the self-signed chain. The setup auto-login cookie drops theSecureflag so it works over both an https-terminating proxy and a plain-HTTP direct connection.VERVAIN_SETUP_HOST: hostname the operator browses to during setup; replaces--cert-fqdn(defaultlocalhost) for the generated cert chain,server_name, and the WebAuthn origin.VERVAIN_SETUP_PORT: listener port (default 15443).VERVAIN_SETUP_BIND_IP: listener address (default 0.0.0.0).
An explicit --bind CLI flag still wins over PORT/BIND_IP; malformed
values fail the boot with a clear error. Typical Traefik onboarding:
VERVAIN_SETUP_ENABLE=true + VERVAIN_SETUP_SCHEME=http, proxy upstream at
http://<host>:15443, register the admin, write a config.json with
settings.tls.mode = "offload", restart.
Admin UI (VS-21)
The operator admin panel is one Rust frontend codebase (crates/vervain-admin,
built in Leptos) that VS-21 targets at three surfaces: a
browser admin panel served by the server itself, a Tauri 2 desktop bundle, and
a Tauri 2 mobile bundle, all wrapping the same Leptos app.
This first PR is the foundation slice. It ships the Leptos view layer rendered
server-side and mounted into the existing axum server at GET /admin (and the
per-domain /<domain>/admin), behind the normal session auth, with one
read-only workflow: list the groups the operator can see. vervain_admin::render_admin_html
is the seam: the axum handler in vervain-web/src/admin.rs does auth plus
the document-store read and hands plain props to the renderer, so the view layer
carries no database dependency.
Deliberately out of scope here, tracked as separate VS-21 follow-ups: client-side
hydration / CSR (needs the wasm32-unknown-unknown target and cargo-leptos,
neither in the build image yet, so the page is currently static server-rendered
HTML), #[server] typed RPC, the remaining operator workflows (view agent, open
tunnel, push command, audit log), and the Tauri desktop/mobile shells with their
signing and store distribution.
Non-goals for this project
- Re-creating the dynamic-dependency installer in
mainStart(). Optional features become Cargo features; nothing is installed at runtime. - Shipping a built-in web UI. The legacy browser client was removed; the
frontend is the separate
vervain-appsproject.vervain-webserves the API plus a minimal landing page atGET /. - Bug-for-bug compatibility with the legacy v1 swarm server. It is
scheduled for
vervain-swarmonly if a deployed user still needs it.