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:
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
|
||||
|
||||
Reference in new issue
Block a user