From 0339b13d12c4f73273f215d10b3eba2327a3e310 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 7 Aug 2026 15:10:30 +0200 Subject: [PATCH] Join the tailnet from a cidata tailscale_authkey (#97) * Join the tailnet from a cidata tailscale_authkey A tailscale_authkey file on the autoinstall drive gets the installed machine onto the tailnet on its own: the tailscale package is bundled in the offline mirror and installed only when the key is present, the key is staged root-only at /etc/tailscale/authkey, and a oneshot first-boot unit runs tailscale up once the network is really there. On success the key is deleted and the unit disables itself; on failure both survive to retry on the next boot, so an offline install joins whenever it first gets connectivity. ufw allows traffic in on tailscale0 via the same write-the-rule-then-ignore-the-chroot-exit-code dance as the SSH phase. Bundling rather than fetching at first boot is deliberate: pacman -Sy tailscale on a fresh machine is a partial upgrade, and pacman -Syu means an unattended full system upgrade racing the user's first login. The offline mirror is a coherent snapshot; installing from it keeps the target consistent and the install offline-capable. See plans/tailscale.md. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Xc74h4WSYpuYPGuYbFAR1q * Do not hold boot hostage for the tailnet join Review caught that the join unit was Type=oneshot wanted by multi-user.target: target units implicitly gain After= for their Wants=, so an offline machine's boot -- SDDM included -- stalled for the full 10-minute timeout, and once timed out the join never retried within that boot. Type=simple counts as started the moment it forks, so boot proceeds while the join retries in the background for as long as the boot lasts. Cleanup moves inside the script, sequenced after tailscale up succeeds, which preserves the retry-across-boots behavior: key and unit survive a boot with no connectivity, and the key never outlives a successful join. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Xc74h4WSYpuYPGuYbFAR1q --------- Co-authored-by: Claude Fable 5 --- README.md | 3 + builder/archinstall.packages | 1 + configs/airootfs/root/.automated_script.sh | 3 +- .../usr/local/bin/omarchy-cidata-load | 2 +- .../usr/local/bin/omarchy-iso-install | 1 + .../share/omarchy-iso/orchestrator/context.py | 2 + .../share/omarchy-iso/orchestrator/main.py | 2 + .../omarchy-iso/orchestrator/phases_impl.py | 116 +++++++++++++ plans/tailscale.md | 124 +++++++++++++ test/cidata-load-test.sh | 4 +- test/test_configure_tailscale.py | 164 ++++++++++++++++++ 11 files changed, 419 insertions(+), 3 deletions(-) create mode 100644 plans/tailscale.md create mode 100644 test/test_configure_tailscale.py diff --git a/README.md b/README.md index 2cafd76..ebd4ae8 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ These are the configurator's own output files, so the way to get a starting set | `user_email_address.txt` | No | Git email | | `user_encrypt_installation.txt` | No | `true` when `user_configuration.json` carries a `disk_encryption` block; defaults to false | | `authorized_keys` | No | SSH public keys in sshd's own format, one per line | +| `tailscale_authkey` | No | Tailscale auth key; the machine joins your tailnet on first boot | Both required files must be present or the installer falls back to the configurator. Generate the password hash for `user_credentials.json` with `openssl passwd -6 "yourpassword"`. @@ -51,6 +52,8 @@ ssh-ed25519 AAAA... you@host When `authorized_keys` is present, autoinstall installs it as the user's `~/.ssh/authorized_keys`, enables `sshd`, and adds a `ufw allow ssh` rule — a stock Omarchy install ships openssh with the service disabled and its firewall opens neither port 22 nor anything else beyond LocalSend. Networking needs nothing extra; NetworkManager is already enabled with DHCP. Password SSH authentication is left at the distro default. An `authorized_keys` with no usable keys fails the install rather than producing a machine nobody can reach. +When `tailscale_authkey` is present (one key, blank lines and `#` comments ignored), the install adds the `tailscale` package from the ISO's bundled mirror — nothing is fetched from the network at install or boot — and stages the join for first boot: the key lands at `/etc/tailscale/authkey` (root-only), `tailscaled` is enabled, ufw allows traffic in on `tailscale0`, and a background unit runs `tailscale up` once the network is actually up, retrying until it succeeds without holding up the boot. After a successful join the key is deleted and the unit disables itself; until then both survive reboots, so a machine installed offline joins whenever it first gets connectivity. The node appears on the tailnet under the configured hostname. Use a reusable, pre-authorized (tagged) key so one drive image serves many machines — or an ephemeral key for disposable VMs. + ### Building the drive ```bash diff --git a/builder/archinstall.packages b/builder/archinstall.packages index bd022ab..643f9c9 100644 --- a/builder/archinstall.packages +++ b/builder/archinstall.packages @@ -12,3 +12,4 @@ openssh pipewire snapper sof-firmware +tailscale diff --git a/configs/airootfs/root/.automated_script.sh b/configs/airootfs/root/.automated_script.sh index 29f6262..5a57508 100644 --- a/configs/airootfs/root/.automated_script.sh +++ b/configs/airootfs/root/.automated_script.sh @@ -116,4 +116,5 @@ rm -f /run/omarchy-install/state.json --full-name-file /root/user_full_name.txt \ --email-file /root/user_email_address.txt \ --encrypt-file /root/user_encrypt_installation.txt \ - --authorized-keys-file /root/authorized_keys + --authorized-keys-file /root/authorized_keys \ + --tailscale-authkey-file /root/tailscale_authkey diff --git a/configs/airootfs/usr/local/bin/omarchy-cidata-load b/configs/airootfs/usr/local/bin/omarchy-cidata-load index 28c4baa..67bcd28 100755 --- a/configs/airootfs/usr/local/bin/omarchy-cidata-load +++ b/configs/airootfs/usr/local/bin/omarchy-cidata-load @@ -38,7 +38,7 @@ mount -o ro "$device" "$mnt" || exit 1 loaded=1 if [[ -f $mnt/user_configuration.json && -f $mnt/user_credentials.json ]]; then files=("$mnt"/user_configuration.json "$mnt"/user_credentials.json) - for file in user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys; do + for file in user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys tailscale_authkey; do [[ -f $mnt/$file ]] && files+=("$mnt/$file") done if cp "${files[@]}" "$dest"/; then diff --git a/configs/airootfs/usr/local/bin/omarchy-iso-install b/configs/airootfs/usr/local/bin/omarchy-iso-install index 9f0c716..baceddf 100755 --- a/configs/airootfs/usr/local/bin/omarchy-iso-install +++ b/configs/airootfs/usr/local/bin/omarchy-iso-install @@ -17,6 +17,7 @@ while [[ $# -gt 0 ]]; do --email-file) export OMARCHY_INSTALL_EMAIL_FILE="$2"; shift 2 ;; --encrypt-file) export OMARCHY_INSTALL_ENCRYPT_FILE="$2"; shift 2 ;; --authorized-keys-file) export OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE="$2"; shift 2 ;; + --tailscale-authkey-file) export OMARCHY_INSTALL_TAILSCALE_AUTHKEY_FILE="$2"; shift 2 ;; *) echo "omarchy-iso-install: unknown arg $1" >&2; exit 2 ;; esac done diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py index 5b6008b..72958e1 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py @@ -19,6 +19,7 @@ class InstallContext: email: str encrypt: bool authorized_keys_path: Path | None + tailscale_authkey_path: Path | None user_configuration: dict user_credentials: dict @@ -61,6 +62,7 @@ class InstallContext: email=_read_text(os.environ.get("OMARCHY_INSTALL_EMAIL_FILE")), encrypt=_read_text(os.environ.get("OMARCHY_INSTALL_ENCRYPT_FILE")).lower() in ("true", "yes", "1"), authorized_keys_path=_optional_path(os.environ.get("OMARCHY_INSTALL_AUTHORIZED_KEYS_FILE")), + tailscale_authkey_path=_optional_path(os.environ.get("OMARCHY_INSTALL_TAILSCALE_AUTHKEY_FILE")), user_configuration=user_configuration, user_credentials=json.loads(creds_path.read_text()), arch_config_path=arch_config_path, diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py index 3d35ef7..a73f7db 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py @@ -39,6 +39,7 @@ def build_phases(ctx: InstallContext): configure_dns_resolver, configure_login, configure_ssh_access, + configure_tailscale, validate_boot, ) @@ -52,6 +53,7 @@ def build_phases(ctx: InstallContext): ("Finalizing user", run_chroot_finalizer), ("Configuring login", configure_login), ("Configuring SSH access", configure_ssh_access), + ("Configuring Tailscale", configure_tailscale), ("Configuring DNS resolver", configure_dns_resolver), ("Validating boot setup", validate_boot), ] diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py index e391225..76a28e2 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py @@ -16,6 +16,7 @@ Phase ordering (full-disk and protected/pre-mounted): run_chroot_finalizer → arch-chroot -u user omarchy-finalize-user configure_login → sddm state + encrypted-install autologin configure_ssh_access → authorized_keys for autoinstall; no-op otherwise + configure_tailscale → tailnet join staged for first boot; no-op otherwise validate_boot → assert UKI / limine.conf / kernel cmdline are sane """ @@ -290,6 +291,14 @@ def arch_install_system(ctx: InstallContext) -> None: info("› installing Omarchy runtime + omarchy-base.packages") installer.add_additional_packages(_runtime_package_list(ctx)) + + # Tailscale is bundled in the offline mirror but only installed + # when an autoinstall drive staged an auth key; must happen here, + # while the mirror is still bind-mounted, not in the phase that + # configures the join. + if ctx.tailscale_authkey_path is not None: + info("› installing tailscale (auth key staged for first boot)") + installer.add_additional_packages(["tailscale"]) finally: _unmask_mkinitcpio_pacman_hooks(ctx) _unmount_offline_package_cache(ctx) @@ -1381,6 +1390,113 @@ def _authorized_keys(path: Path) -> list[str]: return keys +# ───────────────────────────────────────────────────────────────────────────── +# configure_tailscale: stage the tailnet join an autoinstall drive asked for. +# `tailscale up` needs a running tailscaled and there is no systemd in the +# chroot, so the install only stages: the key, the enabled services, and a +# oneshot first-boot unit that performs the join once the network is really +# there. The package itself was installed from the offline mirror during +# arch_install_system -- nothing is fetched at boot. +# ───────────────────────────────────────────────────────────────────────────── + +TAILSCALE_AUTHKEY_TARGET = "/etc/tailscale/authkey" + +# systemd expands $VAR in ExecStart, so the retry loop avoids `$` entirely. +# network-online.target can be reached before there is real connectivity, so +# retry inside the boot -- but NOT as a oneshot: target units implicitly gain +# After= for their Wants=, so a oneshot in multi-user.target holds the whole +# boot (SDDM included) hostage until it finishes. Type=simple counts as +# started the moment it forks, letting boot proceed while the join retries in +# the background for as long as the boot lasts. Cleanup lives inside the +# script because it must only run after a successful join: the key is removed +# and the unit disabled on success, while on a boot with no connectivity both +# survive -- so a machine installed offline joins on the first boot that can. +TAILSCALE_JOIN_UNIT = f"""\ +[Unit] +Description=Join the tailnet with the auth key staged by autoinstall +Wants=network-online.target +After=network-online.target tailscaled.service +Requires=tailscaled.service +ConditionPathExists={TAILSCALE_AUTHKEY_TARGET} + +[Service] +Type=simple +ExecStart=/usr/bin/sh -c 'until tailscale up --auth-key file:{TAILSCALE_AUTHKEY_TARGET}; do sleep 15; done; rm -f {TAILSCALE_AUTHKEY_TARGET}; systemctl disable omarchy-tailscale-join.service' + +[Install] +WantedBy=multi-user.target +""" + + +def configure_tailscale(ctx: InstallContext) -> None: + if ctx.tailscale_authkey_path is None: + return + + key = _tailscale_authkey(ctx.tailscale_authkey_path) + + # An ISO built before tailscale was bundled installs nothing, and a staged + # key with no binary would fail silently forever on first boot. + if not (ctx.target / "usr" / "bin" / "tailscale").exists(): + raise RuntimeError("tailscale is not installed on the target; this ISO does not bundle it") + + info("› staging Tailscale auth key") + ts_dir = ctx.target / "etc" / "tailscale" + ts_dir.mkdir(parents=True, exist_ok=True) + ts_dir.chmod(0o700) + authkey = ts_dir / "authkey" + authkey.write_text(f"{key}\n") + authkey.chmod(0o600) + + info("› enabling tailscaled and the first-boot join") + unit = ctx.target / "etc" / "systemd" / "system" / "omarchy-tailscale-join.service" + unit.parent.mkdir(parents=True, exist_ok=True) + unit.write_text(TAILSCALE_JOIN_UNIT) + subprocess.run( + ["arch-chroot", str(ctx.target), "systemctl", "enable", + "tailscaled.service", "omarchy-tailscale-join.service"], + check=True, + ) + + # Same dance as configure_ssh_access: ufw cannot reach netfilter from the + # chroot and exits non-zero, but it records the rule in user.rules first, + # and that file is what ufw.service loads on first boot. Without the rule + # the node joins the tailnet and is then unreachable over it. + info("› allowing tailnet traffic through ufw") + subprocess.run( + ["arch-chroot", str(ctx.target), "ufw", "allow", "in", "on", "tailscale0"], + check=False, + ) + + rules = ctx.target / "etc" / "ufw" / "user.rules" + text = rules.read_text() if rules.exists() else "" + if "-i tailscale0 -j ACCEPT" not in text: + raise RuntimeError(f"ufw did not record an allow rule for tailscale0 in {rules}") + + +def _tailscale_authkey(path: Path) -> str: + """Read the autoinstall tailscale_authkey: exactly one key, with blank + lines and # comments dropped. No format validation beyond that -- key + formats are Tailscale's to change. + + Raise rather than skip on anything unusable, same reasoning as + _authorized_keys: a machine that "succeeds" into never joining the + tailnet is worse than one that stops with the reason on screen. + """ + try: + lines = path.read_text().splitlines() + except OSError as exc: + raise RuntimeError(f"{path} is not readable: {exc}") from exc + + keys = [line.strip() for line in lines] + keys = [key for key in keys if key and not key.startswith("#")] + if not keys: + raise RuntimeError(f"{path} contains no auth key") + if len(keys) > 1: + raise RuntimeError(f"{path} contains {len(keys)} keys; expected exactly one") + + return keys[0] + + # ───────────────────────────────────────────────────────────────────────────── # validate_boot: hard checks before reboot. If the install ran but produced a # boot config or UKI that can't actually boot, halt here rather than surprise diff --git a/plans/tailscale.md b/plans/tailscale.md new file mode 100644 index 0000000..a6f5fc6 --- /dev/null +++ b/plans/tailscale.md @@ -0,0 +1,124 @@ +# Tailscale Autoinstall Plan + +## Goal + +Let an autoinstall drive carry a Tailscale auth key so the installed machine +joins the tailnet on its own. Combined with `authorized_keys`, this completes +the disposable-VM story: create VM, boot, walk away, `ssh user@machine` over +the tailnet from anywhere — no LAN discovery, no port forwarding. + +## Product Requirements + +- Same trigger philosophy as the rest of autoinstall: presence of a + `tailscale_authkey` file on the `cidata` drive, no new mode flag. No file, + no change — byte-identical behavior to today. +- The join happens when the network is available, not when the installer runs. +- No package installs from the network on first boot. +- The auth key does not persist on the installed system after a successful join. + +--- + +## Chosen Architecture + +### The package is bundled, not fetched + +The `tailscale` package rides in the offline mirror (~10MB on the ISO) and is +installed during the ordinary package phase — but only when the auth key file +is present, so a stock install stays stock. + +The alternative — installing from the network on first boot — was rejected +outright. A bare `pacman -Sy tailscale` on a fresh machine is a partial +upgrade waiting to corrupt something, and the "correct" version, `pacman -Syu +tailscale`, means an unattended full system upgrade racing the user's first +login (potentially swapping the kernel out on a machine that has booted once). +It would also fight Omarchy's own update flow and make the join depend on +mirror availability. The offline mirror is a coherent snapshot; installing +from it keeps the target consistent and the install deterministic and +offline-capable. + +### The join is a first-boot unit, not an install step + +`tailscale up` needs a running tailscaled, and there is no systemd in the +chroot. So the installer only stages: the key at `/etc/tailscale/authkey` +(root, `0600`), `tailscaled.service` enabled, and an +`omarchy-tailscale-join.service` that runs `tailscale up --auth-key +file:/etc/tailscale/authkey` on first boot. + +"When the network is available" lives in the unit, not the installer: +`After=network-online.target` for ordering, plus an in-unit retry loop +(`until tailscale up …; do sleep 15; done`), because network-online can be +reached before there is real connectivity. + +The unit is `Type=simple`, not oneshot, and that is load-bearing: target +units implicitly gain `After=` for their `Wants=`, so a oneshot wanted by +`multi-user.target` holds the whole boot — SDDM included — hostage until the +join finishes or times out, and once timed out it never retries within that +boot. (Caught by review.) Type=simple counts as started the moment it forks: +boot proceeds normally while the join retries in the background for as long +as the boot lasts. + +Cleanup is sequenced inside the script, after `tailscale up` succeeds — the +key is removed and the unit disables itself, so the key never outlives the +join and the unit never runs again. On a boot that never gets connectivity +both survive (`ConditionPathExists` gates re-runs on the key), so a machine +installed offline joins on the first boot that can. + +The machine appears on the tailnet under the hostname from +`user_configuration.json` — nothing extra to configure. + +### Firewall + +`ufw allow in on tailscale0`, the same write-the-rule-then-ignore-the-chroot- +exit-code dance as `configure_ssh_access` (ufw cannot reach netfilter in a +chroot but records the rule in `user.rules`, which is what first boot loads). +Without it the node joins and is then unreachable over the tailnet — worse +than not joining. + +### Secrets + +Same stance the autoinstall plan takes for the LUKS passphrase: whoever +builds the `cidata` drive owns the key on it. Docs recommend a reusable, +pre-authorized (tagged) key so one drive image serves N machines, and note +that ephemeral keys fit disposable VMs. No validation of the key beyond +"exactly one non-comment line" — Tailscale key formats are theirs to change. + +--- + +## Implementation + +- `builder/archinstall.packages`: add `tailscale` so it lands in the offline + mirror on every build. +- `omarchy-cidata-load`: add `tailscale_authkey` to the optional copy list. +- `.automated_script.sh`: pass `--tailscale-authkey-file /root/tailscale_authkey` + (always; the orchestrator no-ops when the file is absent, like + `--authorized-keys-file`). +- `omarchy-iso-install`: parse the flag into + `OMARCHY_INSTALL_TAILSCALE_AUTHKEY_FILE`. +- `context.py`: `tailscale_authkey_path: Path | None` via `_optional_path`. +- `phases_impl.py`: + - `arch_install_system`: when the key is present, add `tailscale` to the + additional packages while the offline mirror is still bind-mounted. + - `configure_tailscale(ctx)`: no-op when the path is `None`; otherwise + stage key + unit, enable services, open ufw. Fails loudly on an empty or + ambiguous key file and when the tailscale binary is missing from the + target (an ISO built before the package was bundled). +- `main.py`: register `("Configuring Tailscale", configure_tailscale)` after + `configure_ssh_access`. +- `README.md`: file-table row + a Tailscale paragraph in the Autoinstall + section. +- Tests: `tailscale_authkey` cases in `cidata-load-test.sh`; + `test_configure_tailscale.py` mirroring the SSH phase tests. + +## Acceptance criteria + +- Drive with `tailscale_authkey`: machine appears on the tailnet under its + configured hostname after first boot with network; `/etc/tailscale/authkey` + is gone; `omarchy-tailscale-join.service` is disabled. +- First boot without network: no failure surfaced to the user; the join + happens on the first boot that has connectivity. +- Drive without the file: no tailscale package on the target, no unit, no + ufw rule — indistinguishable from today. +- Empty or multi-line key file: install stops in the Tailscale phase with a + visible error. +- Interactive installs: unaffected; the flag is passed but the file never + exists. diff --git a/test/cidata-load-test.sh b/test/cidata-load-test.sh index ae5cee1..abb7970 100644 --- a/test/cidata-load-test.sh +++ b/test/cidata-load-test.sh @@ -89,8 +89,9 @@ echo "Jeff" >"$sandbox/media/user_full_name.txt" echo "jeff@example.com" >"$sandbox/media/user_email_address.txt" echo "false" >"$sandbox/media/user_encrypt_installation.txt" echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys" +echo 'tskey-auth-kFAKEKEY' >"$sandbox/media/tailscale_authkey" run_load || fail "full file set loads" -for file in user_configuration.json user_credentials.json user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys; do +for file in user_configuration.json user_credentials.json user_full_name.txt user_email_address.txt user_encrypt_installation.txt authorized_keys tailscale_authkey; do [[ -f $sandbox/root/$file ]] || fail "full file set copies $file" done grep -q '^umount ' "$TEST_LOG" || fail "full file set unmounts the drive" @@ -120,6 +121,7 @@ echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys" run_load || fail "required pair plus authorized_keys loads" [[ -f $sandbox/root/authorized_keys ]] || fail "authorized_keys is copied when present" [[ ! -e $sandbox/root/user_full_name.txt ]] || fail "absent optional files are not copied" +[[ ! -e $sandbox/root/tailscale_authkey ]] || fail "absent tailscale_authkey is not copied" pass "present optional files are copied, absent ones skipped" # Half the required pair is not an autoinstall drive: unmount and fall back. diff --git a/test/test_configure_tailscale.py b/test/test_configure_tailscale.py new file mode 100644 index 0000000..23b7bc6 --- /dev/null +++ b/test/test_configure_tailscale.py @@ -0,0 +1,164 @@ +"""Unit tests for the orchestrator's configure_tailscale phase. + +Same harness as the SSH access tests: the phase touches the target only +through the filesystem and arch-chroot, so it runs against a temp directory +with subprocess.run recorded and the ufw side effect (writing user.rules) +simulated by the fake. +""" + +import stat +import sys +import tempfile +import types +import unittest +from pathlib import Path +from subprocess import CompletedProcess +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "configs/airootfs/usr/share/omarchy-iso")) + +# phases_impl imports the archinstall adapter at module scope, which pulls in +# the archinstall library that only exists on the live ISO. configure_tailscale +# never touches it, so stub the adapter out before the import. +sys.modules["orchestrator.archinstall_adapter"] = types.ModuleType("orchestrator.archinstall_adapter") + +from orchestrator import phases_impl # noqa: E402 + +UFW_RULE = "-A ufw-user-input -i tailscale0 -j ACCEPT\n" + + +class ConfigureTailscaleTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.target = Path(self.tmp.name) + self.calls = [] + + info_patch = mock.patch.object(phases_impl, "info") + info_patch.start() + self.addCleanup(info_patch.stop) + + run_patch = mock.patch.object(phases_impl.subprocess, "run", side_effect=self.fake_run) + run_patch.start() + self.addCleanup(run_patch.stop) + + self.ufw_writes_rule = True + + def fake_run(self, cmd, **kwargs): + self.calls.append(cmd) + if cmd[2] == "ufw": + if self.ufw_writes_rule: + rules = self.target / "etc" / "ufw" / "user.rules" + rules.parent.mkdir(parents=True, exist_ok=True) + rules.write_text(UFW_RULE) + # ufw cannot reach netfilter inside a chroot and exits non-zero + # even when it recorded the rule; the phase must not trust this. + return CompletedProcess(cmd, 1) + return CompletedProcess(cmd, 0) + + def ctx(self, authkey=None, tailscale_installed=True): + authkey_path = None + if authkey is not None: + authkey_path = self.target / "tailscale_authkey" + authkey_path.write_text(authkey) + if tailscale_installed: + binary = self.target / "usr" / "bin" / "tailscale" + binary.parent.mkdir(parents=True, exist_ok=True) + binary.touch() + return types.SimpleNamespace(target=self.target, tailscale_authkey_path=authkey_path) + + def configure(self, **kwargs): + phases_impl.configure_tailscale(self.ctx(**kwargs)) + + def staged_key(self): + return self.target / "etc" / "tailscale" / "authkey" + + def unit(self): + return self.target / "etc" / "systemd" / "system" / "omarchy-tailscale-join.service" + + def chrooted(self, program): + return [cmd for cmd in self.calls if cmd[:2] == ["arch-chroot", str(self.target)] and cmd[2] == program] + + def test_no_authkey_is_a_no_op(self): + self.configure() + self.assertEqual(self.calls, []) + self.assertFalse((self.target / "etc" / "tailscale").exists()) + + def test_stages_the_key(self): + self.configure(authkey="tskey-auth-kFAKEKEY\n") + self.assertEqual(self.staged_key().read_text(), "tskey-auth-kFAKEKEY\n") + + def test_drops_blank_lines_and_comments(self): + self.configure(authkey="# reusable, tagged\n\n tskey-auth-kFAKEKEY \n") + self.assertEqual(self.staged_key().read_text(), "tskey-auth-kFAKEKEY\n") + + def test_key_dir_and_file_are_root_private(self): + self.configure(authkey="tskey-auth-kFAKEKEY\n") + self.assertEqual(stat.S_IMODE((self.target / "etc" / "tailscale").stat().st_mode), 0o700) + self.assertEqual(stat.S_IMODE(self.staged_key().stat().st_mode), 0o600) + + def test_installs_the_join_unit(self): + self.configure(authkey="tskey-auth-kFAKEKEY\n") + text = self.unit().read_text() + self.assertIn("ConditionPathExists=/etc/tailscale/authkey", text) + # The key must not outlive a successful join, the unit must not run + # again after one, and cleanup must be sequenced after the join + # succeeds -- inside the script, not in ExecStartPost. + self.assertIn( + "ExecStart=/usr/bin/sh -c 'until tailscale up --auth-key file:/etc/tailscale/authkey;" + " do sleep 15; done; rm -f /etc/tailscale/authkey;" + " systemctl disable omarchy-tailscale-join.service'", + text, + ) + + def test_join_unit_does_not_hold_up_boot(self): + # A Type=oneshot wanted by multi-user.target holds the whole boot + # (SDDM included) hostage until the join finishes or times out -- + # target units implicitly gain After= for their Wants=. + self.configure(authkey="tskey-auth-kFAKEKEY\n") + text = self.unit().read_text() + self.assertIn("Type=simple", text) + self.assertNotIn("Type=oneshot", text) + self.assertNotIn("TimeoutStartSec", text) + + def test_unit_avoids_systemd_variable_expansion(self): + self.configure(authkey="tskey-auth-kFAKEKEY\n") + self.assertNotIn("$", self.unit().read_text()) + + def test_enables_tailscaled_and_the_join(self): + self.configure(authkey="tskey-auth-kFAKEKEY\n") + self.assertEqual(self.chrooted("systemctl"), [ + ["arch-chroot", str(self.target), "systemctl", "enable", + "tailscaled.service", "omarchy-tailscale-join.service"], + ]) + + def test_allows_tailnet_traffic_through_ufw_despite_chroot_exit_status(self): + self.configure(authkey="tskey-auth-kFAKEKEY\n") + self.assertEqual(self.chrooted("ufw"), [ + ["arch-chroot", str(self.target), "ufw", "allow", "in", "on", "tailscale0"], + ]) + + def test_fails_when_ufw_does_not_record_the_rule(self): + self.ufw_writes_rule = False + with self.assertRaisesRegex(RuntimeError, "allow rule for tailscale0"): + self.configure(authkey="tskey-auth-kFAKEKEY\n") + + def test_fails_when_tailscale_is_not_on_the_target(self): + with self.assertRaisesRegex(RuntimeError, "not installed on the target"): + self.configure(authkey="tskey-auth-kFAKEKEY\n", tailscale_installed=False) + + def test_empty_file_fails_the_phase(self): + with self.assertRaisesRegex(RuntimeError, "contains no auth key"): + self.configure(authkey="") + + def test_comment_only_file_fails_the_phase(self): + with self.assertRaisesRegex(RuntimeError, "contains no auth key"): + self.configure(authkey="# no key here\n") + + def test_multiple_keys_fail_the_phase(self): + with self.assertRaisesRegex(RuntimeError, "expected exactly one"): + self.configure(authkey="tskey-auth-kONE\ntskey-auth-kTWO\n") + + +if __name__ == "__main__": + unittest.main()