Skip to main content

max / alloy

Start the mesh daemon before signing in, under one escalation The preset ships tailscaled disabled on purpose, so on a fresh install 'tailscale up' escalated, spent the user's password, and only then failed reading a socket nothing was listening on. Failing after the password is the expensive place to fail: the one cost the screen asked for had already been paid. alloy-mesh-up enables the unit and then execs the sign-in, so it is one polkit question rather than two. A script rather than 'run0 sh -c' because Invocation is argv so a command runs exactly as displayed, and --login-server carries a value the user typed; as argv it stays data. The script echoes both commands it runs, so the terminal still names tailscale and the abstraction conceals nothing. Enabling rather than starting: enrollment is the opt-in the preset is waiting for, and a mesh that evaporates at the next boot is not one anyone asked for.
Author: Max Johnson <me@maxj.phd> · 2026-09-03 22:54 UTC
Signed with PGP, not checked
Commit: da0e7a012ec11c438d11177d07b73687f714466c
Parent: 04eb110
4 files changed, +121 insertions, -4 deletions
@@ -2721,6 +2721,32 @@
2721 2721 done; \
2722 2722 echo "web handler: $(sed -n 's|^x-scheme-handler/https=||p' /etc/xdg/mimeapps.list | head -n 1), registered for http, https and text/html"
2723 2723
2724 + # =====================================================================
2725 + # The mesh sign-in helper — assert the client, the unit and the script agree.
2726 + #
2727 + # crates/alloy/src/mesh.rs runs `run0 alloy-mesh-up` rather than
2728 + # `run0 tailscale up`, because the preset below ships tailscaled disabled and a
2729 + # sign-in that starts no daemon spends the user's password before it fails. All
2730 + # three pieces have to be in the image for that to hold, and each one goes
2731 + # missing in a different way: the script by a COPY that stopped matching, the
2732 + # unit by a tailscale package that stopped shipping it, the client by the
2733 + # package being dropped from the client profile.
2734 + #
2735 + # Asserted here rather than trusted because the failure is invisible at build
2736 + # time and lands on a first boot, which is the one run where a user has no other
2737 + # way in and no reason to suspect the image.
2738 + RUN set -eu; \
2739 + test -x /usr/bin/alloy-mesh-up \
2740 + || { echo "mesh.rs runs alloy-mesh-up under run0 and it is not executable in the image" >&2; exit 1; }; \
2741 + command -v tailscale >/dev/null \
2742 + || { echo "alloy-mesh-up execs tailscale and the client is not in the image" >&2; exit 1; }; \
2743 + unit=/usr/lib/systemd/system/tailscaled.service; \
2744 + test -f "$unit" \
2745 + || { echo "alloy-mesh-up enables tailscaled.service and $unit is not in the image" >&2; exit 1; }; \
2746 + grep -q 'systemctl enable --now tailscaled.service' /usr/bin/alloy-mesh-up \
2747 + || { echo "alloy-mesh-up no longer enables tailscaled; the sign-in would run against a stopped daemon" >&2; exit 1; }; \
2748 + echo "mesh: alloy-mesh-up enables tailscaled.service, then signs in with $(tailscale version | head -n 1)"
2749 +
2724 2750 # =====================================================================
2725 2751 # The installer's ssh door — assert both halves of the gate are intact.
2726 2752 # =====================================================================
@@ -294,8 +294,25 @@
294 294 /// No `--pipe`. That flag is for the build scripts, which capture output;
295 295 /// here the pty run0 gives its child by default is exactly what is wanted,
296 296 /// since the child is about to print a link for a human to read.
297 + ///
298 + /// # Why this drives a script and not `tailscale up`
299 + ///
300 + /// `tailscale up` is a client for a daemon, and
301 + /// `etc/systemd/system-preset/50-alloy.preset` ships `disable
302 + /// tailscaled.service` on purpose. So on a fresh install the daemon is not
303 + /// running, and running the sign-in alone escalates, spends the user's
304 + /// password, and only then fails on a socket nothing is listening on. The
305 + /// order was the bug: the daemon has to come up first, and it needs the same
306 + /// root the sign-in does.
307 + ///
308 + /// `alloy-mesh-up` is that order, in one file so it is one polkit question
309 + /// instead of two. It is a script rather than `run0 sh -c` because
310 + /// [`Invocation`] is argv precisely so a command runs exactly as displayed,
311 + /// and `--login-server` carries a value the user typed; as argv it stays
312 + /// data. The script echoes both commands it runs, so the terminal the user
313 + /// is handed still names `tailscale` and the abstraction conceals nothing.
297 314 fn enroll(&self, login_server: Option<&str>) -> Invocation {
298 - let invocation = Invocation::new("run0").args(["tailscale", "up"]);
315 + let invocation = Invocation::new("run0").arg("alloy-mesh-up");
299 316 match login_server {
300 317 Some(server) => invocation.arg(format!("--login-server={server}")),
301 318 None => invocation,
@@ -334,12 +334,30 @@
334 334 assert!(error.contains("https://hs.example.org"), "got: {error}");
335 335 }
336 336
337 + // The daemon and the sign-in go under one escalation, which is why this is a
338 + // script and not `tailscale up`. See `Tailscale::enroll`: the preset ships
339 + // tailscaled disabled, so running the sign-in alone spends the user's password
340 + // and then fails on a socket nothing is listening on.
337 341 #[test]
338 - fn enrollment_runs_tailscale_up_under_run0() {
339 - assert_eq!(Tailscale.enroll(None).display(), "run0 tailscale up");
342 + fn enrollment_runs_the_mesh_helper_under_run0() {
343 + assert_eq!(Tailscale.enroll(None).display(), "run0 alloy-mesh-up");
340 344 assert_eq!(
341 345 Tailscale.enroll(Some("https://hs.example.org")).display(),
342 - "run0 tailscale up --login-server=https://hs.example.org"
346 + "run0 alloy-mesh-up --login-server=https://hs.example.org"
347 + );
348 + }
349 +
350 + // The server is carried as one argv element rather than spliced into a command
351 + // string. `validate_login_server` is what stops a hostile value reaching here,
352 + // but the carrier is why it cannot matter: argv stays data all the way to
353 + // `tailscale up "$@"` in the helper. Asserted with a value that would split on
354 + // a shell word boundary, since a quoted display is what a single argument that
355 + // needs quoting looks like.
356 + #[test]
357 + fn the_login_server_stays_one_argument() {
358 + assert_eq!(
359 + Tailscale.enroll(Some("https://hs.example.org x")).display(),
360 + "run0 alloy-mesh-up '--login-server=https://hs.example.org x'"
343 361 );
344 362 }
345 363
@@ -1,0 +1,56 @@
1 + #!/bin/sh
2 + # alloy-mesh-up — start the mesh daemon, then sign in, under one escalation.
3 + #
4 + # `tailscale up` talks to tailscaled over a local socket and does nothing on its
5 + # own behalf. etc/systemd/system-preset/50-alloy.preset ships
6 + # `disable tailscaled.service`, deliberately: joining a mesh reaches out to a
7 + # control server and is a question the machine should be asked rather than
8 + # assume. So on a fresh install the daemon is not running, and a sign-in that
9 + # runs `tailscale up` alone escalates, takes the user's password, and then fails
10 + # reading a socket nothing is listening on. That is the whole bug this exists to
11 + # close, and it failed *after* the password, which is the expensive place to
12 + # fail: the user has already paid the one cost the screen asked them for.
13 + #
14 + # Enrolling IS the opt-in the preset is waiting for, so this enables the unit
15 + # rather than merely starting it. A mesh that evaporates at the next boot is not
16 + # a mesh anyone asked for, and the preset's comment already names enrollment as
17 + # where the unit is meant to come on.
18 + #
19 + # Why a script and not two commands from the console. Both halves need root, and
20 + # two `run0` invocations are two polkit questions unless the caching happens to
21 + # hold. One file is one prompt. It is also why this is not `run0 sh -c '...'`
22 + # from the console: crates/alloy/src/cli.rs holds Invocation as argv precisely so
23 + # a command is executed exactly as displayed, with no shell and no quoting
24 + # round-trip, and --login-server carries a value the user typed. Passing it as
25 + # argv to "$@" keeps that property.
26 + #
27 + # Every argument is forwarded to `tailscale up` untouched.
28 + #
29 + # The two real commands are echoed before they run. The console's log pane shows
30 + # this script's path rather than what it drives, and mesh.rs is explicit that the
31 + # abstraction must not conceal which tool is running, so the terminal the user
32 + # was just handed says it.
33 +
34 + set -eu
35 +
36 + echo "+ systemctl enable --now tailscaled.service"
37 + systemctl enable --now tailscaled.service
38 +
39 + # Not a fixed sleep. `tailscale up` fails on a socket that exists but is not yet
40 + # answering, and `systemctl --now` returns when the unit is active rather than
41 + # when tailscaled has finished opening it. Ten tries at a tenth of a second is
42 + # a second of patience for something that normally takes one round.
43 + i=0
44 + while [ "$i" -lt 10 ]; do
45 + tailscale status >/dev/null 2>&1 && break
46 + # `status` exits non-zero on a running daemon that is merely logged out,
47 + # which is the state this script is always in. Distinguish "not answering"
48 + # from "answering that there is nothing to report" by asking for the version,
49 + # which needs the socket and not an account.
50 + tailscale version --daemon >/dev/null 2>&1 && break
51 + i=$((i + 1))
52 + sleep 0.1
53 + done
54 +
55 + echo "+ tailscale up $*"
56 + exec tailscale up "$@"