* 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 <noreply@anthropic.com> 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Xc74h4WSYpuYPGuYbFAR1q --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
165 lines
6.8 KiB
Python
165 lines
6.8 KiB
Python
"""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()
|