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
This commit is contained in:
1 parent
4d10233371
commit
d33487a09e
11 files changed
+399
-3
No files matched your search
@@ -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 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.
|
||||
|
||||
### Building the drive
|
||||
|
||||
```bash
|
||||
|
||||
@@ -12,3 +12,4 @@ openssh
|
||||
pipewire
|
||||
snapper
|
||||
sof-firmware
|
||||
tailscale
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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; 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.
|
||||
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=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
|
||||
|
||||
[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
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# 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 a oneshot
|
||||
`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:
|
||||
|
||||
- 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 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""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()
|
||||
Reference in new issue
Block a user