# Security model

Published 2026-10-21 · Canonical: https://setucp.com/docs/security

---

What Setu protects, from whom, and what it leaves to you. The design is spec §4 and §7 ([spec](/docs/specs/10-09-setu-design)); ruling ids like (R70) point into the phase 1 ledger of rulings (kept in the private planning repository).

## Trust model

- **The control plane is root on every agent.** An agent executes the typed commands its control plane queues, and some of them run as root through the helper. Whoever controls the control plane (its host, its database, its `secret_key`, an `owner` or `operator` account) can create users, write units and env files, install binaries and drop databases on every joined server, within the helper's verbs. The helper bounds *what* can be done (no free paths, modes, owners or shell), not *who* asks. Protect the control plane accordingly.
- **The agent is not root.** It runs as `setu` and reaches root only through `sudo -n /opt/setu/setu helper <verb> --site <short>` and `postgres` only through `psql`/`pg_dump`/`pg_restore`. A compromised agent process is bounded by the same verbs.
- **Sites are untrusted.** Each runs as its own user under a hardened unit and cannot write anything a privileged path later reads (only its `data/` and `run/`).
- **Operators** are authenticated people with a role; `viewer`s never see a secret.
- On the single-box install the control plane and the agent run as the same `setu` user, so a compromise of either is a compromise of both.

## The helper

`/opt/setu/setu` is root:root 0755 in a root-owned directory (`setu agent doctor` checks both). The agent calls it only as `sudo -n /opt/setu/setu helper <verb> --site <short>`. Guarantees:

- The only arguments are the verb and `--site`, validated as `^[a-z0-9]{8}$`. Input is one line of typed JSON on stdin (unknown fields refused, at most 1 MiB); archives stream over stdin/stdout (R61). Nothing path-like, no mode and no owner crosses sudo.
- Every path derives from the short id. Each component under `/srv/setu/sites` is opened `O_NOFOLLOW|O_DIRECTORY` step by step from root; files are written temp-file-plus-rename inside root-owned parents; no special mode bit is ever set.
- The helper re-validates what it is given: `version` `^[0-9A-Za-z.+-]{1,64}$`, `exec` relative `[A-Za-z0-9._-]` segments without `..`, `args` and env values without control characters, dir names `^[a-z][a-z0-9-]{0,31}$`, env names `^[A-Z][A-Z0-9_]*$`, sha256 64 lowercase hex. Unit values are systemd-escaped; env values use systemd's `EnvironmentFile=` quoting (R64).
- Archives (`install`, `restore`) are extracted into a root-owned staging directory; any `..`, absolute name, symlink, hard link, device file or setuid/setgid/sticky bit refuses the whole operation and leaves nothing behind. `backup` never follows a link a site user planted in `data/` (links and special files are skipped), and skips regular files the site user does not own or that have more than one hard link (R135); `agent doctor` checks `fs.protected_hardlinks = 1`.
- `--root <dir>` (an alternate filesystem root for tests) exists only in builds with the `test-root` feature, is refused when `SUDO_UID` is set, and the release workflow fails if the binary offers it (R129).
- `install-system` (units, sudoers, firewall, Caddy) is not a helper verb; it runs only as real root from `setu agent install`, `setu agent install-system` and `install.sh`.

### sudoers

`/etc/sudoers.d/setu` (0440), verbatim (R70):

```
setu ALL=(root) NOPASSWD: /opt/setu/setu helper *
setu ALL=(postgres) NOPASSWD: /usr/bin/psql, /usr/bin/pg_dump, /usr/bin/pg_restore
```

`helper *` is safe because every verb takes only `--site <short>` and typed stdin. `psql` receives SQL on stdin; a role password is a `\set pw '<alphanumeric>'` line on stdin, never argv or the sudo log. `install-system` checks the file with `visudo -cf` before installing it; `scripts/check-sudoers.sh` compares the golden asset and runs `visudo -cf` (classic sudo and sudo-rs both accept it).

## Network

Setu manages only its own table. SSH, Postgres (Ubuntu's default listens on localhost only) and everything else on the host are yours to firewall.

## Caddy's admin socket

Caddy is programmed only through `unix//run/caddy/admin.sock|0660`, group `caddy`; `setu` is in that group, site users are not (e2e assertion 5 checks a site user cannot open it). Anyone who can write that socket controls every route and certificate on the box. Routes Setu writes have ids `setu-site-<short>` and `setu-panel`; others are never touched.

The panel socket `/run/setu/http.sock` is 0660 `setu:caddy`, so only Caddy (and `setu`) can connect. Its directory is 0755 (R126): local users can see the socket's name, not use it.

## What hosted sites share

Sites are isolated by user, unit hardening (`ProtectSystem=strict`, `ProtectHome`, `PrivateTmp`, `NoNewPrivileges`, `RestrictSUIDSGID`, `UMask=0027`) and cgroup limits, but they share:

- **the kernel**: a kernel exploit in one site is every site's;
- **the Postgres instance**: each site has its own role and database with `REVOKE CONNECT … FROM PUBLIC` (a test asserts another site's role cannot connect), but one site can load the shared server;
- **loopback**: any site can connect to another site's port on 127.0.0.1 (the firewall only blocks other interfaces), and can send forged `X-Forwarded-For` headers to apps that trust 127.0.0.1 as a proxy. Vyasa does (`VYASA_TRUSTED_PROXIES = "127.0.0.1"`), so a hostile neighbour can make its requests appear to come from any IP (rate limits, audit IPs). Host mutually distrusting customers on separate servers until phase 2 adds network isolation;
- **disk**: `disk_mb` is reported, not enforced, in phase 1.

The same applies to the panel when it listens on TCP: with the default `trusted_proxies = ["127.0.0.1/32"]` a site on the same box can reach `127.0.0.1:7000` and forge the client IP the login limiter and the audit log see. The installer's unix socket avoids this.

## Application logs

`FetchLogs` (the site's Logs tab) returns the app's journal **verbatim**. Setu redacts the secrets it handles in its own job logs and command results, but it cannot know what an app prints: apps must not log their own secrets (database URLs, generated keys, the one-time admin password).

## Operators and sessions

- Passwords are argon2id. An unknown email is verified against a dummy hash, so its timing and its 401 match a wrong password.
- Login rate limit: 10 failures per client IP per 15 minutes → 429 `rate_limited`. Wrong passwords on reveal count in the same limiter. An unknown client IP shares one bucket.
- Sessions live in the `sessions` table: the cookie `setu_session` carries 32 random bytes, only their SHA-256 is stored; `HttpOnly`, `SameSite=Lax`, `Secure` when `public_url` is https; 7 days, sliding. Login always issues a fresh session (no fixation); a password change ends every session of that operator (R102). Deleting an operator (owner only) refuses yourself and the last owner with 409, ends every session of theirs and is audited as `operator.delete`; concurrent deletions are serialised by an advisory lock, so two owners deleting each other cannot both succeed (R149).
- Roles: `owner` > `operator` > `viewer`; every route × role is pinned by `crates/api/tests/matrix.rs`. Every mutation writes `audit_log` (operator, action, target kind and id, client IP).

### The Origin rule

Every `POST`/`PUT`/`DELETE`/`PATCH` under `/api/v1`, login and setup included, must carry `Origin` equal to `public_url` (scheme, host and port) or `Sec-Fetch-Site: same-origin`; anything else is 403 (R103). Browsers send one of them; scripts must send `Origin: <public_url>`. The agent API (`/agent/v1`, bearer tokens) is outside this rule.

### Client IP and X-Forwarded-For

The client IP feeds the login limiter and audit rows (R104):

- on the unix socket the peer is Caddy by construction, so `X-Forwarded-For` is believed;
- on TCP it is believed only when the peer is in `trusted_proxies` (CIDRs; default `127.0.0.1/32`); otherwise the peer address is the client;
- within a believed header the client is the rightmost hop that is not itself a trusted proxy (a client cannot hide behind hops it prepended); if every hop is trusted, the leftmost; an unparsable hop voids the header (unix peer: unknown IP; TCP: the peer).

### Revealing a secret

`GET /sites/{id}/env` masks every secret value for every role, and any value sourced from `postgres_url` regardless of its flag. The only way to read one is `POST /sites/{id}/env/{name}/reveal {password}`: `operator` or `owner`, with the caller's own password. A wrong password is 403 and counts in the login limiter. Every attempt past the role check is audited: `env.reveal` when the value was returned, `env.reveal_denied` when not, target `<site_id>:<NAME>` (R107).

### The setup token

With no operator in the database, `setu serve` writes a one-time token to `<data_dir>/setup-token` (0600 from creation, readable only by `setu` and root) and stores only its SHA-256. `POST /setup` needs it; the token check, the owner insert and the hash deletion are one transaction under an advisory lock, so exactly one owner is created; the file is then removed. A lost file with no owner yet makes the next boot mint a new token (R105). After the first owner, `/setup` answers 409.

### The join token and nonce

`POST /servers` (and the wizard) create a pending server with a join token (`setu_join_…`, single use, 1 hour). It is shown once, next to an install command that does not contain it; the agent reads it from `SETU_JOIN_TOKEN` or stdin, never argv. The agent sends a random nonce with it: a retry within 60 s with the same nonce gets the same server token back (a lost response is recoverable), a different nonce is refused, so someone who saw the join token cannot take over a server that already joined. Every failure is the same 401 (R76, R99).

The server token (`setu_srv_…`, 32 random bytes) is stored as a SHA-256 hash on the control plane and in `/etc/setu/agent.toml` (`setu`, 0600, created 0600 rather than chmodded later) on the agent. Agent polling is rate-limited per server. Rotation: [OPERATIONS.md](/docs/operations#rotating-a-server-token). An owner rotates a server's token with `POST /servers/{id}/rotate-token`, which issues a single-use join token under the rules above (valid one hour); the current server token keeps working until the agent re-joins.

## Secrets at rest

Every struct that holds a secret has a `Debug` that redacts it; secrets never travel on argv (join token, operator password, role passwords, `pg_dump`'s password via `PGPASSWORD` in the child's environment).

## Not in phase 1

- Two-factor authentication and operator API tokens (phase 2).
- Enforced disk quotas (reported only).
- Network isolation between sites on one box.
- Encryption of local backups, off-box backups.
- Rotation of `secret_key`.
- Telemetry: there is none.
