From 0d6fc07fd330bf0f3cb588c22e1c390293b591b9 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 28 Aug 2026 16:08:15 -0700 Subject: [PATCH 1/2] fix(deploy): make the Linux operator preflight actually detect the role MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The preflight probed `tailscale serve status` to prove this account may operate tailscaled. That is a *read*, and tailscaled serves reads to everyone — only writes are gated on the operator role. So on exactly the machine whose Serve write is about to be denied, the probe printed "No serve config", exited 0, and the installer reported ✓ this account may operate tailscaled before going on to fail at the Serve step — after the build, and after `current` had already advanced. That late refusal is the thing the check exists to prevent, so the check could never fire. Read the role itself instead, from `tailscale debug prefs` (OperatorUser), compared against the invoking account. `debug` is an unstable CLI surface, so only a definitive mismatch is fatal: an unreadable or unparseable answer warns and proceeds, degrading to the old late refusal rather than blocking an install that would have worked. The `serve status` read still runs first, since a denial there means something broader is wrong. Also make the late refusal useful when it is reached: `current` has already switched and the service is healthy by then, so say the install is complete but unserved and point at `manage serve`, which adds the HTTPS front door without a reinstall. Neither leg is reachable under DORMOUSE_INSTALL_TEST=1, which never consults Tailscale, so this stays the one preflight rule CI cannot exercise. Verified by hand against a live tailnet: operator set, operator belonging to another account, operator unset, `debug prefs` unavailable, and `debug prefs` returning non-JSON. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01J1jDWEh3MTRaFTqmKPoJkK --- SELF_HOST.md | 5 +++- deploy/local/install-linux.sh | 44 ++++++++++++++++++++++++++++++----- docs/specs/server.md | 25 ++++++++++++++++---- 3 files changed, 63 insertions(+), 11 deletions(-) diff --git a/SELF_HOST.md b/SELF_HOST.md index fd34f0ceb..56e38cf5a 100644 --- a/SELF_HOST.md +++ b/SELF_HOST.md @@ -559,7 +559,10 @@ Do not print the setup password or any credential in the handoff. nothing will start. - **`tailscale serve` is refused for a non-root user (Linux):** grant the operator role — see Prerequisites. The installer checks for this before - building, so hitting it later means the check regressed. + building, so hitting it later means the check regressed. If you do hit it + late, the release is already installed and the service already running: grant + the role, then run `manage serve` to add the HTTPS front door rather than + reinstalling. - **The service disappears at logout (Linux):** that is the documented per-login default, not a fault. This is deliberate — see Prerequisites for the `--linger` opt-out, and make the availability change in checkpoint 6 explicit if you take diff --git a/deploy/local/install-linux.sh b/deploy/local/install-linux.sh index b40eda7b0..31ef1130e 100755 --- a/deploy/local/install-linux.sh +++ b/deploy/local/install-linux.sh @@ -320,7 +320,7 @@ die_needs_operator() { sudo tailscale set --operator=\$USER - This installer will not run sudo for you." + This installer will not run sudo for you.${3:-}" } # ------------------------------------------------------------------ start ---- @@ -336,6 +336,7 @@ printf '%sDormouse selfhost server — Linux installer%s\n' "$C_BLD" "$C_OFF" # failed install. The probe temporaries are here too so that a `die` anywhere # between staging and the switch cannot leak them. TS_STATUS_JSON="" +TS_PREFS_JSON="" WS_STATE="$REPO_ROOT/node_modules/.pnpm-workspace-state-v1.json" WS_STATE_BACKUP="" PROBE_STATE="" @@ -349,7 +350,7 @@ restore_workspace_state() { } cleanup() { restore_workspace_state - rm -f "$TS_STATUS_JSON" "$PROBE_LOG" + rm -f "$TS_STATUS_JSON" "$TS_PREFS_JSON" "$PROBE_LOG" [ -n "$PROBE_STATE" ] && rm -rf "$PROBE_STATE" return 0 } @@ -401,13 +402,38 @@ else esac # Prove the operator role now rather than at the Serve step, which happens after - # the build and after `current` has already moved. A node with no Serve - # configuration exits nonzero too, so only a refusal is fatal here. + # the build and after `current` has already moved. + # + # `tailscale serve status` cannot answer this on its own: it is a *read*, and + # tailscaled serves reads to everyone. Only writes are gated on the operator + # role, so on exactly the machine whose Serve write is about to be denied the + # read probe prints "No serve config" and exits 0. The role itself is the thing + # to check, and `debug prefs` is where tailscaled exposes it. The read still + # runs first: a denial *there* means something broader than the operator role + # is wrong. A node with no Serve configuration exits nonzero too, so only a + # refusal is fatal. if [ "$TEST_MODE" != "1" ]; then if ! SERVE_PROBE="$(ts serve status 2>&1)"; then ts_denied "$SERVE_PROBE" && die_needs_operator "\`tailscale serve status\`" "$SERVE_PROBE" fi - ok "this account may operate tailscaled" + + # Only a definitive mismatch is fatal. `debug` is an explicitly unstable CLI + # surface, so an unreadable or unparseable answer must not block an install + # that would otherwise succeed; it degrades to the late refusal this check + # exists to pull earlier, which is no worse than having no check. + TS_PREFS_JSON="$(mktemp_file ts-prefs)" + if ts debug prefs 2>/dev/null > "$TS_PREFS_JSON" \ + && TS_OPERATOR="$(json_query "$TS_PREFS_JSON" "OperatorUser")"; then + if [ -n "$TS_OPERATOR" ] && [ "$TS_OPERATOR" = "$(id -un)" ]; then + ok "this account may operate tailscaled" + else + die_needs_operator "\`tailscale serve\`" \ + "tailscaled's operator is ${TS_OPERATOR:-unset}, not this account ($(id -un))." + fi + else + warn "could not read tailscaled's operator role from \`tailscale debug prefs\`." + warn "If it is unset, the Serve step below will be refused." + fi fi fi @@ -1502,7 +1528,13 @@ elif [ "$NEEDS_SERVE" = "1" ]; then info "tailscale serve --bg $LOOPBACK_PORT" detail "Tailscale may open a browser consent flow if HTTPS is not yet enabled." SERVE_ERR="$(ts serve --bg "$LOOPBACK_PORT" 2>&1)" || { - ts_denied "$SERVE_ERR" && die_needs_operator "\`tailscale serve\`" "$SERVE_ERR" + ts_denied "$SERVE_ERR" && die_needs_operator "\`tailscale serve\`" "$SERVE_ERR" " + + The release is installed and the service is running on 127.0.0.1:$LOOPBACK_PORT; + only the HTTPS front door is missing. After granting the role you can finish + without a reinstall: + + \"$BIN_DIR/manage\" serve" die "\`tailscale serve --bg $LOOPBACK_PORT\` failed: ${SERVE_ERR}" } ok "Serve configured" diff --git a/docs/specs/server.md b/docs/specs/server.md index 6b9fced63..a67290d53 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -991,10 +991,27 @@ Mechanical traps the scripts encode, each of which fails silently otherwise: * **Linux `tailscaled` answers only root unless an operator is set.** (Linux.) Its local API socket is root-owned, so an unprivileged `tailscale serve` is refused. Left unchecked that refusal arrives at the Serve step — after the - build, and after `current` has already advanced to the new release. The - installer probes `tailscale serve status` in preflight instead, matches the - denial, and prints the one-line fix (`sudo tailscale set --operator=$USER`) - rather than running sudo itself. + build, and after `current` has already advanced to the new release. Preflight + checks the operator role instead, and prints the one-line fix + (`sudo tailscale set --operator=$USER`) rather than running sudo itself. + + **The check reads the role, not a Serve command's exit status.** Only *writes* + to the local API are gated on the operator role; tailscaled serves reads to + everyone. So `tailscale serve status` prints `No serve config` and exits 0 on + exactly the machine whose Serve write is about to be denied, and a preflight + built on it silently passes and defers the refusal to the late step it exists + to avoid. The role is read from `tailscale debug prefs` (`OperatorUser`) and + compared against the invoking account. `debug` is an unstable CLI surface, so + only a *definitive* mismatch is fatal: an unreadable or unparseable answer + warns and proceeds, degrading to the late refusal rather than blocking an + install that would have worked. The `serve status` read still runs first, + because a denial *there* means something broader is wrong. Neither leg is + reachable in `DORMOUSE_INSTALL_TEST=1`, which never consults Tailscale, so + this is the one preflight rule CI cannot exercise. + + Because the late refusal is reached with `current` already switched and the + service healthy, it reports that the install is complete but unserved and + points at `manage serve`, which re-applies the mapping without a reinstall. * **`systemctl --user` needs a real login session, not just a shell.** (Linux.) Under `su`, or anywhere `XDG_RUNTIME_DIR` is unset and no user manager is running, it fails with a message about `DBUS_SESSION_BUS_ADDRESS` that does From f48c07f49c3e1e0c7320a71fd96d7113672404ae Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 28 Aug 2026 16:21:39 -0700 Subject: [PATCH 2/2] fix(deploy): treat an omitted OperatorUser as a definitive unset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review catch, and it invalidated one row of the previous commit's verification table. `ipn.Prefs.OperatorUser` carries `json:",omitempty"` (confirmed in tailscale v1.102.3), so with no operator set the key is absent from `debug prefs` rather than present and empty. Deciding readability with the same `json_query` call that reads the answer therefore sent the commonest form of this bug — nobody ever ran `tailscale set --operator` — into the lenient warn branch, reopening the exact miss the check exists to close. The earlier "operator unset" test passed only because its stub forced a literal `"OperatorUser": ""` with sed, a shape tailscale never emits. Re-tested against a stub that deletes the key, which is the real shape. Decide readability with `ControlURL`, which has no `omitempty` and is always marshalled; absent-or-empty `OperatorUser` on a blob that parsed is then a definitive unset rather than an unknown. Also from review: - Drop json_query's stderr on this path, and only this path: every other call site reads known-good JSON, whereas here a parse failure is an expected outcome and the runner's stack trace buried the two warnings. - Let `die_needs_operator`'s caller supply the lead clause. One of the four sites has not run anything yet — preflight predicts the refusal — so the hardcoded "was refused" was wrong there; it now reads "will be refused". Header comment corrected to four call sites with `$3` documented. - Fix the spec's stated reason for the CI gap: `DORMOUSE_INSTALL_TEST=1` alone still consults Tailscale; it is the probe's own TEST_MODE gate, plus the injected origin CI pairs with it, that put this out of reach. Re-verified against a live tailnet: operator set, operator belonging to another account, operator key absent, `debug prefs` unavailable, and `debug prefs` returning non-JSON (no stack trace). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01J1jDWEh3MTRaFTqmKPoJkK --- deploy/local/install-linux.sh | 36 +++++++++++++++++++++++++++-------- docs/specs/server.md | 15 +++++++++++++-- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/deploy/local/install-linux.sh b/deploy/local/install-linux.sh index 31ef1130e..b3cb45efd 100755 --- a/deploy/local/install-linux.sh +++ b/deploy/local/install-linux.sh @@ -310,11 +310,15 @@ ts_denied() { esac } -# One remediation, three call sites. The `sudo` line is the single most +# One remediation, four call sites. The `sudo` line is the single most # important operator-facing instruction in this script, so it is written once. -# $1 = what was refused, $2 = the CLI's own output +# The lead clause is the caller's, because one of the four has not run anything +# yet: preflight predicts the refusal rather than reporting one. +# $1 = lead clause, phrased for what actually happened +# $2 = supporting detail (the CLI's own output, or what the role check found) +# $3 = optional trailing paragraph, for context only some call sites have die_needs_operator() { - die "$1 was refused for this user: $2 + die "$1: $2 On Linux the tailscaled control socket is root-owned. Grant this account the operator role once, then re-run: @@ -372,7 +376,7 @@ else # so the capture is always empty and the operator-role remediation below # becomes dead code with a blank error body. TS_STATUS_ERR="$(ts status --json 2>&1 > "$TS_STATUS_JSON")" || { - ts_denied "$TS_STATUS_ERR" && die_needs_operator "\`tailscale status\`" "$TS_STATUS_ERR" + ts_denied "$TS_STATUS_ERR" && die_needs_operator "\`tailscale status\` was refused for this user" "$TS_STATUS_ERR" die "\`tailscale status --json\` failed. Is tailscaled running and signed in? (systemctl status tailscaled) ${TS_STATUS_ERR}" } @@ -414,20 +418,36 @@ else # refusal is fatal. if [ "$TEST_MODE" != "1" ]; then if ! SERVE_PROBE="$(ts serve status 2>&1)"; then - ts_denied "$SERVE_PROBE" && die_needs_operator "\`tailscale serve status\`" "$SERVE_PROBE" + ts_denied "$SERVE_PROBE" && die_needs_operator "\`tailscale serve status\` was refused for this user" "$SERVE_PROBE" fi # Only a definitive mismatch is fatal. `debug` is an explicitly unstable CLI # surface, so an unreadable or unparseable answer must not block an install # that would otherwise succeed; it degrades to the late refusal this check # exists to pull earlier, which is no worse than having no check. + # + # Readability is decided by a *different* field than the answer, because + # `ipn.Prefs.OperatorUser` carries `json:",omitempty"`: with no operator set + # the key is absent, which is indistinguishable from an unparseable blob if + # the answer field is also the liveness probe. Reading it that way would send + # the commonest case of this bug — nobody ever ran `tailscale set --operator` + # — into the lenient branch, i.e. exactly the miss this whole check exists to + # close. `ControlURL` has no `omitempty` and is always marshalled, so it + # answers "did prefs parse?" on its own; absent-or-empty `OperatorUser` on a + # blob that parsed is then a definitive unset, not an unknown. + # + # json_query's stderr is dropped here and only here: every other call site + # reads JSON already known to be well-formed, whereas on this path a parse + # failure is an expected outcome and its runner's stack trace would bury the + # two warnings below. TS_PREFS_JSON="$(mktemp_file ts-prefs)" if ts debug prefs 2>/dev/null > "$TS_PREFS_JSON" \ - && TS_OPERATOR="$(json_query "$TS_PREFS_JSON" "OperatorUser")"; then + && json_query "$TS_PREFS_JSON" "ControlURL" >/dev/null 2>&1; then + TS_OPERATOR="$(json_query "$TS_PREFS_JSON" "OperatorUser" 2>/dev/null || true)" if [ -n "$TS_OPERATOR" ] && [ "$TS_OPERATOR" = "$(id -un)" ]; then ok "this account may operate tailscaled" else - die_needs_operator "\`tailscale serve\`" \ + die_needs_operator "\`tailscale serve\` will be refused for this user" \ "tailscaled's operator is ${TS_OPERATOR:-unset}, not this account ($(id -un))." fi else @@ -1528,7 +1548,7 @@ elif [ "$NEEDS_SERVE" = "1" ]; then info "tailscale serve --bg $LOOPBACK_PORT" detail "Tailscale may open a browser consent flow if HTTPS is not yet enabled." SERVE_ERR="$(ts serve --bg "$LOOPBACK_PORT" 2>&1)" || { - ts_denied "$SERVE_ERR" && die_needs_operator "\`tailscale serve\`" "$SERVE_ERR" " + ts_denied "$SERVE_ERR" && die_needs_operator "\`tailscale serve\` was refused for this user" "$SERVE_ERR" " The release is installed and the service is running on 127.0.0.1:$LOOPBACK_PORT; only the HTTPS front door is missing. After granting the role you can finish diff --git a/docs/specs/server.md b/docs/specs/server.md index a67290d53..c30236204 100644 --- a/docs/specs/server.md +++ b/docs/specs/server.md @@ -1004,9 +1004,20 @@ Mechanical traps the scripts encode, each of which fails silently otherwise: compared against the invoking account. `debug` is an unstable CLI surface, so only a *definitive* mismatch is fatal: an unreadable or unparseable answer warns and proceeds, degrading to the late refusal rather than blocking an - install that would have worked. The `serve status` read still runs first, + install that would have worked. + + **Readability is decided by a different field than the answer.** + `ipn.Prefs.OperatorUser` carries `json:",omitempty"`, so an unset operator + omits the key rather than emitting an empty string — indistinguishable from an + unparseable blob if the answer field doubles as the liveness probe, which would + route the commonest form of this bug (nobody ever ran `tailscale set + --operator`) into the lenient branch and reopen the exact miss the check + closes. `ControlURL`, which has no `omitempty`, answers "did prefs parse?"; + absent-or-empty `OperatorUser` on a blob that parsed is then a definitive + unset. The `serve status` read still runs first, because a denial *there* means something broader is wrong. Neither leg is - reachable in `DORMOUSE_INSTALL_TEST=1`, which never consults Tailscale, so + reachable under `DORMOUSE_INSTALL_TEST=1`, which gates the whole probe out — + and CI pairs it with an injected origin, which skips Tailscale altogether — so this is the one preflight rule CI cannot exercise. Because the late refusal is reached with `current` already switched and the