Skip to main content

max / alloy

Continue the install over ssh from a minted medium The headless flow's last step: boot the server off a medium minted with your pubkey, `ssh installer@<name>`, and the installer TUI continues there. No password auth, no one-time code on a console the machine does not have, no window in which a box on the LAN is takeoverable. The baked pubkey is the credential. The gate is the existence of the `installer` account, not the sshd config. sshd's Match takes User/Group/Host/Address and nothing else, so it cannot read the kernel command line: the drop-in ships in every image and is inert without the account, and the account is created by a unit carrying ConditionKernelCommandLine=alloy.installer. That fails safe, since the unit not running means a login that does not happen. Writing the drop-in at boot instead was tried and races sshd's start. The drop-in ends with `Match all`. Without it the block swallows every later sshd drop-in, this image's own `PasswordAuthentication no` included, and the symptom is not a parse error but a machine whose ssh policy has quietly stopped applying to anyone but `installer`. A Containerfile guard enforces the terminator, the kernel-flag condition, and that the unit and the drop-in name the same account. `alloy install` now refuses to run on a machine that was not booted from the installer medium. A condition on a unit gates the unit and not the binary, and this verb is reachable through the ForceCommand above without passing either unit. An unreadable /proc/cmdline refuses, unlike the two readers next to it, because this one runs before anything is written to disk. No override flag: the medium boots in a VM, which is hole enough. Key discovery filters by key shape rather than by the .pub suffix, offers what it finds and refuses to guess between several. It never reaches the network. Boot-tested in qemu, on the live medium and on the machine it installed: the account exists only on the medium, a wrong key and password auth are refused, and `alloy install` on the installed machine refuses and exits 1.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-09 22:15 UTC
Signed with PGP, not checked
Commit: 02d05d2175b26fd7aec00930b598a25bb74750f0
Parent: 0b92156
7 files changed, +497 insertions, -4 deletions
@@ -1287,6 +1287,53 @@
1287 1287 COPY usr/ /usr/
1288 1288 COPY --from=rust-build /staged-skel/ /
1289 1289
1290 + # =====================================================================
1291 + # The installer's ssh door — assert both halves of the gate are intact.
1292 + # =====================================================================
1293 + # `Match User installer` + `ForceCommand /usr/bin/alloy install` is a
1294 + # remote path to a program that writes disks. What keeps it off an
1295 + # installed machine is that the account does not exist there, and the
1296 + # account is created by alloy-installer-ssh.service under the same
1297 + # `alloy.installer` kernel flag alloy-installer.service uses. Neither
1298 + # file can state that on its own, so the agreement between them is
1299 + # checked here — the failure would otherwise be silent and remote.
1300 + #
1301 + # Three things, each of which has a way of going wrong quietly.
1302 + #
1303 + # 1. THE CONDITION. A unit that lost it would create the account on every
1304 + # install, which is the whole hazard rather than a degradation of it.
1305 + #
1306 + # 2. THE NAME. The sshd Match and the useradd have to agree, and nothing
1307 + # links them but the string. Disagreeing fails in the safe direction
1308 + # (nobody logs in) but breaks the flow with no diagnostic anywhere.
1309 + #
1310 + # 3. THE SCOPE TERMINATOR. sshd applies a Match to everything that
1311 + # follows it, and Include does not end that scope, so a drop-in whose
1312 + # Match is left open swallows the global policy of every file after
1313 + # it, this image's own `PasswordAuthentication no` included. The
1314 + # symptom is not a parse error; it is a machine whose ssh policy has
1315 + # quietly stopped applying to anyone but the installer. Two `Match`
1316 + # lines and `Match all` last is the shape that cannot do that.
1317 + #
1318 + # Comments stay out of the RUN below: a `#` line inside a line
1319 + # continuation is handled differently by different parsers, and one that
1320 + # reaches the shell ends the command at that point rather than being
1321 + # ignored, which would silently skip every check under it.
1322 + RUN set -eu; \
1323 + conf=/etc/ssh/sshd_config.d/20-alloy-installer.conf; \
1324 + unit=/etc/systemd/system/alloy-installer-ssh.service; \
1325 + grep -q '^ConditionKernelCommandLine=alloy.installer$' "$unit" \
1326 + || { echo "$unit does not gate on alloy.installer, so the installer account would exist on installed machines" >&2; exit 1; }; \
1327 + grep -q '^Match User installer$' "$conf" \
1328 + || { echo "$conf no longer matches the account $unit creates" >&2; exit 1; }; \
1329 + grep -q 'useradd .*installer' "$unit" \
1330 + || { echo "$unit no longer creates the account $conf matches" >&2; exit 1; }; \
1331 + [ "$(grep -c '^Match ' "$conf")" = 2 ] \
1332 + || { echo "$conf must hold exactly two Match lines: the block and its terminator" >&2; exit 1; }; \
1333 + tail -n 1 "$conf" | grep -q '^Match all$' \
1334 + || { echo "$conf must end with 'Match all' or its block leaks into every later sshd drop-in" >&2; exit 1; }; \
1335 + echo "installer ssh: gated on alloy.installer, scope closed"
1336 +
1290 1337 # =====================================================================
1291 1338 # The encrypted install path — assert bootc will actually permit it.
1292 1339 #
@@ -602,6 +602,38 @@
602 602 known && fields.next().is_some_and(|body| body.len() > 16)
603 603 }
604 604
605 + /// The public keys sitting in `~/.ssh` on the minting host.
606 + ///
607 + /// Sorted, and filtered by [`looks_like_pubkey`] rather than by the `.pub`
608 + /// suffix alone: `~/.ssh` collects other people's keys, `known_hosts` fragments
609 + /// and the occasional misnamed private half, and offering one of those as a
610 + /// candidate is how a private key gets baked into an artifact whose whole
611 + /// premise is that it holds no secrets.
612 + ///
613 + /// **This never reaches the network.** `gh:username` was considered and takes
614 + /// the explicit opt-in the installer's timezone geolocation already has; until
615 + /// someone builds that, the only keys offered are ones already on this disk.
616 + fn discover_pubkeys() -> Vec<String> {
617 + let Some(home) = std::env::var_os("HOME") else {
618 + return Vec::new();
619 + };
620 + let Ok(entries) = std::fs::read_dir(PathBuf::from(home).join(".ssh")) else {
621 + return Vec::new();
622 + };
623 +
624 + let mut found: Vec<String> = entries
625 + .flatten()
626 + .map(|entry| entry.path())
627 + .filter(|path| path.extension().is_some_and(|ext| ext == "pub"))
628 + .filter(|path| {
629 + std::fs::read_to_string(path).is_ok_and(|contents| looks_like_pubkey(&contents))
630 + })
631 + .map(|path| path.display().to_string())
632 + .collect();
633 + found.sort();
634 + found
635 + }
636 +
605 637 /// Find the Alloy checkout to drive.
606 638 ///
607 639 /// Walks up from the working directory looking for the two things that make a
@@ -669,7 +701,7 @@
669 701 Row::Lang(lang) => lang.label(),
670 702 Row::Artifact => "ISO boots into the installer; raw and qcow2 are installed systems",
671 703 Row::Hostname => "baked in, so a headless box is found at <name>.local",
672 - Row::Pubkey => "path to a PUBLIC key. It is the installer's only credential",
704 + Row::Pubkey => "a PUBLIC key from ~/.ssh, or a path. The installer's only credential",
673 705 }
674 706 }
675 707 }
@@ -682,6 +714,10 @@
682 714 /// the screen renders rather than an error it fails with: someone may open
683 715 /// the builder to read what it would do before cloning anything.
684 716 repo: Option<PathBuf>,
717 + /// The public keys found in `~/.ssh` at startup, for the pubkey row to
718 + /// cycle. Kept even when one of them was adopted as the default, so a host
719 + /// with several keys stays choosable without typing a path.
720 + candidates: Vec<String>,
685 721 /// The open text editor, if the focused row takes one.
686 722 editing: Option<TextField>,
687 723 /// The running build. `None` before the first one and after a finished one
@@ -701,10 +737,44 @@
701 737 if let Some(from) = &loaded.from {
702 738 log.record(format!("# choices from {from}"), Severity::Healthy);
703 739 }
740 +
741 + let mut choices = loaded.choices;
742 + let candidates = discover_pubkeys();
743 +
744 + // Only when the record did not already answer. A saved `pubkey` is a
745 + // statement about which key this machine is minted with, and quietly
746 + // replacing it with whatever sorts first in `~/.ssh` would be the
747 + // silent-forgetting failure `load` exists to avoid.
748 + //
749 + // ONE key is adopted; SEVERAL are offered and none is chosen. There is
750 + // no rule that picks correctly between a personal key and a key for the
751 + // machine being built, and guessing wrong bakes the wrong credential
752 + // into an artifact nobody re-reads before writing it to a stick.
753 + if choices.pubkey.is_empty() {
754 + match candidates.as_slice() {
755 + [only] => {
756 + choices.pubkey.clone_from(only);
757 + log.record(
758 + format!("# one public key in ~/.ssh: {only}"),
759 + Severity::Info,
760 + );
761 + }
762 + [_, _, ..] => log.record(
763 + format!(
764 + "# {} public keys in ~/.ssh, so none was chosen: h/l on the ssh pubkey row",
765 + candidates.len()
766 + ),
767 + Severity::Info,
768 + ),
769 + [] => {}
770 + }
771 + }
772 +
704 773 Self {
705 - choices: loaded.choices,
774 + choices,
706 775 cursor: Cursor::new(),
707 776 repo: find_repo(),
777 + candidates,
708 778 editing: None,
709 779 sequence: None,
710 780 pending_write: None,
@@ -768,11 +838,51 @@
768 838 // rows rather than nothing. A key that is inert on one row of a
769 839 // form reads as the form being broken.
770 840 Row::Lang(_) => self.toggle(),
771 - Row::Hostname | Row::Pubkey => {}
841 + // Cycles the keys found in `~/.ssh`, which is the whole of the
842 + // choice on a host that has more than one. Typing a path is still
843 + // there on enter, for a key that lives somewhere else.
844 + Row::Pubkey => self.cycle_pubkey(forward),
845 + Row::Hostname => {}
772 846 }
773 847 self.saved = false;
774 848 }
775 849
850 + /// Step through the discovered keys.
851 + ///
852 + /// Empty is one of the positions rather than something to escape from: an
853 + /// image without a baked key is the ordinary desktop install, so a user who
854 + /// cycles past the last candidate should land back on "none" instead of
855 + /// wrapping straight onto a key they were trying to get away from.
856 + ///
857 + /// A path typed by hand is not in the list, so it reads as position zero
858 + /// and the first press moves to the first candidate. That loses the typed
859 + /// value, which is why this is `h/l` and the typed path is committed with
860 + /// enter: the two are different gestures.
861 + fn cycle_pubkey(&mut self, forward: bool) {
862 + if self.candidates.is_empty() {
863 + self.error = Some("no public keys in ~/.ssh: press enter to type a path".to_string());
864 + return;
865 + }
866 +
867 + // Position 0 is "none", so the ring is one longer than the candidates.
868 + let len = self.candidates.len() + 1;
869 + let current = self
870 + .candidates
871 + .iter()
872 + .position(|key| *key == self.choices.pubkey)
873 + .map_or(0, |index| index + 1);
874 + let next = if forward {
875 + (current + 1) % len
876 + } else {
877 + (current + len - 1) % len
878 + };
879 +
880 + self.choices.pubkey = match next {
881 + 0 => String::new(),
882 + index => self.candidates[index - 1].clone(),
883 + };
884 + }
885 +
776 886 /// Toggle the language on the focused row.
777 887 fn toggle(&mut self) {
778 888 let Row::Lang(lang) = self.row() else {
@@ -877,7 +987,16 @@
877 987 let mut hints = vec![hint("j/k", "row")];
878 988 match self.row() {
879 989 Row::Lang(_) => hints.push(hint("space", "toggle")),
880 - Row::Hostname | Row::Pubkey => hints.push(hint("enter", "edit")),
990 + Row::Hostname => hints.push(hint("enter", "edit")),
991 + // Both, on this row, and they do different things: one walks the
992 + // keys already on this disk, the other takes a path to one that is
993 + // not. Naming only the editor would hide the common case.
994 + Row::Pubkey => {
995 + if !self.candidates.is_empty() {
996 + hints.push(hint("h/l", "found keys"));
997 + }
998 + hints.push(hint("enter", "path"));
999 + }
881 1000 _ => hints.push(hint("h/l", "change")),
882 1001 }
883 1002 hints.push(hint("b", "build"));
@@ -1317,6 +1436,7 @@
1317 1436 choices: Choices::default(),
1318 1437 cursor: Cursor::new(),
1319 1438 repo: None,
1439 + candidates: Vec::new(),
1320 1440 editing: None,
1321 1441 sequence: None,
1322 1442 pending_write: None,
@@ -1418,6 +1538,109 @@
1418 1538 assert_eq!(parsed.artifact, Artifact::Iso);
1419 1539 }
1420 1540
1541 + /// A view with a known candidate list, so the cycling can be exercised
1542 + /// without a `~/.ssh` to stand in front of it.
1543 + fn view_with(candidates: &[&str]) -> ImageView {
1544 + ImageView {
1545 + choices: Choices::default(),
1546 + cursor: Cursor::new(),
1547 + repo: None,
1548 + candidates: candidates.iter().map(|key| (*key).to_string()).collect(),
1549 + editing: None,
1550 + sequence: None,
1551 + pending_write: None,
1552 + device: None,
1553 + error: None,
1554 + saved: false,
1555 + }
1556 + }
1557 +
1558 + /// Empty is a position on the ring, not a state to escape. An image with no
1559 + /// baked key is the ordinary desktop install, so it has to stay reachable
1560 + /// once a key has been cycled onto the row.
1561 + #[test]
1562 + fn cycling_the_pubkey_row_passes_back_through_none() {
1563 + let mut view = view_with(&["/home/max/.ssh/a.pub", "/home/max/.ssh/b.pub"]);
1564 + assert_eq!(view.choices.pubkey, "");
1565 +
1566 + view.cycle_pubkey(true);
1567 + assert_eq!(view.choices.pubkey, "/home/max/.ssh/a.pub");
1568 + view.cycle_pubkey(true);
1569 + assert_eq!(view.choices.pubkey, "/home/max/.ssh/b.pub");
1570 + view.cycle_pubkey(true);
1571 + assert_eq!(view.choices.pubkey, "", "the ring returns to no key");
1572 +
1573 + // And backwards, off none onto the last one.
1574 + view.cycle_pubkey(false);
1575 + assert_eq!(view.choices.pubkey, "/home/max/.ssh/b.pub");
1576 + }
1577 +
1578 + /// A path typed by hand is not one of the candidates, so it reads as the
1579 + /// empty position rather than panicking on a lookup that finds nothing.
1580 + #[test]
1581 + fn a_typed_path_is_not_lost_to_an_index_it_never_had() {
1582 + let mut view = view_with(&["/home/max/.ssh/a.pub"]);
1583 + view.choices.pubkey = "/elsewhere/key.pub".to_string();
1584 + view.cycle_pubkey(true);
1585 + assert_eq!(view.choices.pubkey, "/home/max/.ssh/a.pub");
1586 + }
1587 +
1588 + /// Nothing to cycle says so, rather than silently doing nothing. An inert
1589 + /// key reads as the form being broken.
1590 + #[test]
1591 + fn no_candidates_explains_itself() {
1592 + let mut view = view_with(&[]);
1593 + view.cycle_pubkey(true);
1594 + assert_eq!(view.choices.pubkey, "");
1595 + assert!(
1596 + view.error.as_deref().is_some_and(|e| e.contains("~/.ssh")),
1597 + "{:?}",
1598 + view.error
1599 + );
1600 + }
1601 +
1602 + /// Discovery is filtered by shape, not by suffix. `~/.ssh` collects other
1603 + /// files, and a misnamed private half offered as a candidate is how a
1604 + /// secret reaches an artifact that promises to hold none.
1605 + #[test]
1606 + fn discovery_refuses_anything_that_is_not_a_public_key() {
1607 + let dir = std::env::temp_dir().join(format!("alloy-image-scan-{}", std::process::id()));
1608 + let ssh = dir.join(".ssh");
1609 + std::fs::create_dir_all(&ssh).expect("scratch dir");
1610 +
1611 + std::fs::write(
1612 + ssh.join("id_ed25519.pub"),
1613 + "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJmVLm7Yk2xQ max@fw13\n",
1614 + )
1615 + .expect("write key");
1616 + // A private key that someone named `.pub`. The suffix is not the check.
1617 + std::fs::write(
1618 + ssh.join("oops.pub"),
1619 + "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n",
1620 + )
1621 + .expect("write private");
1622 + // And the ordinary neighbours, which have no `.pub` at all.
1623 + std::fs::write(ssh.join("known_hosts"), "github.com ssh-ed25519 AAAA\n")
1624 + .expect("write known_hosts");
1625 +
1626 + // SAFETY: single-threaded within this test's own scratch HOME. The
1627 + // discovery reads HOME rather than taking a directory because that is
1628 + // what it does in the program, and testing a different function would
1629 + // test nothing.
1630 + let restore = std::env::var_os("HOME");
1631 + unsafe { std::env::set_var("HOME", &dir) };
1632 + let found = discover_pubkeys();
1633 + match restore {
1634 + Some(home) => unsafe { std::env::set_var("HOME", home) },
1635 + None => unsafe { std::env::remove_var("HOME") },
1636 + }
1637 +
1638 + assert_eq!(found.len(), 1, "{found:?}");
1639 + assert!(found[0].ends_with("id_ed25519.pub"), "{found:?}");
1640 +
1641 + let _ = std::fs::remove_dir_all(&dir);
1642 + }
1643 +
1421 1644 #[test]
1422 1645 fn the_record_says_it_is_not_a_lockfile() {
1423 1646 let toml = Choices::default().to_toml();
@@ -2103,6 +2103,45 @@
2103 2103 .unwrap_or_else(|| UPDATE_IMAGE.to_string())
2104 2104 }
2105 2105
2106 + /// The kernel command line parameter that says this is the installer medium.
2107 + ///
2108 + /// Set only by the GRUB entries `build/make-iso.sh` writes for the live ISO, and
2109 + /// by nothing an installed system ever boots with. It is the same flag
2110 + /// `etc/systemd/system/alloy-installer.service` and
2111 + /// `etc/systemd/system/alloy-installer-ssh.service` carry as their
2112 + /// `ConditionKernelCommandLine`.
2113 + const INSTALLER_PARAM: &str = "alloy.installer";
2114 +
2115 + /// Whether `cmdline` names the installer medium.
2116 + ///
2117 + /// Matches systemd's `ConditionKernelCommandLine` on purpose: a bare word, or
2118 + /// that word with a value after it. Matching on a substring instead would accept
2119 + /// `alloy.installer-check=1` and any future parameter that merely starts the same
2120 + /// way, which is the mistake [`update_target_in`] avoids by splitting into words
2121 + /// first.
2122 + fn installer_flag_in(cmdline: &str) -> bool {
2123 + cmdline.split_ascii_whitespace().any(|word| {
2124 + word == INSTALLER_PARAM
2125 + || word
2126 + .strip_prefix(INSTALLER_PARAM)
2127 + .is_some_and(|rest| rest.starts_with('='))
2128 + })
2129 + }
2130 +
2131 + /// Whether this machine was booted from the installer medium.
2132 + ///
2133 + /// **An unreadable `/proc/cmdline` refuses, where [`update_image`] and
2134 + /// [`updates_scheduled`] both fall back to the permissive answer.** The
2135 + /// asymmetry is deliberate and is about when each one runs: those two are read
2136 + /// partway through an install, with the disk already committed, so the
2137 + /// recoverable choice is to carry on. This one runs before anything is written,
2138 + /// and the thing it is deciding is whether to partition a disk that may hold
2139 + /// somebody's system. Not knowing is a reason to stop. Do not "fix" this to
2140 + /// match its neighbours.
2141 + pub(crate) fn booted_from_installer_medium() -> bool {
2142 + std::fs::read_to_string("/proc/cmdline").is_ok_and(|cmdline| installer_flag_in(&cmdline))
2143 + }
2144 +
2106 2145 /// The timer that pulls and applies updates on its own.
2107 2146 ///
2108 2147 /// Ships with bootc, so it is present in every Alloy image already; what decides
@@ -6836,6 +6875,44 @@
6836 6875 assert_eq!(split_tag("alloy"), None);
6837 6876 }
6838 6877
6878 + // ---- the installer flag on the command line ----
6879 +
6880 + // What the live ISO's GRUB entries actually write, abbreviated. The flag is
6881 + // bare there, which is the form that has to work.
6882 + #[test]
6883 + fn the_installer_flag_is_recognized_on_the_live_command_line() {
6884 + assert!(installer_flag_in(
6885 + "root=live:CDLABEL=ALLOY rd.live.image rd.live.overlay.overlayfs=1 \
6886 + alloy.installer console=tty0"
6887 + ));
6888 + }
6889 +
6890 + // An installed system boots without it, and that is the case the guard in
6891 + // main.rs exists for.
6892 + #[test]
6893 + fn an_installed_command_line_does_not_carry_the_installer_flag() {
6894 + assert!(!installer_flag_in(
6895 + "root=UUID=0e6a rootflags=subvol=root rw quiet"
6896 + ));
6897 + }
6898 +
6899 + // Not a substring match. A parameter that merely starts the same way must
6900 + // not arm the installer.
6901 + #[test]
6902 + fn a_similarly_named_parameter_is_not_the_installer_flag() {
6903 + assert!(!installer_flag_in("alloy.installer-check=1"));
6904 + assert!(!installer_flag_in("noalloy.installer"));
6905 + }
6906 +
6907 + // systemd's ConditionKernelCommandLine matches a bare word or that word with
6908 + // a value, and this follows it so the two gates cannot disagree about the
6909 + // same command line.
6910 + #[test]
6911 + fn the_installer_flag_may_carry_a_value() {
6912 + assert!(installer_flag_in("quiet alloy.installer=1"));
6913 + assert!(installer_flag_in("quiet alloy.installer= rhgb"));
6914 + }
6915 +
6839 6916 // ---- the update target on the command line ----
6840 6917
6841 6918 #[test]
@@ -426,6 +426,25 @@
426 426 shell::run(&theme, &mut view, &mut log)
427 427 }
428 428 Command::Install => {
429 + // The installer partitions and overwrites a disk, so it runs only on
430 + // a machine booted from the installer medium. The systemd units that
431 + // start it carry `ConditionKernelCommandLine=alloy.installer`, but a
432 + // condition on a unit gates the unit and not the binary: the sshd
433 + // drop-in's ForceCommand reaches this verb without going through
434 + // either unit, and so does anyone with a shell. The kernel command
435 + // line is the one signal an installed system cannot present, which
436 + // is why the check is here rather than on an argument the caller
437 + // supplies.
438 + if !install::booted_from_installer_medium() {
439 + anyhow::bail!(
440 + "`alloy install` runs only on the Alloy installer medium.\n\
441 + This machine's kernel command line does not carry \
442 + `alloy.installer`, so it is an installed system or a live \
443 + session booted some other way. Refusing: this verb \
444 + partitions and overwrites a disk.\n\
445 + Boot the installer ISO, on hardware or in a VM, to run it."
446 + );
447 + }
429 448 let mut view = install::InstallView::new(&mut log);
430 449 shell::run(&theme, &mut view, &mut log)
431 450 }
@@ -25,6 +25,17 @@
25 25 # the installer ISO sets, so enabling it on every install is safe and keeps
26 26 # the live medium from needing a modified copy of the image.
27 27 enable alloy-installer.service
28 + # The ssh half of the same flow, and inert by the same flag. It creates the
29 + # `installer` account that etc/ssh/sshd_config.d/20-alloy-installer.conf names,
30 + # so a headless machine can be installed from another one. Enabled on every
31 + # install for the reason above: the live medium is the image, not a modified
32 + # copy of it.
33 + #
34 + # This line is the whole gate. Without the account the sshd drop-in matches
35 + # nothing, so removing this entry disables the feature rather than opening it —
36 + # but a line that read `enable` on a unit missing its condition would be the
37 + # other direction, which is why the condition lives in the unit and not here.
38 + enable alloy-installer-ssh.service
28 39 # alloy-debug-shell@ is deliberately not listed here. It is a template, and
29 40 # `systemctl preset-all` cannot instantiate one from a preset line: a line
30 41 # naming `alloy-debug-shell@tty9.service` matches no unit file and is ignored
@@ -1,0 +1,63 @@
1 + # The installer, reachable over ssh on the installer medium.
2 + #
3 + # The headless flow (wiki `alloy-image-minting`, GO alloy 1fc19a8d): a machine
4 + # with no screen boots the ISO, and the operator continues the install from
5 + # another machine with `ssh installer@<name>.local`. The credential is the
6 + # public key baked into the medium at mint time by `alloy image` — see the
7 + # identity block in the Containerfile. No password auth, no one-time code on a
8 + # console the machine does not have, no window in which a box on the LAN is
9 + # takeoverable.
10 + #
11 + # THE GATE IS THE ACCOUNT, NOT THIS FILE.
12 + #
13 + # `alloy install` writes disks. Reaching it over ssh must be impossible on an
14 + # installed machine, and the flag that says which machine this is lives on the
15 + # kernel command line, which sshd_config cannot read: `Match` takes User,
16 + # Group, Host, LocalAddress, LocalPort, RDomain and Address, and nothing else.
17 + # (`Match exec` is ssh_config, the client side. It is not available here.)
18 + #
19 + # So this file ships everywhere and does nothing on its own. The `installer`
20 + # account it names is created by alloy-installer-ssh.service, which carries the
21 + # same `ConditionKernelCommandLine=alloy.installer` as alloy-installer.service
22 + # and so runs only on the live medium. On an installed system there is no such
23 + # user, `Match User installer` matches nothing, and the block below is unread.
24 + # That direction matters: the failure mode of the unit not running is a login
25 + # that does not happen, rather than one that should not have.
26 + #
27 + # It is also why nothing here is written at boot. Copying a drop-in into
28 + # /etc/ssh at runtime would put the gate in a race with sshd's own start, and
29 + # losing that race fails open.
30 +
31 + Match User installer
32 +
33 + # The baked key, at an absolute path. The account has no home directory to
34 + # hold a `~/.ssh`, and giving it one would mean the credential lived somewhere
35 + # a half-finished install could have written to.
36 + AuthorizedKeysFile /usr/lib/alloy/authorized_keys
37 +
38 + # The session IS the installer. Not a shell that offers to start it: whatever
39 + # the client asked to run is replaced by this, so there is no command line to
40 + # get wrong and no fallback to a prompt.
41 + #
42 + # Its exit status is the session's exit status, and there is no retry here or
43 + # in the unit. A crashed installer has left a disk in an unknown state, and the
44 + # next person to connect should meet a machine that says so rather than a fresh
45 + # wizard offering to go over it again. That is the same ruling
46 + # alloy-installer.service records for tty1 in its `Restart=no`.
47 + ForceCommand /usr/bin/alloy install
48 +
49 + # A TUI needs a terminal. ForceCommand alone does not allocate one, and the
50 + # installer would draw into a pipe and be unusable.
51 + PermitTTY yes
52 +
53 + # Back to global scope, and this line is load-bearing rather than tidy.
54 + #
55 + # sshd applies a `Match` to everything that follows it, and `Include` does not
56 + # end that scope: /etc/ssh/sshd_config puts its
57 + # `Include /etc/ssh/sshd_config.d/*.conf` at line 15 and then sets keywords for
58 + # another two hundred. Without this, every one of those — and every drop-in
59 + # sorting after this one, which is most of Fedora's — would be read as part of
60 + # the block above and apply to the installer account alone. The symptom is not
61 + # a parse error; it is a machine whose global ssh policy quietly stopped
62 + # existing.
63 + Match all
@@ -1,0 +1,53 @@
1 + # Creates the `installer` account, on the installer medium only.
2 + #
3 + # This unit is the gate for the whole ssh-in half of the headless flow. The
4 + # sshd policy that turns that account into an installer session ships in every
5 + # image (etc/ssh/sshd_config.d/20-alloy-installer.conf) and is inert without
6 + # it: `Match User installer` matches nothing when there is no such user. So the
7 + # condition below is the single thing standing between a running Alloy machine
8 + # and a remotely reachable disk-partitioning wizard.
9 + #
10 + # Same flag and same reasoning as alloy-installer.service, which gates the tty1
11 + # installer. `alloy.installer` is set only by the GRUB entries the ISO build
12 + # writes (build/make-iso.sh), so this is inert on an installed system — which
13 + # is the same image.
14 + #
15 + # Fails safe. If the condition does not hold, or the unit fails, or it simply
16 + # has not run yet, the account does not exist and nobody logs in. The bad
17 + # direction would be a gate whose absence leaves the door open, which is what
18 + # writing the sshd drop-in at runtime would have been.
19 + [Unit]
20 + Description=Alloy installer ssh account
21 + Documentation=https://git.sr.ht/~maxmj/alloy
22 + ConditionKernelCommandLine=alloy.installer
23 +
24 + # Ordered ahead of sshd so the account exists before anything can ask for it.
25 + # Both are pulled by multi-user.target, so they land in one transaction and
26 + # this ordering is honoured. sshd does not need restarting afterwards: the user
27 + # is resolved per connection, not read at start.
28 + Before=sshd.service
29 +
30 + [Service]
31 + Type=oneshot
32 + RemainAfterExit=yes
33 +
34 + # Idempotent, because a `useradd` that has already run exits 9 and would put
35 + # the unit in failed. The `id` test is the check rather than a `-` prefix on
36 + # the command, which would also swallow the failures worth seeing.
37 + #
38 + # The shell is /bin/sh and that is not cosmetic: sshd runs a ForceCommand
39 + # through the account's login shell with `-c`, so /sbin/nologin would refuse
40 + # every session. Nothing is reachable through it — ForceCommand replaces
41 + # whatever the client asked for — so this grants no shell.
42 + #
43 + # No home directory, and `--home-dir /` so sshd has somewhere to chdir rather
44 + # than logging a failure on every connection. The authorized_keys it reads is
45 + # an absolute path in /usr/lib, baked at build time and owned by root.
46 + #
47 + # One physical line, not a continuation. The Containerfile checks that this
48 + # unit and the sshd drop-in name the same account, and it can only do that by
49 + # reading the line the account name is on.
50 + ExecStart=/bin/sh -c 'id -u installer >/dev/null 2>&1 || useradd --system --no-create-home --home-dir / --shell /bin/sh --comment "Alloy installer over ssh" installer'
51 +
52 + [Install]
53 + WantedBy=multi-user.target