Make the installed machine reachable over SSH

An autoinstall drive can carry an ssh.json of public keys. Install them
for the user, enable sshd, and open port 22.

All three are needed: Omarchy ships openssh with the service disabled,
and install/config/firewall.sh opens LocalSend and docker DNS but not
22, so keys alone leave the machine pingable with SSH timing out.

The ufw call exits non-zero from a chroot -- it cannot reach netfilter
-- but writes the rule to user.rules before failing, and that file is
what ufw.service loads on first boot. Check the file rather than the
exit status, since the file is the thing that has to be right.

Unusable key files fail the phase. A machine that installs
"successfully" and then can't be logged into is worse than one that
stops with the reason on screen.

Claude-Session: https://claude.ai/code/session_01Sy3HeoBqb13ahLuaiP2UkM
This commit is contained in:
Jeffrey Guenther authored and David Heinemeier Hansson committed 2026-08-04 05:54:00 -07:00
1 parent b323a4cf08
commit 5f601f2c54
3 files changed
+103

No files matched your search

@@ -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 ""
@@ -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),
]
@@ -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