Recipe Book

Letting an unprivileged panel ask root for an update

How Cat-AgentUI's System update page works when the code belongs to root and the service runs as its own user: the panel can only drop one short line into an inbox, and a root helper decides what that line is allowed to mean.

A self-updating web panel is a slightly uncomfortable idea. The safest way to run Cat-AgentUI is with the checkout owned by root and the service running as a dedicated user under systemd, with User= and NoNewPrivileges= set. In that setup the panel cannot change its own code, and it cannot use sudo either. That is the point. But it also means the Admin → System update page I added in a29a574 could not do anything on those deployments.

I did not want to fix that by giving the service more rights. This entry is the recipe I ended up with in f0642f8: keep the panel unprivileged, and let it ask a root helper for exactly two things.

The ingredients

Piece Runs as Job
The panel (server/src/update-helper.ts) the service user writes one-line requests, reads status and log
The inbox, /var/lib/cat-agentui-update/<instance>/inbox owned by the service user, mode 0700 the only place under the helper's state directory the panel can write
cat-agentui-update@<instance>.path systemd starts the helper when a request file appears
/usr/local/sbin/cat-agentui-update --serve root validates the request, merges, builds, restarts or rolls back
/etc/cat-agentui/instances.d/<instance>.conf root-owned says which checkout, service, remote and branch this instance means

The important design choice is that everything that matters, which repository, which service, which branch, which SSH key, lives in the root-owned config file. The panel never gets to say any of it.

Step 1: make the request boring

The panel can send check or update. A request is a single line:

text
1 check <request-id>
1 update <request-id> <40-hex commit>

On the panel side, the file is written to a temporary name and then renamed into place, so the helper never reads half of it. On the root side, the helper reads it with a deliberately small function:

bash
take_inbox() {
  local file="$inbox/$1" line=""
  if [[ -f "$file" && ! -L "$file" ]]; then
    line="$(head -c 200 -- "$file" | head -n 1 | tr -cd '0-9a-z -')" || line=""
  fi
  rm -f -- "$file"
  printf '%s' "$line"
}

Because the inbox belongs to the less trusted user, the helper refuses to follow a symlink out of it, takes at most 200 bytes and one line, and strips every character except digits, lowercase letters, spaces and hyphens. Then the line has to match the expected shape exactly: the protocol number, an action, a UUID-shaped id, and for update a full 40-character commit hash. Anything else, including an extra trailing field, is logged as a malformed request and ignored.

The comment at the top of the script states the rule I was aiming for: a request names a commit at most, and nothing read from the inbox reaches a shell.

Step 2: only allow forward motion

Even a well-formed commit hash should not be able to move the checkout anywhere it likes. Before it touches anything, the helper checks two ancestry conditions:

bash
git merge-base --is-ancestor "$old_head" "$target" \
  && git merge-base --is-ancestor "$target" "refs/remotes/$GIT_REMOTE/$GIT_BRANCH"

The target must be ahead of what is running now, and it must already be on the configured upstream branch. The merge itself is git merge --ff-only. So the worst a compromised panel could do through this channel is ask for an update to a commit that the real upstream already contains, which is the same thing an operator would do by hand.

The config values get the same treatment. The instance name, SERVICE_NAME, GIT_REMOTE, GIT_BRANCH, SSH_KEY and HEALTH_URL are each checked against a narrow pattern before use, even though they come from a root-owned file. That costs a few lines and removes a class of surprises.

Step 3: let the panel vote, not act

An update still has steps only the panel can do well: snapshot its database, start the new build in a self-test mode, and wait for replies that are still running. So the flow is split:

  1. The panel snapshots the database and drops the request.
  2. The helper merges, installs dependencies and builds, then waits.
  3. The panel self-tests the new build and waits for in-flight work.
  4. The panel drops go or abort with the same request id.
  5. On go, the helper swaps the frontend in and restarts the service. On abort, a failure, or no answer within 30 minutes, it rolls back.

Leftover go or abort files from an earlier update are deleted before a new one starts, and a verdict only counts if it carries the current request id. If the panel restarts unexpectedly halfway, it checks whether it is already running the new version, and either lets the helper finish or asks it to roll back.

Step 4: refuse the setups this is not for

The helper's setup command, --enable-panel, refuses to run if the checkout is not owned by root, or if the service itself runs as root. In both cases the panel can update itself without a helper, and adding one would only add another moving part.

A related fix in 71b9dfa covers the other side. When the panel updates its own checkout, it now also refuses if the repository's owner differs from the user the service runs as, even if safe.directory has been set so git would allow it. Otherwise the update leaves behind files with a different owner, and the operator's next manual git pull fails on them. The error messages now also say which problem it is: git missing, not a git checkout, or dubious ownership.

What I would check if I were reviewing this

I tried to write the list I would want someone else to go through:

  • Can anything the service user controls end up as an argument to git, npm or systemctl? It should only ever be the validated commit hash.
  • Can the service user replace the inbox directory with a symlink? The helper checks that the inbox is a real directory and that inbox files are regular files, not links.
  • What happens on every failure path after the merge? The exit trap rolls back unless the new version has already gone live, and records it in the status file if the rollback itself fails.
  • Does a slow or silent panel leave root waiting forever? No: the wait for a verdict is capped, and npm steps run under a timeout.

None of this makes a self-updating panel risk-free. It does keep the shape I care about: the privileged side owns the decisions, and the unprivileged side can only ask, in a language small enough to check completely.