Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a46fef776 | ||
|
|
d33487a09e |
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_email_address.txt` | No | Git email |
|
||||||
| `user_encrypt_installation.txt` | No | `true` when `user_configuration.json` carries a `disk_encryption` block; defaults to false |
|
| `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 |
|
| `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"`.
|
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 `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 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
|
### Building the drive
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -12,3 +12,4 @@ openssh
|
|||||||
pipewire
|
pipewire
|
||||||
snapper
|
snapper
|
||||||
sof-firmware
|
sof-firmware
|
||||||
|
tailscale
|
||||||
@@ -116,4 +116,5 @@ rm -f /run/omarchy-install/state.json
|
|||||||
--full-name-file /root/user_full_name.txt \
|
--full-name-file /root/user_full_name.txt \
|
||||||
--email-file /root/user_email_address.txt \
|
--email-file /root/user_email_address.txt \
|
||||||
--encrypt-file /root/user_encrypt_installation.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
|
loaded=1
|
||||||
if [[ -f $mnt/user_configuration.json && -f $mnt/user_credentials.json ]]; then
|
if [[ -f $mnt/user_configuration.json && -f $mnt/user_credentials.json ]]; then
|
||||||
files=("$mnt"/user_configuration.json "$mnt"/user_credentials.json)
|
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")
|
[[ -f $mnt/$file ]] && files+=("$mnt/$file")
|
||||||
done
|
done
|
||||||
if cp "${files[@]}" "$dest"/; then
|
if cp "${files[@]}" "$dest"/; then
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
--email-file) export OMARCHY_INSTALL_EMAIL_FILE="$2"; shift 2 ;;
|
--email-file) export OMARCHY_INSTALL_EMAIL_FILE="$2"; shift 2 ;;
|
||||||
--encrypt-file) export OMARCHY_INSTALL_ENCRYPT_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 ;;
|
--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 ;;
|
*) echo "omarchy-iso-install: unknown arg $1" >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class InstallContext:
|
|||||||
email: str
|
email: str
|
||||||
encrypt: bool
|
encrypt: bool
|
||||||
authorized_keys_path: Path | None
|
authorized_keys_path: Path | None
|
||||||
|
tailscale_authkey_path: Path | None
|
||||||
|
|
||||||
user_configuration: dict
|
user_configuration: dict
|
||||||
user_credentials: dict
|
user_credentials: dict
|
||||||
@@ -61,6 +62,7 @@ class InstallContext:
|
|||||||
email=_read_text(os.environ.get("OMARCHY_INSTALL_EMAIL_FILE")),
|
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"),
|
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")),
|
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_configuration=user_configuration,
|
||||||
user_credentials=json.loads(creds_path.read_text()),
|
user_credentials=json.loads(creds_path.read_text()),
|
||||||
arch_config_path=arch_config_path,
|
arch_config_path=arch_config_path,
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ def build_phases(ctx: InstallContext):
|
|||||||
configure_dns_resolver,
|
configure_dns_resolver,
|
||||||
configure_login,
|
configure_login,
|
||||||
configure_ssh_access,
|
configure_ssh_access,
|
||||||
|
configure_tailscale,
|
||||||
validate_boot,
|
validate_boot,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ def build_phases(ctx: InstallContext):
|
|||||||
("Finalizing user", run_chroot_finalizer),
|
("Finalizing user", run_chroot_finalizer),
|
||||||
("Configuring login", configure_login),
|
("Configuring login", configure_login),
|
||||||
("Configuring SSH access", configure_ssh_access),
|
("Configuring SSH access", configure_ssh_access),
|
||||||
|
("Configuring Tailscale", configure_tailscale),
|
||||||
("Configuring DNS resolver", configure_dns_resolver),
|
("Configuring DNS resolver", configure_dns_resolver),
|
||||||
("Validating boot setup", validate_boot),
|
("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
|
run_chroot_finalizer → arch-chroot -u user omarchy-finalize-user
|
||||||
configure_login → sddm state + encrypted-install autologin
|
configure_login → sddm state + encrypted-install autologin
|
||||||
configure_ssh_access → authorized_keys for autoinstall; no-op otherwise
|
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
|
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")
|
info("› installing Omarchy runtime + omarchy-base.packages")
|
||||||
installer.add_additional_packages(_runtime_package_list(ctx))
|
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:
|
finally:
|
||||||
_unmask_mkinitcpio_pacman_hooks(ctx)
|
_unmask_mkinitcpio_pacman_hooks(ctx)
|
||||||
_unmount_offline_package_cache(ctx)
|
_unmount_offline_package_cache(ctx)
|
||||||
@@ -1381,6 +1390,113 @@ def _authorized_keys(path: Path) -> list[str]:
|
|||||||
return keys
|
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 -- 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
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target tailscaled.service
|
||||||
|
Requires=tailscaled.service
|
||||||
|
ConditionPathExists={TAILSCALE_AUTHKEY_TARGET}
|
||||||
|
|
||||||
|
[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
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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
|
# 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
|
# boot config or UKI that can't actually boot, halt here rather than surprise
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# 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 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 15; done`), because network-online can be
|
||||||
|
reached before there is real 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.
|
||||||
|
|
||||||
|
### 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 "jeff@example.com" >"$sandbox/media/user_email_address.txt"
|
||||||
echo "false" >"$sandbox/media/user_encrypt_installation.txt"
|
echo "false" >"$sandbox/media/user_encrypt_installation.txt"
|
||||||
echo 'ssh-ed25519 AAAA jeff@host' >"$sandbox/media/authorized_keys"
|
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"
|
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"
|
[[ -f $sandbox/root/$file ]] || fail "full file set copies $file"
|
||||||
done
|
done
|
||||||
grep -q '^umount ' "$TEST_LOG" || fail "full file set unmounts the drive"
|
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"
|
run_load || fail "required pair plus authorized_keys loads"
|
||||||
[[ -f $sandbox/root/authorized_keys ]] || fail "authorized_keys is copied when present"
|
[[ -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/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"
|
pass "present optional files are copied, absent ones skipped"
|
||||||
|
|
||||||
# Half the required pair is not an autoinstall drive: unmount and fall back.
|
# Half the required pair is not an autoinstall drive: unmount and fall back.
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""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()
|
||||||
Reference in new issue
Block a user