max / alloy
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 "$@" |