max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
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 |