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 99.6%
  • Nushell 0.2%
  • Dockerfile 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-08 02:33:39 +02:00
.forgejo/workflows ci: run check on the opensuse-dev runner image 2026-07-28 05:13:55 -04:00
.idea refactor!: rebrand MeshCentral to Vervain across the workspace 2026-06-20 16:32:53 -04:00
common@4de5dfa607 chore(common): bump the common submodule to 4de5dfa and add a default recipe 2026-08-07 18:55:57 -04:00
crates feat(agent-proto): mirror VNC and MCP capability bits 2026-07-28 07:01:53 -04:00
oci-build fix(ci): derive OCI publish mode from trigger, not git describe 2026-06-26 07:31:38 -04:00
.dockerignore chore: adopt the shared psa-systems/common runner 2026-07-19 22:36:25 -04:00
.env.example chore: adopt the shared psa-systems/common runner 2026-07-19 22:36:25 -04:00
.gitignore chore: Sync .gitignore 2026-06-06 13:13:08 -04:00
.gitmodules chore: adopt the shared psa-systems/common runner 2026-07-19 22:36:25 -04:00
Cargo.lock feat(agent-proto): macOS arm64 agent id, Vervain-native numbering, native Darwin build 2026-07-27 22:44:45 -04:00
Cargo.toml feat(web): compress agent binary fetch and push transport 2026-07-05 23:34:59 -04:00
compose.dev.yml chore: adopt the shared psa-systems/common runner 2026-07-19 22:36:25 -04:00
compose.yml docs(fetcher): name real agent package owner psa-systems-private 2026-06-23 21:40:15 +02:00
Dockerfile fix(build): drop COPY of deleted views/public from Dockerfiles 2026-06-20 21:36:57 -04:00
justfile chore(common): bump the common submodule to 4de5dfa and add a default recipe 2026-08-07 18:55:57 -04:00
README.md chore: adopt the shared psa-systems/common runner 2026-07-19 22:36:25 -04:00
RMM.md refactor!: rebrand MeshCentral to Vervain across the workspace 2026-06-20 16:32:53 -04:00
ROADMAP.md chore: adopt the shared psa-systems/common runner 2026-07-19 22:36:25 -04:00
rust-toolchain.toml ci: build from rust-builder-* (rust 1.94) 2026-05-10 08:07:19 -04:00

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 its RTCConfiguration.ice_servers;
  • to the browser, inside the serverinfo frame's webrtcconfig field (vervain-web/src/user_session.rs::ServerInfoBody::from_domain), which the desktop-tab SPA passes straight to new 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 override VERVAIN_TLS_MODE) applies to the browser listener: terminate (default) makes it terminate TLS itself with its own certificate chain. offload makes it serve plain HTTP because a reverse proxy (Traefik, Caddy, nginx) terminates TLS in front of it; point the proxy's upstream at http://<host>:15080 with no TLS skip-verify needed on the proxy. Unlike dev mode (VERVAIN_ENV=development), offload mode keeps Secure cookies because browsers still reach the server over https.
  • The agent mTLS listener (--agent-bind / VERVAIN_AGENT_BIND, default 0.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 no serversTransport / insecureSkipVerify re-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: when true, 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; keep false in production.
  • trustedProxies (env override VERVAIN_TRUSTED_PROXIES, comma-separated; VS-83): CIDR ranges (or bare IPs, treated as host routes) of the reverse proxies permitted to set X-Forwarded-For. In offload mode 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 from X-Forwarded-For using 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 its X-Forwarded-For ignored 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: like mode, 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 override VERVAIN_BASE_URL, VS-55): the single host stamped into every agent-facing URL the server mints (the .vagent GroupServer= line, agent core/update push URLs, and the email verification + invite links). Set VERVAIN_BASE_URL to 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 is settings.cert > settings.external_base_url > VERVAIN_BASE_URL, so an explicit config.json still wins while the env var makes a config-less deployment correct. Without any of them the server falls back to wss://localhost and 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 (the main table, type="settings", id settings/overlay). Its body holds one settings object of settings.<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.json settings object before any subsystem reads its section, so an admin's edits are the effective configuration and survive restarts. Overlay values win; an overlay null clears 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) and settingsupdate (validate, persist, and apply one section live). Saving a valid agentbinaryfetcher rebuilds the fetcher and runs an immediate refresh, so the new agent binaries serve from /vervainagents/:id with 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_KEYS may be written; any other key is rejected. The list starts with just agentbinaryfetcher and 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 agentbinaryfetcher section 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: the age recipient (age1...) whose secret signed the binaries (required). VERVAIN_AGENT_FETCHER_PUBLIC_KEY_FILE reads it from a file instead.
  • VERVAIN_AGENT_FETCHER_TOKEN: Forgejo API token for a private package (optional). VERVAIN_AGENT_FETCHER_TOKEN_FILE reads it from a mounted secret file (the Docker *_FILE convention) 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; 0 or 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) or http. With http the 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 the Secure flag 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 (default localhost) 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-apps project. vervain-web serves the API plus a minimal landing page at GET /.
  • Bug-for-bug compatibility with the legacy v1 swarm server. It is scheduled for vervain-swarm only if a deployed user still needs it.