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()