Tetra is a modular host agent and recipe renderer for the Ultramarine Server Dashboard.
It is organized around a single dispatcher with independent,
feature-gated modules for apps, files, recipes, SELinux, services, Quadlets,
reverse proxy (Caddy), Podman, Samba, NFS, users, storage, network, power,
updates, remote access (Tailscale, Cloudflare Tunnel), and virtual machines.
The settings module is always compiled so the control plane can discover
basic host facts.
Local clients (the dashboard container and the server OOBE) talk to Tetra over
a Unix socket at /run/tetra/tetra.sock (agent-serve). This is the only
listener a production install runs; Tetra does not listen on the network.
Tetra identifies the client from the socket's peer credentials and is the only
authority for login and authorization: users log in with a password (PAM) plus
TOTP, or a passkey, and every request after login carries a Tetra-issued
session token (login, setup-code redemption, the app access check and
shell.open take none; see the "no token" operations). See
docs/auth-protocol.md.
An outbound mTLS WSS connection (wss:// only) to a remote control plane also
exists. It is off unless remote.enabled is set in /etc/tetra/tetra.json.
For development, agent-ws-serve (the dev-listener feature) serves the
same Tetra session protocol as the Unix socket over loopback TCP. TCP has no
peer credentials, so --client dashboard|oobe (default dashboard) applies
to every connection; any local process can claim that client identity, but
still needs a valid Tetra session. It is dev-only and refuses non-loopback
binds. See the session protocol.
The local agent-dispatch command accepts the same command envelope shape for
one-shot debugging. See docs/agent-protocol.md for
the controller-facing API, connection negotiation, and agent protocol reference.
Recipes are declared in YAML, and Quadlet generation uses Tera templates. A recipe declares metadata, UI parameters, requirements, and a list of resources to render.
- Agent protocol reference — Connection negotiation, authentication, and the full module action catalog.
- Session protocol — Login, sessions, permissions and first-boot setup on the local socket.
- Dashboard auth guide — How the dashboard and OOBE should call Tetra for login and authorization.
- Recipe authoring guide — How to write recipes, parameters, templates, and resources.
- Architecture overview — Crate layout, dispatcher, queue, transports, and how to add a module.
- Remote enrollment — The controller side of
tetra enrolland the outbound mTLS connection. - Troubleshooting — Common issues with TLS, identity, the development listener, recipe rendering, SELinux, and queue backpressure.
Default build features:
appsfilesrecipesselinuxservicesquadletsreverse-proxypodmansambanfsusersstoragenetworkpowerupdatesremote-access
Additional optional features:
virtual-machinesdev-listener: the development-only loopback TCP session listener (agent-ws-serve). Leave it off in release builds.testing: exposestetra::auth::testing(fake PAM, accounts, sealer and host) for integration tests.
Build with every module:
cargo build --all-featuresBuild with only recipe rendering and Quadlet management:
cargo build --no-default-features --features recipes,quadletsTetra doesn't ship recipes. The app recipes and their Tera templates live in
Ultramarine-Linux/recipes;
the dashboard fetches them and sends them to Tetra inline. Each recipe keeps
its templates in its own templates/ directory, so to render one locally,
clone that repository and point --templates-dir there:
git clone https://github.com/Ultramarine-Linux/recipes ../recipes
cargo run -- render ../recipes/recipes/official/nginx-site/recipe.yaml \
--templates-dir ../recipes/recipes/official/nginx-site/templates --output-dir ./quadletsRecipes that also use the recipes repository's shared/ templates need both
directories, which --templates-dir can't express; render those with
cargo run -p recipes -- render <id> in the recipes repository instead. It
renders through the same code (tetra-recipe) with the templates the
published bundle carries.
Preview without writing:
cargo run -- render ../recipes/recipes/official/nginx-site/recipe.yaml \
--templates-dir ../recipes/recipes/official/nginx-site/templates --dry-runParameter values are supplied as a simple YAML map:
domain: cloud.example.com
enable_redis: truePass them with:
cargo run -- render recipe.yaml --values values.yaml --templates-dir templates --output-dir ./quadletsThe one-shot CLI accepts the same JSON command envelope used by transports:
{
"id": "cmd-1",
"module": "settings",
"action": "get_system",
"payload": {}
}Run it (without a file it reads the command from stdin):
cargo run -- agent-dispatch examples/settings.command.jsonagent-dispatch calls the modules directly, with no login, authorization or
action log, so use it only for local debugging.
examples/apps.command.json shows a dry-run apps.create that cooks an
inline recipe into an installed app bundle.
agent-serve is the host agent daemon. It serves the session protocol
(docs/auth-protocol.md) to local clients over a Unix
socket. Only the ultramarine-dashboard and ultramarine-oobe accounts may
connect (--dashboard-user/--oobe-user). Under systemd the socket comes
from tetra.socket through socket activation; otherwise Tetra binds
--socket (default /run/tetra/tetra.sock) with mode 0660:
cargo run -- agent-serve \
--socket /tmp/tetra.sock \
--identity-dir /tmp/tetra-identity \
--state-dir /tmp/tetra-stateClients open a WebSocket over the Unix stream; the HTTP Host and path are
ignored. With Node's ws, use ws+unix:///run/tetra/tetra.sock.
agent-ws-serve runs the host daemon on loopback TCP instead of the Unix
socket. It is only built with dev-listener; it uses the same AuthService,
PAM/TOTP or passkey login, session tokens, grants, and action log as
agent-serve. On a server, this normally means running it as root; the
example uses scratch state so it does not reuse the host's login database:
cargo build --features dev-listener
sudo target/debug/tetra agent-ws-serve \
--listen 127.0.0.1:7780 --client dashboard \
--identity-dir /tmp/tetra-identity --state-dir /tmp/tetra-state--client accepts dashboard or oobe and defaults to dashboard. Since
TCP has no peer credentials, the selected client is applied to every
connection; any local process that reaches the port gets that client identity,
but every operation still needs what it needs on the socket (a valid Tetra
session, except the "no token" ones such as login). Handshakes with an
Origin header (that is, from a web page in a browser) are refused with 403;
connect from a backend. --listen rejects non-loopback addresses. TLS is omitted because this
loopback-only development endpoint is not a remote transport and cannot replace
the process credentials of the Unix socket. Use the Unix socket in production.
See docs/auth-protocol.md
for connection and frame details.
Remote control ships disabled. agent-serve connects out only while
remote.enabled is true in the runtime config, /etc/tetra/tetra.json
(override with --config or TETRA_CONFIG). A missing file means remote
control is off, and an unreadable or invalid file turns it off too.
After tetra enroll, the gate is all the config needs; the
host id, control-plane URL and mTLS material come from the enrollment:
{ "remote": { "enabled": true } }remote.host_id and remote.transport override the enrollment when set:
{
"remote": {
"enabled": true,
"host_id": "host-01",
"transport": {
"control_plane_url": "wss://dashboard.example.com/tetra/agent",
"client_cert_path": "/var/lib/tetra/remote/client.crt",
"client_key_path": "/var/lib/tetra/remote/client.key",
"server_ca_path": "/var/lib/tetra/remote/server-ca.crt"
}
}
}examples/tetra.json has the full shape. Tetra re-reads the file on SIGHUP
(systemctl reload tetra.service). Enabling remote control starts the mTLS
session; disabling it drops any live session immediately. A reload that
leaves remote unchanged keeps the current session.
agent-connect runs the same outbound session on its own. It uses the
enrollment by default; --config (a transport file) and --host-id override
it. It obeys the same gate: it refuses to start unless remote.enabled is
true, and it drops the session when a SIGHUP reload turns it off or finds
the enrollment removed:
cargo run -- agent-connect --runtime-config examples/tetra.json
cargo run -- agent-connect --config examples/transport.json --host-id myhost \
--runtime-config examples/tetra.jsonsystemd/tetra.socket creates /run/tetra/tetra.sock as root:tetra, mode
0660, and socket-activates systemd/tetra.service, which runs
agent-serve and persists identity under /var/lib/tetra/identity and login
state under /var/lib/tetra (root only). The service does not bind a TCP port.
systemd/tetra-setup-code.service shows the first-boot setup code on the
console until setup completes. pam/ultramarine-dashboard is the PAM service
used for dashboard passwords and pam/tetra-shell the one web shells run
(agent-serve --shell-pam-service); both belong in /etc/pam.d.
systemd/tetra.sysusers.conf creates the tetra group with the fixed GID
241, chosen from Fedora's system range (201–999) outside the 0–200 range
the setup package reserves. The dashboard's rootful container must keep the
host UID of ultramarine-dashboard and receive the group by number: in its
Quadlet, Volume=/run/tetra/tetra.sock:/run/tetra/tetra.sock and
GroupAdd=241. The OOBE service uses SupplementaryGroups=tetra. A host whose
tetra group already existed with another GID keeps it (systemd-sysusers
does not change existing groups, and falls back to a dynamic GID if 241 is
taken); agent-serve logs a warning in that case, and the group has to be
fixed with groupmod -g 241 tetra.
User apps run rootless under one system account, ultramarine-apps, which
systemd/tetra.sysusers.conf also creates, with the fixed UID and GID
242 (same range and reasoning as the tetra group) and the home
/var/lib/ultramarine-apps. It cannot log in: no password, nologin shell,
and Tetra refuses it as a dashboard user. Packaging:
- Install
systemd/tetra.sysusers.confas/usr/lib/sysusers.d/tetra.confandsystemd/tetra.tmpfiles.confas/usr/lib/tmpfiles.d/tetra.conf(%sysusers_create_compatand%tmpfiles_createin the spec; bootc images apply both at boot).systemd-sysusersonly records the home, so the tmpfiles snippet creates it (0700, owned by the account). - Linger. The tmpfiles snippet also creates
/var/lib/systemd/linger/ultramarine-apps, which is whatloginctl enable-lingerrecords: logind starts the account's user manager at boot, so app services run without a login. Tetra runsloginctl enable-lingeritself if the flag is missing. - Subordinate IDs. Rootless Podman needs
/etc/subuidand/etc/subgidranges, whichsystemd-sysusersdoes not allocate (anduseradd -ronly does with-F). Tetra adds them on the firstapps.create/updatethat converges: the first free 65536 IDs from 524288 (Fedora'sSUB_UID_MIN/SUB_UID_COUNT), withusermod --add-subuids FIRST-LAST --add-subgids FIRST-LAST ultramarine-apps, skipped when the account (by name or UID) already has a range. Doing it at runtime rather than in a scriptlet keeps it right on bootc, where/etcis per machine and the account only exists after the first boot. - SELinux. A home under
/var/libgets none of theHOME_DIRrules that label rootless storage in/home, so Tetra addssemanage fcontext -a -e /var/lib/containers/storage /var/lib/ultramarine-apps/.local/share/containers/storageunlessfile_contexts.subsalready has it, creates the storage and relabels it withrestorecon -R, once (recorded in/var/lib/tetra/apps-storage-labels). Images may apply the same rule at build time instead. agent-servelogs a warning when the account is missing or its UID/GID is not 242 (usermod -u 242 -g 242fixes it; files in the home must then be re-owned).
Apps installed by earlier releases are system-scope (rootful) and keep
working with "scope": "system"; Tetra does not migrate them. See the
apps section of docs/agent-protocol.md.
Remote control is outbound only: the host dials the controller over WSS and authenticates with an mTLS client certificate. Nothing listens on the network and no inbound address is registered.
tetra enroll obtains that certificate with a device authorization flow:
- Tetra checks that
remote.enabledistruein/etc/tetra/tetra.jsonand refuses to run otherwise. It also refuses if the host is already enrolled. - It generates a fresh ECDSA P-256 key pair and a certificate signing request
with the
clientAuthextended key usage. The private key never leaves the host. - It sends the CSR to the controller, prints a verification URL and a short approval code, and waits for an administrator to approve the request.
- On approval the controller signs the CSR and returns the client
certificate, the CA bundle for its WSS endpoint, the host id and the
wss://control-plane URL. Tetra checks that the certificate is for its key, allowsclientAuthand is currently valid. - Tetra stores everything root-only under
/var/lib/tetra/remote(directory0700, files0600) and reloadstetra.service, which then connects out.
tetra enroll --dashboard-url https://control.example.com --display-name host-01| File | Contents |
|---|---|
/var/lib/tetra/remote/client.key |
Client private key, generated on the host. |
/var/lib/tetra/remote/client.crt |
Client certificate signed by the controller. |
/var/lib/tetra/remote/server-ca.crt |
CA bundle that verifies the controller's WSS. |
/var/lib/tetra/remote/controller.json |
Host id, control-plane URL and enrollment URL. |
The controller URL must use https://, because the server CA is delivered over
that connection. --insecure allows http:// and invalid TLS for local
development only. --skip-service skips the systemctl calls, for containers
and the OOBE. --enrollment-dir (or TETRA_ENROLLMENT_DIR) changes the
storage directory for enroll, unenroll, agent-serve and agent-connect.
Setting remote.enabled back to false (and reloading) stops the connection
but keeps the enrollment, so turning it on again reconnects without
re-enrolling. To remove the enrollment, run:
tetra unenrollThis deletes the client key, certificate, server CA and controller record and
reloads tetra.service, which drops any live session. It works whether or not
remote control is enabled. It does not contact the controller; remove or
revoke the host there as well.
The controller-side API is described in docs/remote-enrollment.md.
Local clients authenticate users through Tetra and send a session token with
every request after login (the "no token" operations aside); Tetra authorizes each action from the user's admin status
(ultramarine-admin or wheel) and per-page grants. See
docs/auth-protocol.md. Outbound WSS uses mTLS for
transport identity, with the client certificate issued by tetra enroll; its
commands run with full rights, always as root, and are recorded as remote
actions. Commands are not signed on either production transport. The
development TCP listener uses the same Tetra-issued session protocol as the
Unix socket.
Console recovery: sudo tetra auth reset-2fa <user> removes a user's TOTP and
recovery codes, and sudo tetra setup code prints the first-boot setup code. On a host that
already has an eligible administrator, sudo tetra setup complete finishes
setup without the OOBE.
To discover enabled modules and actions, send:
{
"id": "cmd-capabilities",
"module": "agent",
"action": "capabilities",
"payload": {}
}Each module also supports its own capabilities action.
Fedora and SELinux-oriented installs can use the selinux module to inspect
SELinux status, list and set booleans, manage file-context rules with
semanage fcontext, and run restorecon after managed file changes.
Modules that create or manage paths also accept a shared selinux payload to
apply file-context rules and relabel paths as part of the same action. For
example, a Samba share path can be labeled while updating generated config:
{
"id": "cmd-samba-config",
"module": "samba",
"action": "set_config",
"payload": {
"contents": "[media]\npath = /srv/media\n",
"dry_run": true,
"selinux": {
"path": "/srv/media",
"context_type": "samba_share_t",
"recursive": true
}
}
}