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
151 lines
6.2 KiB
Python
151 lines
6.2 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("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)
|
|
|
|
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()
|