diff --git a/README.md b/README.md index fbf1d8c..ebd4ae8 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ 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 oneshot unit runs `tailscale up` once the network is actually up, retrying until it succeeds. 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. +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 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 2a3eeca..76a28e2 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py @@ -1403,11 +1403,14 @@ 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; TimeoutStartSec bounds it. ExecStartPost only runs -# after ExecStart succeeds, which gives the retry-across-boots behavior for -# free: on failure the key stays and the unit stays enabled, so a machine -# installed offline joins on the first boot that has connectivity. On success -# the key is removed and the unit never runs again. +# 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 @@ -1417,11 +1420,8 @@ Requires=tailscaled.service ConditionPathExists={TAILSCALE_AUTHKEY_TARGET} [Service] -Type=oneshot -TimeoutStartSec=10min -ExecStart=/usr/bin/sh -c 'until tailscale up --auth-key file:{TAILSCALE_AUTHKEY_TARGET}; do sleep 10; done' -ExecStartPost=/usr/bin/rm -f {TAILSCALE_AUTHKEY_TARGET} -ExecStartPost=/usr/bin/systemctl disable omarchy-tailscale-join.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 diff --git a/plans/tailscale.md b/plans/tailscale.md index f5a709b..a6f5fc6 100644 --- a/plans/tailscale.md +++ b/plans/tailscale.md @@ -40,22 +40,28 @@ offline-capable. `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 a oneshot +(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 10; done` under `TimeoutStartSec=10min`), -because network-online can be reached before there is real connectivity. -The systemd success/failure semantics do the rest for free: +(`until tailscale up …; do sleep 15; done`), because network-online can be +reached before there is real connectivity. -- On success, `ExecStartPost` removes the key and disables the unit — the key - never outlives the join, and the unit never runs again. -- On failure (no network, tailscaled not ready, key rejected), ExecStartPost - is skipped: the key stays, the unit stays enabled, and the join retries on - every boot until it succeeds. A machine installed offline joins whenever it - first gets 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. diff --git a/test/test_configure_tailscale.py b/test/test_configure_tailscale.py index 812dc89..23b7bc6 100644 --- a/test/test_configure_tailscale.py +++ b/test/test_configure_tailscale.py @@ -100,12 +100,26 @@ class ConfigureTailscaleTest(unittest.TestCase): def test_installs_the_join_unit(self): self.configure(authkey="tskey-auth-kFAKEKEY\n") text = self.unit().read_text() - self.assertIn("tailscale up --auth-key file:/etc/tailscale/authkey", text) self.assertIn("ConditionPathExists=/etc/tailscale/authkey", text) - # The key must not outlive a successful join, and the unit must not - # run again after one. - self.assertIn("ExecStartPost=/usr/bin/rm -f /etc/tailscale/authkey", text) - self.assertIn("ExecStartPost=/usr/bin/systemctl disable omarchy-tailscale-join.service", 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")