Diag Access Runbook
Diag Access Runbook
Locked-down diag Linux user on the VPS for teammates’ AI agents to run
diagnostics over Tailscale SSH: read journald, check unit health, and restart
exactly the two web slots. Replaces the need for any per-agent service or
token: ssh diag@<vps> "journalctl ..." is the whole interface.
Scope for the diag user: read all journald units (via the
systemd-journalgroup),systemctl status/is-activeon
brioso-web*, andsudo systemctl restartonbrioso-web-aand
brioso-web-bonly. No root, no shell as root, no file reads outside
world-readable paths, no writes anywhere.
What diag can do
| Capability | How | Boundary |
|---|---|---|
| Read journald, all units | member of systemd-journal group |
read only; covers web slots, Caddy, Litestream, deploy failures |
| Read the deploy result | cat /opt/brioso/{,beta/}deploy/last-result.json |
file is root-owned 0644 inside a root:diag 0750 dir (lockstep rule below); no sudo |
| Check web slot health | systemctl status brioso-web*, is-active brioso-web* |
brioso-web units only |
| Restart a web slot | sudo -n systemctl restart brioso-web-a (or brioso-web-b) |
exact commands, NOPASSWD, nothing else |
| Disk / general read-only diagnostics | df -h, systemctl --failed, etc. |
unprivileged commands only |
Everything else is denied by normal file permissions (/home/* is 700) and
by sudoers: sudo -n systemctl restart caddy, sudo -n /bin/bash, any other
sudo invocation fails.
Provisioning (pyinfra)
The user, group membership, permissions, and sudoers are provisioned by the
v2 diag_user pyinfra subsystem (deploy/pyinfra/v2/state/diag_user.py,
all envs). The sudoers drop-in /etc/sudoers.d/diag is rendered from
deploy/sudoers/diag (kept in exact parity by
v2/tests/test_v2_sudoers_parity.py):
diag ALL=(ALL) NOPASSWD: /usr/bin/systemctl status brioso-web*
diag ALL=(ALL) NOPASSWD: /usr/bin/systemctl is-active brioso-web*
diag ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart brioso-web-a.service
diag ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart brioso-web-b.service
Validate after deploy with sudo visudo -c. (Both files exist on the branch
and are being implemented in parallel; this runbook covers the access model
either way.)
last-result.json readability (lockstep rule)
The deploy result file <deploy_dir>/last-result.json is rewritten by the
root-run activate binary on every deploy, so its permissions must be part
of the desired state, not a one-off chmod (which regresses at the next
deploy). The lockstep rule across three files:
deploy/activate/src/commands.ts(writeResult): writes the file mode
0644.deploy/pyinfra/v2/state/diag_user.py: keeps both deploy dirs- This runbook: documents the capability (table above).
Group path traversal (diag in the dir’s group, dir g+x) makes the
root-owned file readable without sudo — plus a traverse-only (--x) ACL
for diag on /opt/brioso itself, which is web-services:brioso 0750 and
would otherwise block diag from entering the tree at all (same module
pins it; --x grants no listing, so shared/ env files stay unreachable).
If any of these moves, move all.
Blue-green restart caveat
Restarts are not free: which slot is active matters.
- Restarting the inactive slot is always safe: Caddy proxies to the other
slot, and deploys use this exact mechanism. - Restarting the active slot briefly drops traffic (seconds, user-visible)
until the unit comes back.
Check which slot is active before restarting: read /opt/brioso/deploy/state.env
on the VPS (ACTIVE_SLOT / the active slot’s port is recorded there). When in
doubt, don’t restart; report instead.
Common agent one-liners
journalctl -u brioso-web-a -n 200 --no-pager # recent logs for a slot
systemctl --failed # any failed units
df -h # disk
systemctl status brioso-web-a # unit health
sudo -n systemctl restart brioso-web-a # restart (see caveat above)
Manual Tailscale steps (one-time, out-of-repo)
These are done in the Tailscale admin console, not by pyinfra. Export the
live policy first and edit on top of it — never paste
.agents/docs/tailscale-acls.jsonl over live (it would drop CI’s SSH rule and
break every deploy).
- Network grant on
:22: Tailscale permits an SSH connection only when
the policy has both a network grant and an SSH rule. Add togrants:Members otherwise only have{ "src": ["autogroup:member"], "dst": ["tag:server"], "ip": ["tcp:22"] }tcp:443totag:server, so without this they
are blocked at the network layer before thesshsection is consulted —
which is why the VPS looks “online and reachable” (ICMP/443 work) while SSH
does not. - SSH ACL rule: add to the policy’s
sshsection:{ "action": "accept", "src": ["autogroup:member"], "dst": ["tag:server"], "users": ["diag"] }accept, notcheck: teammates point non-interactive AI agents at this and
checkstalls the first connect on a browser re-auth URL. Attribution still
comes from tailnet identity in the connection logs, and the account is
fenced by sudoers + file permissions. The existing baselinecheckrule
grants members SSH toautogroup:self(their own devices) as any nonroot
user; it does not covertag:server, so the two rules don’t conflict. - Session recording for diag (follow-up, not yet done):
recordis not
a validsshaction — the only actions areacceptandcheck. Recording
is arecorderfield (plus optionalenforceRecorder) on an
accept/checkrule and needs a tsrecorder node provisioned and
tagged in the tailnet. Tracked as BRI-376; do not add arecordrule. - Verify the final policy with the ACL editor’s rules preview:
- members are allowed to
tag:serverasdiagand as no other user; tag:ci->deploy(the CI deploy rule) is still allowed.
- members are allowed to
After saving, overwrite .agents/docs/tailscale-acls.jsonl with the live
post-change export (not a hand-edit) and keep doing that after every console
change — the file is a read-only mirror, not the source of truth.
Audit
- Tailscale admin console: connection logs tie every SSH session to the
tailnet identity that opened it, before mapping to the shareddiagLinux
user. - journald sudo logs:
journalctl -t sudoon the VPS shows every sudo
command diag ran (command line, tty, exit status). - Session recording is NOT active (no tsrecorder node yet — see Manual
Tailscale steps #3). Until it is, sudo journald logs plus Tailscale
connection logs are the only attribution for the shareddiagaccount. - Shared-user audit story: multiple teammates share
diag, but attribution
comes from Tailscale identity at connect time; sudo logs add command-level
confirmation.
Revocation (per teammate)
There are no per-user credentials on the VPS to rotate. To revoke a teammate:
remove them from the tailnet. If finer grain is wanted later, switch the ACL
rule’s src from autogroup:member to a dedicated group:diag and manage
membership in the admin console.
Verification
After provisioning and the manual Tailscale steps:
- Positive (as a teammate, from their tailnet machine, non-interactive):
ssh -o BatchMode=yes diag@<vps> 'journalctl -u brioso-web-a -n 5 --no-pager'
succeeds;ssh -o BatchMode=yes diag@<vps> 'sudo -n systemctl status brioso-web-a'
succeeds with no browser prompt and no password prompt. - Negative:
ssh root@<vps>andssh deploy@<vps>as a member are
denied;sudo -n systemctl restart caddyas diag fails with “not allowed”;
cat /home/<other-user>/.bashrcgets permission denied;
sudo -n /bin/bashis refused. (Verified 2026-08-26 on prod via
sudo -u diag— all pass.) - CI not broken (the real regression risk): re-run
deploy.yml(or push
a trivial commit tobeta) in the same session as the policy change and
confirm theDeploy over Tailscale SSHstep still connects asdeploy@. - Recording: not active — no tsrecorder node yet (Manual Tailscale steps
#3). BRI-376 tracks provisioning it. LogMCP no-regression— LogMCP was removed repo-side on
2026-08-26 (WS4 of the deploy v2 restructure); the VPS-side decommission
steps are in
.agents/docs/windmill-logmcp-decommission-runbook.md.
Once executed,systemctl status brioso-logmcpshould report the unit as
not found/inactive.mise run validategreen for the repo change (pyinfra + docs).
LogMCP removal
LogMCP was decommissioned 2026-08-26 (repo-side removal in the WS4 branch;
host-side steps pending in
windmill-logmcp-decommission-runbook.md).
It was loopback-only in practice — the logs.usebrioso.com Caddy block was
never provisioned. Diag SSH is now the sole logs/journald surface and the
restart surface lives in diag’s sudoers (the LogMCP clitool plugin was never
provisioned with a write token).
Teammate handoff
No SSH key is needed. With Tailscale SSH enabled, tailscaled terminates
the connection itself and authenticates the tailnet identity over WireGuard
node keys (SSH auth type none) — there is no authorized_keys file for
diag, and the pyinfra module deliberately never creates one
(deploy/pyinfra/v2/state/diag_user.py). Any key your local ssh offers is
ignored. Nothing to generate, nothing to share.
Prerequisites:
- Be a member of the tailnet (
autogroup:member) with Tailscale running and
logged in on the machine you connect from. ssh diag@<vps-tailnet-host>.
No tokens to store, no MCP client config, no per-user credentials. Access
follows tailnet membership. The ssh rule uses accept, so there is no
device-approval or browser prompt on first connect.
Two gotchas that look like auth failures but are not:
REMOTE HOST IDENTIFICATION HAS CHANGED— Tailscale SSH presents its
own host key, distinct from the VPS’s sshd host key. If you ever SSHed to
that host directly, drop the stale entry:
ssh-keygen -R <vps-tailnet-host>.Permission denied (publickey)— the connection reached the real sshd,
not Tailscale SSH, i.e. the policy change has not taken effect for you yet.
It is not a missing-key problem; do not “fix” it by asking for a key.