deploy: sudo-rs-compatible operator sudoers — scope systemctl via lchat-svc helper (#909)
Ubuntu 26.04 ships sudo-rs, which rejects argument wildcards in sudoers (systemctl restart lchat-*). The merged allowlist was invalid on the actual fleet OS — visudo -c passed only on classic sudo. Replace the two wildcard systemctl lines with a lchat-svc helper (mirrors lchat-env-install) that takes a whole-argument wildcard and enforces the lchat* unit prefix itself. Validated against the box's real sudo-rs visudo.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+68
−6
deploy/lchat-svc+44 −0deploy/sudoers-operators+9 −2docs/instance-router.md+15 −4
diff --git a/deploy/lchat-svc b/deploy/lchat-svc| new file mode 100755 |
| index 0000000..2fc5a00 |
| --- /dev/null |
| +++ b/deploy/lchat-svc |
| @@ -0,0 +1,44 @@ |
| +#!/bin/sh |
| +# lchat-svc — scoped systemctl for operators, without a root shell. |
| +# |
| +# Ubuntu 26.04 ships sudo-rs, which (unlike classic sudo) rejects argument |
| +# wildcards in sudoers — so `systemctl restart lchat-*` cannot be granted |
| +# directly. Operators go through this helper instead; sudoers grants only |
| +# `/usr/local/sbin/lchat-svc *` (a whole-argument wildcard, which sudo-rs |
| +# does allow), and the helper confines systemctl to lchat* units. Same shape |
| +# as lchat-env-install: the narrow, auditable privileged surface. Amend via a |
| +# reviewed PR + redeploy, never by hand on a box. |
| +# |
| +# lchat-svc daemon-reload |
| +# lchat-svc <verb> [--now] <lchat-unit...> |
| +# verb ∈ start|stop|restart|reload|enable|disable|status|reset-failed|is-active|is-enabled |
| +set -eu |
| + |
| +verb="${1:-}" |
| +[ -n "$verb" ] || { echo "usage: lchat-svc <verb> [--now] <lchat-unit...> | lchat-svc daemon-reload" >&2; exit 1; } |
| + |
| +# daemon-reload is unit-less and always safe to allow. |
| +if [ "$verb" = daemon-reload ]; then |
| + [ "$#" -eq 1 ] || { echo "daemon-reload takes no arguments" >&2; exit 1; } |
| + exec systemctl daemon-reload |
| +fi |
| + |
| +case "$verb" in |
| + start|stop|restart|reload|enable|disable|status|reset-failed|is-active|is-enabled) ;; |
| + *) echo "verb not permitted: $verb" >&2; exit 1 ;; |
| +esac |
| +shift |
| + |
| +# --now may follow enable/disable; pass it through if present. |
| +now="" |
| +if [ "${1:-}" = "--now" ]; then now="--now"; shift; fi |
| + |
| +[ "$#" -ge 1 ] || { echo "no unit given" >&2; exit 1; } |
| +for u in "$@"; do |
| + case "$u" in |
| + lchat|lchat-*|lchat@*|lchat.*) ;; |
| + *) echo "unit not permitted (lchat* only): $u" >&2; exit 1 ;; |
| + esac |
| +done |
| + |
| +exec systemctl "$verb" ${now:+"$now"} "$@" |
diff --git a/deploy/sudoers-operators b/deploy/sudoers-operators| index b6a0a52..0c5b856 100644 |
| --- a/deploy/sudoers-operators |
| +++ b/deploy/sudoers-operators |
| @@ -2,9 +2,16 @@ |
| # Installed at /etc/sudoers.d/lchat-operators (mode 0440) by the operator-setup stage. |
| # This file IS the operator ops surface: amend it via a reviewed PR + redeploy, |
| # never by hand-editing on a box. Root shells are bootstrap-only. |
| +# |
| +# NB: the fleet runs Ubuntu 26.04, whose default sudo is sudo-rs (the Rust |
| +# rewrite). sudo-rs rejects argument wildcards (e.g. `systemctl restart |
| +# lchat-*`) — only a whole-argument `*` (any args) is allowed. So systemctl is |
| +# scoped through the lchat-svc helper (deploy/lchat-svc), not an argument glob. |
| +# Keep every command spec here either exact-args or `<path> *`. |
| %lchat-operators ALL=(root) NOPASSWD: /opt/lchat/lchat, /opt/lchat/lchat * |
| -%lchat-operators ALL=(root) NOPASSWD: /usr/bin/systemctl start lchat-*, /usr/bin/systemctl stop lchat-*, /usr/bin/systemctl restart lchat-*, /usr/bin/systemctl daemon-reload, /usr/bin/systemctl reset-failed lchat-* |
| -%lchat-operators ALL=(root) NOPASSWD: /usr/bin/systemctl enable lchat-*, /usr/bin/systemctl disable lchat-* |
| +# Scoped systemctl for lchat* units (the helper enforces the prefix): |
| +%lchat-operators ALL=(root) NOPASSWD: /usr/local/sbin/lchat-svc * |
| +# The SSH-flip deadman units are not lchat-prefixed, so grant them exactly: |
| %lchat-operators ALL=(root) NOPASSWD: /usr/bin/systemctl start sshroot-revert.service, /usr/bin/systemctl stop sshroot-revert.timer, /usr/bin/systemctl stop sshflip-revert.timer |
| %lchat-operators ALL=(root) NOPASSWD: /usr/bin/wg, /usr/bin/wg *, /usr/bin/wg-quick save wg0 |
| %lchat-operators ALL=(root) NOPASSWD: /usr/local/sbin/lchat-env-install * |
diff --git a/docs/instance-router.md b/docs/instance-router.md| index 04979bd..6b7d18b 100644 |
| --- a/docs/instance-router.md |
| +++ b/docs/instance-router.md |
| @@ -634,10 +634,21 @@ else binds a unix socket and needs no capabilities (`CapabilityBoundingSet=`). |
| Thereafter operators are named users over the tunnel; root capability exists |
| only through the allowlist, and anything new that needs root gets a line |
| added **via a reviewed PR + redeploy**, never a root shell or a hand-edit on |
| - the box. The helper exists because placing secrets is the one routine |
| - privileged op that doesn't fit a fixed command line: |
| - `sops -d file.env | ssh <box> sudo lchat-env-install <name>`. Every operator |
| - SSH session is published to the public status ledger. Be honest about what |
| + the box. Two small helpers back the allowlist, because a couple of routine |
| + privileged ops don't fit a fixed command line — install both alongside the |
| + sudoers file (`/usr/local/sbin/…`, mode `0755`): |
| + - **`deploy/lchat-env-install`** places a secret: |
| + `sops -d file.env | ssh <box> sudo lchat-env-install <name>`. |
| + - **`deploy/lchat-svc`** runs `systemctl` scoped to `lchat*` units. This |
| + indirection is *required* on Ubuntu 26.04, whose default sudo is |
| + **sudo-rs** (the Rust rewrite): sudo-rs rejects argument wildcards, so a |
| + sudoers line like `systemctl restart lchat-*` is a syntax error — only a |
| + whole-argument `*` is allowed. The helper takes the wildcard (`lchat-svc |
| + *`) and enforces the `lchat*` unit prefix itself. Operators run |
| + `sudo lchat-svc restart lchat-bot@<host>`, `sudo lchat-svc daemon-reload`, |
| + etc. (Keep every other command spec in the sudoers file either |
| + exact-args or `<path> *` for the same reason.) |
| + Every operator SSH session is published to the public status ledger. Be honest about what |
| this buys: the allowlist includes deploying binaries, so it is |
| accident-proofing, attribution, and a reviewable ops surface — **not** a hard |
| security boundary; the hard boundary is the WireGuard key. |