diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py index 1d2e704..34e8de9 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/context.py @@ -18,6 +18,7 @@ class InstallContext: full_name: str email: str encrypt: bool + ssh_keys_path: Path | None user_configuration: dict user_credentials: dict @@ -59,6 +60,7 @@ class InstallContext: full_name=_read_text(os.environ.get("OMARCHY_INSTALL_FULL_NAME_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"), + ssh_keys_path=_optional_path(os.environ.get("OMARCHY_INSTALL_SSH_KEYS_FILE")), user_configuration=user_configuration, user_credentials=json.loads(creds_path.read_text()), arch_config_path=arch_config_path, @@ -103,6 +105,16 @@ def _default_omarchy_install(user_configuration: dict) -> dict[str, Any]: } +def _optional_path(path: str | None) -> Path | None: + """A path the caller always passes but that need not exist. The live ISO + passes --ssh-keys-file on every install; only autoinstall drives put a file + there.""" + if not path: + return None + p = Path(path) + return p if p.exists() else None + + def _read_text(path: str | None) -> str: if not path: return "" diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py index 62f4c88..3d35ef7 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/main.py @@ -38,6 +38,7 @@ def build_phases(ctx: InstallContext): run_chroot_finalizer, configure_dns_resolver, configure_login, + configure_ssh_access, validate_boot, ) @@ -50,6 +51,7 @@ def build_phases(ctx: InstallContext): ("Finalizing Limine boot", finalize_limine_boot), ("Finalizing user", run_chroot_finalizer), ("Configuring login", configure_login), + ("Configuring SSH access", configure_ssh_access), ("Configuring DNS resolver", configure_dns_resolver), ("Validating boot setup", validate_boot), ] diff --git a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py index 468636a..ba0a639 100644 --- a/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py +++ b/configs/airootfs/usr/share/omarchy-iso/orchestrator/phases_impl.py @@ -15,12 +15,14 @@ Phase ordering (full-disk and protected/pre-mounted): finalize_limine_boot → final Limine config/UKI build after hardware drop-ins 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 validate_boot → assert UKI / limine.conf / kernel cmdline are sane """ from __future__ import annotations import hashlib +import json import os import re import shutil @@ -1304,6 +1306,93 @@ def configure_login(ctx: InstallContext) -> None: ) +# ───────────────────────────────────────────────────────────────────────────── +# configure_ssh_access: make the installed machine reachable over SSH with the +# keys an autoinstall drive supplied. A stock Omarchy install ships openssh but +# leaves sshd disabled, and its firewall.sh opens only LocalSend and docker DNS, +# so all three pieces -- keys, service, firewall -- have to be done here. +# ───────────────────────────────────────────────────────────────────────────── + +def configure_ssh_access(ctx: InstallContext) -> None: + if ctx.ssh_keys_path is None: + return + + keys = _authorized_keys(ctx.ssh_keys_path) + info(f"› installing {len(keys)} SSH key(s) for {ctx.username}") + + ssh_dir = ctx.target / "home" / ctx.username / ".ssh" + ssh_dir.mkdir(parents=True, exist_ok=True) + ssh_dir.chmod(0o700) + + authorized_keys = ssh_dir / "authorized_keys" + authorized_keys.write_text("".join(f"{key}\n" for key in keys)) + authorized_keys.chmod(0o600) + + # Ask the target for the uid rather than assuming the first user is 1000. + # Uncaptured so a failure shows up in the install log, and checked because + # a root-owned authorized_keys is one sshd refuses to read. + subprocess.run( + ["arch-chroot", str(ctx.target), "chown", "-R", + f"{ctx.username}:{ctx.username}", f"/home/{ctx.username}/.ssh"], + check=True, + ) + + info("› enabling sshd") + subprocess.run( + ["arch-chroot", str(ctx.target), "systemctl", "enable", "sshd.service"], + check=True, + ) + + _allow_ssh_through_firewall(ctx) + + +def _allow_ssh_through_firewall(ctx: InstallContext) -> None: + """Open port 22 in the target's ufw. + + Omarchy's install/config/firewall.sh opens LocalSend and docker DNS and + nothing else, and ufw runs default-deny incoming, so an enabled sshd is + still unreachable -- connections time out rather than being refused. + + ufw cannot reach netfilter from inside the chroot and exits non-zero saying + so, but it writes the rule to user.rules first, and that file is what + ufw.service loads on first boot. So the exit status is the wrong thing to + check here; the rule landing in the file is the thing that matters. + """ + if not (ctx.target / "usr" / "bin" / "ufw").exists(): + info("› no ufw in target, leaving firewall alone") + return + + info("› allowing SSH through ufw") + subprocess.run(["arch-chroot", str(ctx.target), "ufw", "allow", "ssh"], check=False) + + rules = ctx.target / "etc" / "ufw" / "user.rules" + text = rules.read_text() if rules.exists() else "" + if "--dport 22 -j ACCEPT" not in text: + raise RuntimeError(f"ufw did not record an allow rule for port 22 in {rules}") + + +def _authorized_keys(path: Path) -> list[str]: + """Parse the autoinstall ssh.json: a JSON array of public key strings. + + Raise rather than skip on anything unusable. An install that "succeeds" + into a machine nobody can log into is worse than one that stops with the + reason on screen. + """ + try: + keys = json.loads(path.read_text()) + except (OSError, json.JSONDecodeError) as exc: + raise RuntimeError(f"{path} is not readable JSON: {exc}") from exc + + if not isinstance(keys, list) or not all(isinstance(key, str) for key in keys): + raise RuntimeError(f"{path} must be a JSON array of public key strings") + + keys = [key.strip() for key in keys if key.strip()] + if not keys: + raise RuntimeError(f"{path} contains no SSH keys") + + return keys + + # ───────────────────────────────────────────────────────────────────────────── # 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