eleven / ocho Public

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>
Paul Campbell 4c4bd3ed8eed5b3383030367a347d505ef6d5db4 parent 534bfeb
3 files changed, +68 −6
  • deploy/lchat-svc +44 −0
  • deploy/sudoers-operators +9 −2
  • docs/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.