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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xc74h4WSYpuYPGuYbFAR1q
This commit is contained in:
1 parent
d33487a09e
commit
2a46fef776
4 files changed
+46
-26
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+16
-10
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in new issue
Block a user