max / alloy
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
2 files changed,
+303 insertions,
-21 deletions
| @@ -895,9 +895,9 @@ | |||
| 895 | 895 | ||
| 896 | 896 | [[package]] | |
| 897 | 897 | name = "makeover" | |
| 898 | - | version = "2.2.0" | |
| 898 | + | version = "2.3.0" | |
| 899 | 899 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 900 | - | checksum = "f3d7c2a566230afeed8b2b9d8decc2edfdc9b1f067e7791303df8301a73aeff1" | |
| 900 | + | checksum = "e35cc903581eea6df09a3a102f101d85a6b941ea18431fcd0a70738762271fdf" | |
| 901 | 901 | dependencies = [ | |
| 902 | 902 | "include_dir", | |
| 903 | 903 | "serde", |
| @@ -118,7 +118,8 @@ | |||
| 118 | 118 | const FIELD_USERNAME: usize = 0; | |
| 119 | 119 | const FIELD_PASSWORD: usize = 1; | |
| 120 | 120 | const FIELD_CONFIRM: usize = 2; | |
| 121 | - | const ACCOUNT_FIELDS: usize = 3; | |
| 121 | + | const FIELD_PUBKEY: usize = 3; | |
| 122 | + | const ACCOUNT_FIELDS: usize = 4; | |
| 122 | 123 | ||
| 123 | 124 | /// Which slot of the hostname step has focus. | |
| 124 | 125 | /// | |
| @@ -132,6 +133,13 @@ | |||
| 132 | 133 | /// Longest username `useradd` accepts. | |
| 133 | 134 | const USERNAME_MAX: usize = 32; | |
| 134 | 135 | ||
| 136 | + | /// Width of the right-aligned label column on the form panes. | |
| 137 | + | /// | |
| 138 | + | /// Named because two things depend on it agreeing: the label itself, and the | |
| 139 | + | /// budget [`field_line`](InstallView::field_line) has left for a value that has | |
| 140 | + | /// to scroll under its caret. | |
| 141 | + | const LABEL_WIDTH: usize = 10; | |
| 142 | + | ||
| 135 | 143 | /// Check a username against what `useradd` will accept. | |
| 136 | 144 | /// | |
| 137 | 145 | /// Rules are the portable ones NAME_REGEX enforces on Fedora: start with a | |
| @@ -221,6 +229,105 @@ | |||
| 221 | 229 | Ok(()) | |
| 222 | 230 | } | |
| 223 | 231 | ||
| 232 | + | /// The key types OpenSSH will accept in an `authorized_keys` line. | |
| 233 | + | /// | |
| 234 | + | /// `ssh-dss` is deliberately absent: OpenSSH disabled DSA at runtime years ago | |
| 235 | + | /// and removed it outright in 9.8, so accepting one here would write a file the | |
| 236 | + | /// target's sshd ignores, which is indistinguishable from the installer having | |
| 237 | + | /// dropped the key. | |
| 238 | + | const PUBKEY_TYPES: [&str; 6] = [ | |
| 239 | + | "ssh-ed25519", | |
| 240 | + | "sk-ssh-ed25519@openssh.com", | |
| 241 | + | "ssh-rsa", | |
| 242 | + | "ecdsa-sha2-nistp256", | |
| 243 | + | "ecdsa-sha2-nistp384", | |
| 244 | + | "ecdsa-sha2-nistp521", | |
| 245 | + | ]; | |
| 246 | + | ||
| 247 | + | /// Check an SSH public key, returning why it is unacceptable. | |
| 248 | + | /// | |
| 249 | + | /// **Empty is acceptable.** The field is optional by the 2026-07-25 minting | |
| 250 | + | /// decision: a minted image carries the key already, so this is the recovery | |
| 251 | + | /// path rather than the happy one. What is not acceptable is a value that looks | |
| 252 | + | /// like a key and is not, because sshd's response to a malformed | |
| 253 | + | /// `authorized_keys` line is to ignore it silently, and the user finds out by | |
| 254 | + | /// being unable to log in. | |
| 255 | + | /// | |
| 256 | + | /// The case worth its own message is a **private** key. `id_ed25519` and | |
| 257 | + | /// `id_ed25519.pub` differ by four characters, the wrong one is the first | |
| 258 | + | /// completion in most shells, and a private key pasted into a field on screen in | |
| 259 | + | /// front of whoever is standing there is a real thing to have happen. Naming it | |
| 260 | + | /// is the difference between "that is the wrong file" and a generic rejection | |
| 261 | + | /// the user answers by pasting it again. | |
| 262 | + | pub(crate) fn validate_pubkey(key: &str) -> Result<(), String> { | |
| 263 | + | let key = key.trim(); | |
| 264 | + | if key.is_empty() { | |
| 265 | + | return Ok(()); | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | // Checked before the shape, because a private key fails the shape check too | |
| 269 | + | // and would otherwise get the unhelpful message. | |
| 270 | + | if key.starts_with("-----BEGIN") { | |
| 271 | + | return Err("that is a private key; paste the .pub file instead".into()); | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | let mut parts = key.split_whitespace(); | |
| 275 | + | let (Some(kind), Some(body)) = (parts.next(), parts.next()) else { | |
| 276 | + | return Err("a key is '<type> <base64>', as found in a .pub file".into()); | |
| 277 | + | }; | |
| 278 | + | if !PUBKEY_TYPES.contains(&kind) { | |
| 279 | + | return Err(format!("'{kind}' is not a key type openssh accepts")); | |
| 280 | + | } | |
| 281 | + | // The base64 body, not the whole line: a comment may hold anything, and | |
| 282 | + | // people put spaces and punctuation in them. | |
| 283 | + | if body.len() % 4 != 0 || !body.trim_end_matches('=').bytes().all(is_base64) { | |
| 284 | + | return Err("the key body is not valid base64; the paste may be truncated".into()); | |
| 285 | + | } | |
| 286 | + | // Every accepted type encodes its own name at the front of the body, and | |
| 287 | + | // base64 maps the first three bytes of that to a fixed prefix. So a body | |
| 288 | + | // pasted from a different line than its type is catchable without decoding | |
| 289 | + | // anything: `ssh-ed25519` bodies begin `AAAAC3NzaC1lZDI1`, `ssh-rsa` bodies | |
| 290 | + | // `AAAAB3NzaC1yc2E`. Checking the shared `AAAA` alone is enough to catch a | |
| 291 | + | // body that is base64 of something else entirely, which is the realistic | |
| 292 | + | // paste error rather than a crafted mismatch. | |
| 293 | + | if !body.starts_with("AAAA") { | |
| 294 | + | return Err("that base64 is not an ssh key body".into()); | |
| 295 | + | } | |
| 296 | + | Ok(()) | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | /// A base64 digit, in the standard alphabet `authorized_keys` uses. | |
| 300 | + | fn is_base64(b: u8) -> bool { | |
| 301 | + | b.is_ascii_alphanumeric() || b == b'+' || b == b'/' | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | /// A key as a review screen should show it. | |
| 305 | + | /// | |
| 306 | + | /// The full body is 68 characters for ed25519 and nearer 400 for RSA, which on a | |
| 307 | + | /// summary would push every other row off the screen and still not be something | |
| 308 | + | /// anyone reads character by character. What a person checks is the type, the | |
| 309 | + | /// comment naming which machine it came from, and enough of the body to tell two | |
| 310 | + | /// keys apart, so that is what this keeps. | |
| 311 | + | fn pubkey_summary(key: &str) -> String { | |
| 312 | + | let mut parts = key.split_whitespace(); | |
| 313 | + | let (Some(kind), Some(body)) = (parts.next(), parts.next()) else { | |
| 314 | + | return key.to_string(); | |
| 315 | + | }; | |
| 316 | + | // The tail rather than the head: every body of a given type starts with the | |
| 317 | + | // same encoded type name, so the first characters are the ones that do not | |
| 318 | + | // distinguish anything. | |
| 319 | + | let tail: String = body | |
| 320 | + | .chars() | |
| 321 | + | .skip(body.chars().count().saturating_sub(8)) | |
| 322 | + | .collect(); | |
| 323 | + | let comment = parts.collect::<Vec<_>>().join(" "); | |
| 324 | + | if comment.is_empty() { | |
| 325 | + | format!("{kind} …{tail}") | |
| 326 | + | } else { | |
| 327 | + | format!("{kind} …{tail} {comment}") | |
| 328 | + | } | |
| 329 | + | } | |
| 330 | + | ||
| 224 | 331 | // ---- what the installer has been told ---- | |
| 225 | 332 | ||
| 226 | 333 | /// The answers collected so far. | |
| @@ -240,6 +347,13 @@ | |||
| 240 | 347 | /// [`TextField`] until the plan is built, which keeps the number of | |
| 241 | 348 | /// places holding a readable copy to one. See [`Secret`]. | |
| 242 | 349 | pub username: Option<String>, | |
| 350 | + | /// The SSH public key authorized for that account, if one was given. | |
| 351 | + | /// | |
| 352 | + | /// Not a [`Secret`], unlike the password, and the distinction is the point: | |
| 353 | + | /// this half of a keypair is meant to be published. Holding it here rather | |
| 354 | + | /// than leaving it in its [`TextField`] is therefore fine, and the summary | |
| 355 | + | /// step needs to read it back to say whether the machine will be reachable. | |
| 356 | + | pub pubkey: Option<String>, | |
| 243 | 357 | /// Whether the install may ask the network where this machine is, to set | |
| 244 | 358 | /// the timezone from it. | |
| 245 | 359 | /// | |
| @@ -478,6 +592,15 @@ | |||
| 478 | 592 | /// Mode for a new home directory, matching Fedora's `HOME_MODE`. | |
| 479 | 593 | const HOME_MODE: &str = "700"; | |
| 480 | 594 | ||
| 595 | + | /// Modes sshd requires of `~/.ssh` and `authorized_keys`. | |
| 596 | + | /// | |
| 597 | + | /// Not arbitrary hardening: sshd refuses to read an `authorized_keys` that is | |
| 598 | + | /// writable by anyone but its owner, and says so only in the server's own log. | |
| 599 | + | /// A key written with the wrong mode therefore looks exactly like a key that was | |
| 600 | + | /// never written. | |
| 601 | + | const SSH_DIR_MODE: &str = "700"; | |
| 602 | + | const SSH_FILE_MODE: &str = "600"; | |
| 603 | + | ||
| 481 | 604 | /// The shell new accounts get. | |
| 482 | 605 | /// | |
| 483 | 606 | /// nushell is the shell Alloy's config tree is written for: the aliases, the | |
| @@ -751,6 +874,7 @@ | |||
| 751 | 874 | hostname: &str, | |
| 752 | 875 | username: &str, | |
| 753 | 876 | password: &str, | |
| 877 | + | pubkey: Option<&str>, | |
| 754 | 878 | locate_timezone: bool, | |
| 755 | 879 | root: &str, | |
| 756 | 880 | ) -> Result<Vec<Stage>, String> { | |
| @@ -838,6 +962,48 @@ | |||
| 838 | 962 | .arg(format!("{root}/etc/skel/.")) | |
| 839 | 963 | .arg(&home), | |
| 840 | 964 | ), | |
| 965 | + | ]; | |
| 966 | + | ||
| 967 | + | // Before the chown below, deliberately. That stage is already recursive over | |
| 968 | + | // the home directory, so a `.ssh` written here is covered by it and there is | |
| 969 | + | // no second place that has to know the numeric ids. Writing the key after it | |
| 970 | + | // would leave the directory root-owned, and sshd refuses an authorized_keys | |
| 971 | + | // it does not trust the ownership of, silently. | |
| 972 | + | if let Some(key) = pubkey { | |
| 973 | + | let ssh_dir = format!("{home}/.ssh"); | |
| 974 | + | let authorized = format!("{ssh_dir}/authorized_keys"); | |
| 975 | + | stages.extend([ | |
| 976 | + | Stage::Run(Invocation::new("mkdir").args(["-p", &ssh_dir])), | |
| 977 | + | // `tee` because the key arrives on stdin, and stdin because an | |
| 978 | + | // Invocation is argv with no shell (see [`Invocation`]) so there is | |
| 979 | + | // no redirect to write with. The value goes through [`Secret`], | |
| 980 | + | // which is the only stdin this type takes; the redaction that comes | |
| 981 | + | // with it is incidental rather than a claim that a public key needs | |
| 982 | + | // hiding. | |
| 983 | + | // | |
| 984 | + | // Note that tee copies stdin to stdout, so the key does appear in | |
| 985 | + | // the run screen's streamed output even though it is absent from the | |
| 986 | + | // rendered argv. That is wanted rather than tolerated: this half of | |
| 987 | + | // the pair is meant to be published, and seeing it echoed is how the | |
| 988 | + | // user confirms the key that landed is the key they pasted. | |
| 989 | + | // | |
| 990 | + | // Trailing newline: sshd parses authorized_keys by line, and a final | |
| 991 | + | // line without one is not reliably read. | |
| 992 | + | Stage::Run( | |
| 993 | + | Invocation::new("tee") | |
| 994 | + | .arg(&authorized) | |
| 995 | + | .stdin(Secret::new(format!("{key}\n"))), | |
| 996 | + | ), | |
| 997 | + | // sshd enforces these itself: a group- or world-writable .ssh or | |
| 998 | + | // authorized_keys is ignored, with the reason going only to the | |
| 999 | + | // server's log. So getting them wrong produces exactly the symptom | |
| 1000 | + | // this whole field exists to prevent. | |
| 1001 | + | Stage::Run(Invocation::new("chmod").args([SSH_DIR_MODE, &ssh_dir])), | |
| 1002 | + | Stage::Run(Invocation::new("chmod").args([SSH_FILE_MODE, &authorized])), | |
| 1003 | + | ]); | |
| 1004 | + | } | |
| 1005 | + | ||
| 1006 | + | stages.extend([ | |
| 841 | 1007 | // First discovery: which uid and gid useradd picked. Reading the file | |
| 842 | 1008 | // rather than asking getent, because getent answers about this machine. | |
| 843 | 1009 | Stage::Resolve { | |
| @@ -859,7 +1025,7 @@ | |||
| 859 | 1025 | .arg("--encrypted") | |
| 860 | 1026 | .stdin(Secret::new(format!("{username}:{hash}\n"))), | |
| 861 | 1027 | ), | |
| 862 | - | ]; | |
| 1028 | + | ]); | |
| 863 | 1029 | ||
| 864 | 1030 | // Last of the answers, and deliberately after the account: it is the only | |
| 865 | 1031 | // stage that can decline to do anything, and the only one that touches the | |
| @@ -975,11 +1141,13 @@ | |||
| 975 | 1141 | hostname: &str, | |
| 976 | 1142 | username: &str, | |
| 977 | 1143 | password: &str, | |
| 1144 | + | pubkey: Option<&str>, | |
| 978 | 1145 | locate_timezone: bool, | |
| 979 | 1146 | ) -> Vec<Stage> { | |
| 980 | 1147 | let hostname = hostname.to_string(); | |
| 981 | 1148 | let username = username.to_string(); | |
| 982 | 1149 | let password = password.to_string(); | |
| 1150 | + | let pubkey = pubkey.map(str::to_string); | |
| 983 | 1151 | ||
| 984 | 1152 | vec![ | |
| 985 | 1153 | // --wipe is explicit rather than implied by the confirm the user just | |
| @@ -1066,6 +1234,7 @@ | |||
| 1066 | 1234 | &hostname, | |
| 1067 | 1235 | &username, | |
| 1068 | 1236 | &password, | |
| 1237 | + | pubkey.as_deref(), | |
| 1069 | 1238 | locate_timezone, | |
| 1070 | 1239 | deployment, | |
| 1071 | 1240 | ) | |
| @@ -1414,6 +1583,7 @@ | |||
| 1414 | 1583 | username: TextField, | |
| 1415 | 1584 | password: TextField, | |
| 1416 | 1585 | confirm: TextField, | |
| 1586 | + | pubkey: TextField, | |
| 1417 | 1587 | /// Which of the two hostname-step slots has focus. | |
| 1418 | 1588 | machine: FocusRing, | |
| 1419 | 1589 | /// The checkbox, until the step is confirmed and it becomes an answer. | |
| @@ -1452,6 +1622,7 @@ | |||
| 1452 | 1622 | username: TextField::new(), | |
| 1453 | 1623 | password: TextField::new(), | |
| 1454 | 1624 | confirm: TextField::new(), | |
| 1625 | + | pubkey: TextField::new(), | |
| 1455 | 1626 | machine: FocusRing::new(HOSTNAME_SLOTS), | |
| 1456 | 1627 | locate_timezone: false, | |
| 1457 | 1628 | fields: FocusRing::new(ACCOUNT_FIELDS), | |
| @@ -1568,6 +1739,7 @@ | |||
| 1568 | 1739 | match self.fields.current() { | |
| 1569 | 1740 | FIELD_PASSWORD => &mut self.password, | |
| 1570 | 1741 | FIELD_CONFIRM => &mut self.confirm, | |
| 1742 | + | FIELD_PUBKEY => &mut self.pubkey, | |
| 1571 | 1743 | _ => &mut self.username, | |
| 1572 | 1744 | } | |
| 1573 | 1745 | } | |
| @@ -1590,8 +1762,18 @@ | |||
| 1590 | 1762 | self.fields.focus(FIELD_CONFIRM); | |
| 1591 | 1763 | return Flow::Continue; | |
| 1592 | 1764 | } | |
| 1765 | + | if let Err(message) = validate_pubkey(self.pubkey.value()) { | |
| 1766 | + | self.error = Some(message); | |
| 1767 | + | self.fields.focus(FIELD_PUBKEY); | |
| 1768 | + | return Flow::Continue; | |
| 1769 | + | } | |
| 1593 | 1770 | ||
| 1594 | 1771 | self.answers.username = Some(self.username.value().to_string()); | |
| 1772 | + | // Trimmed and emptied to None together: a field holding only whitespace | |
| 1773 | + | // is a field the user left alone, and it must not become a blank line in | |
| 1774 | + | // authorized_keys. | |
| 1775 | + | let key = self.pubkey.value().trim(); | |
| 1776 | + | self.answers.pubkey = (!key.is_empty()).then(|| key.to_string()); | |
| 1595 | 1777 | self.error = None; | |
| 1596 | 1778 | self.steps.advance(); | |
| 1597 | 1779 | Flow::Continue | |
| @@ -1607,7 +1789,7 @@ | |||
| 1607 | 1789 | KeyCode::Tab | KeyCode::Down => self.fields.next(), | |
| 1608 | 1790 | KeyCode::BackTab | KeyCode::Up => self.fields.prev(), | |
| 1609 | 1791 | KeyCode::Enter => { | |
| 1610 | - | if self.fields.current() == FIELD_CONFIRM { | |
| 1792 | + | if self.fields.current() == FIELD_PUBKEY { | |
| 1611 | 1793 | return self.create_account(); | |
| 1612 | 1794 | } | |
| 1613 | 1795 | self.fields.next(); | |
| @@ -1637,11 +1819,12 @@ | |||
| 1637 | 1819 | field: &TextField, | |
| 1638 | 1820 | slot: usize, | |
| 1639 | 1821 | masked: bool, | |
| 1822 | + | width: u16, | |
| 1640 | 1823 | ) -> Line<'a> { | |
| 1641 | 1824 | let focused = self.fields.is_focused(slot); | |
| 1642 | 1825 | let (before, under, after) = field.split(); | |
| 1643 | 1826 | ||
| 1644 | - | let (before, under, after) = if masked { | |
| 1827 | + | let (mut before, under, mut after) = if masked { | |
| 1645 | 1828 | ( | |
| 1646 | 1829 | "•".repeat(before.chars().count()), | |
| 1647 | 1830 | under.map(|_| '•'), | |
| @@ -1651,11 +1834,33 @@ | |||
| 1651 | 1834 | (before.to_string(), under, after.to_string()) | |
| 1652 | 1835 | }; | |
| 1653 | 1836 | ||
| 1837 | + | // Scroll the value under a fixed caret when it is wider than the pane. | |
| 1838 | + | // | |
| 1839 | + | // Every other field on this screen is short enough that this never | |
| 1840 | + | // triggers: a username is capped at 32. An ed25519 public key is 68 | |
| 1841 | + | // characters before its comment and an RSA one is nearer 400, against a | |
| 1842 | + | // label column and a pane that can be 80 wide. Without this the caret | |
| 1843 | + | // walks off the right edge and the user is typing somewhere they cannot | |
| 1844 | + | // see, which is a poor place to be checking a paste for truncation. | |
| 1845 | + | let budget = usize::from(width).saturating_sub(LABEL_WIDTH + 3); | |
| 1846 | + | if budget > 0 { | |
| 1847 | + | let len = before.chars().count(); | |
| 1848 | + | if len > budget { | |
| 1849 | + | before = std::iter::once('…') | |
| 1850 | + | .chain(before.chars().skip(len - budget + 1)) | |
| 1851 | + | .collect(); | |
| 1852 | + | } | |
| 1853 | + | let tail = budget.saturating_sub(before.chars().count()); | |
| 1854 | + | if after.chars().count() > tail { | |
| 1855 | + | after = after.chars().take(tail).collect(); | |
| 1856 | + | } | |
| 1857 | + | } | |
| 1858 | + | ||
| 1654 | 1859 | let mut spans = vec![ | |
| 1655 | 1860 | if focused { | |
| 1656 | - | text::bold(theme, format!("{label:>10} ")) | |
| 1861 | + | text::bold(theme, format!("{label:>LABEL_WIDTH$} ")) | |
| 1657 | 1862 | } else { | |
| 1658 | - | text::muted(theme, format!("{label:>10} ")) | |
| 1863 | + | text::muted(theme, format!("{label:>LABEL_WIDTH$} ")) | |
| 1659 | 1864 | }, | |
| 1660 | 1865 | text::primary(theme, before), | |
| 1661 | 1866 | ]; | |
| @@ -1675,18 +1880,45 @@ | |||
| 1675 | 1880 | Line::from(spans) | |
| 1676 | 1881 | } | |
| 1677 | 1882 | ||
| 1678 | - | /// The account pane: three fields, the passwords masked. | |
| 1883 | + | /// The account pane: four fields, the passwords masked. | |
| 1679 | 1884 | fn render_account(&self, frame: &mut Frame, area: Rect, theme: &Theme) { | |
| 1680 | - | let lines = vec![ | |
| 1885 | + | let w = area.width; | |
| 1886 | + | let mut lines = vec![ | |
| 1681 | 1887 | Line::from(text::muted( | |
| 1682 | 1888 | theme, | |
| 1683 | 1889 | "The account you will log in with. It can become root with run0.", | |
| 1684 | 1890 | )), | |
| 1685 | 1891 | Line::default(), | |
| 1686 | - | self.field_line(theme, "username", &self.username, FIELD_USERNAME, false), | |
| 1687 | - | self.field_line(theme, "password", &self.password, FIELD_PASSWORD, true), | |
| 1688 | - | self.field_line(theme, "confirm", &self.confirm, FIELD_CONFIRM, true), | |
| 1892 | + | self.field_line(theme, "username", &self.username, FIELD_USERNAME, false, w), | |
| 1893 | + | self.field_line(theme, "password", &self.password, FIELD_PASSWORD, true, w), | |
| 1894 | + | self.field_line(theme, "confirm", &self.confirm, FIELD_CONFIRM, true, w), | |
| 1895 | + | Line::default(), | |
| 1896 | + | self.field_line(theme, "ssh key", &self.pubkey, FIELD_PUBKEY, false, w), | |
| 1689 | 1897 | ]; | |
| 1898 | + | ||
| 1899 | + | // Said here rather than only at the summary, because this is where the | |
| 1900 | + | // decision gets made. The image refuses password logins over ssh | |
| 1901 | + | // (etc/ssh/sshd_config.d/10-alloy.conf), so an empty field does not mean | |
| 1902 | + | // "ssh is less convenient", it means ssh cannot work at all until | |
| 1903 | + | // somebody adds a key from the console. A machine that will not have a | |
| 1904 | + | // console is a machine this field is the only way into. | |
| 1905 | + | let hint: &[&str] = if self.pubkey.value().trim().is_empty() { | |
| 1906 | + | &[ | |
| 1907 | + | "optional, and paste is fine.", | |
| 1908 | + | "left empty, ssh into this machine will be impossible: the", | |
| 1909 | + | "image refuses password logins, so a key has to be added here", | |
| 1910 | + | "or later from the console.", | |
| 1911 | + | ] | |
| 1912 | + | } else { | |
| 1913 | + | &["authorized for the account above."] | |
| 1914 | + | }; | |
| 1915 | + | for line in hint { | |
| 1916 | + | lines.push(Line::from(text::muted( | |
| 1917 | + | theme, | |
| 1918 | + | format!("{:LABEL_WIDTH$} {line}", ""), | |
| 1919 | + | ))); | |
| 1920 | + | } | |
| 1921 | + | ||
| 1690 | 1922 | frame.render_widget(Paragraph::new(lines), area); | |
| 1691 | 1923 | } | |
| 1692 | 1924 | ||
| @@ -1726,6 +1958,19 @@ | |||
| 1726 | 1958 | self.answers.username.as_deref().unwrap_or("-"), | |
| 1727 | 1959 | String::new(), | |
| 1728 | 1960 | ), | |
| 1961 | + | // Same rule as the timezone below: named whichever way it went. The | |
| 1962 | + | // no-key case is the one worth a review screen, since it decides | |
| 1963 | + | // whether this machine is reachable at all, so it says what it means | |
| 1964 | + | // rather than printing a dash. | |
| 1965 | + | match self.answers.pubkey.as_deref() { | |
| 1966 | + | Some(key) => row(theme, "ssh key", &pubkey_summary(key), String::new()), | |
| 1967 | + | None => row( | |
| 1968 | + | theme, | |
| 1969 | + | "ssh key", | |
| 1970 | + | "none", | |
| 1971 | + | " no ssh access until one is added".into(), | |
| 1972 | + | ), | |
| 1973 | + | }, | |
| 1729 | 1974 | // Named on the summary whichever way it went. "UTC" is a decision | |
| 1730 | 1975 | // the install is about to make, and a review screen that only | |
| 1731 | 1976 | // listed the answers someone changed would hide the defaults it is | |
| @@ -1857,6 +2102,7 @@ | |||
| 1857 | 2102 | hostname, | |
| 1858 | 2103 | username, | |
| 1859 | 2104 | password, | |
| 2105 | + | self.answers.pubkey.as_deref(), | |
| 1860 | 2106 | self.answers.locate_timezone, | |
| 1861 | 2107 | ) | |
| 1862 | 2108 | } | |
| @@ -2369,6 +2615,7 @@ | |||
| 2369 | 2615 | username: TextField::new(), | |
| 2370 | 2616 | password: TextField::new(), | |
| 2371 | 2617 | confirm: TextField::new(), | |
| 2618 | + | pubkey: TextField::new(), | |
| 2372 | 2619 | machine: FocusRing::new(HOSTNAME_SLOTS), | |
| 2373 | 2620 | locate_timezone: false, | |
| 2374 | 2621 | fields: FocusRing::new(ACCOUNT_FIELDS), | |
| @@ -2616,7 +2863,11 @@ | |||
| 2616 | 2863 | (view, log) | |
| 2617 | 2864 | } | |
| 2618 | 2865 | ||
| 2619 | - | /// Fill the three account fields, moving between them the way a user does. | |
| 2866 | + | /// Fill the account fields, moving between them the way a user does. | |
| 2867 | + | /// | |
| 2868 | + | /// Leaves focus on the key field with it empty, which is where Enter | |
| 2869 | + | /// submits. That is also the default install: the key is optional, so a user | |
| 2870 | + | /// who does not want one tabs past it. | |
| 2620 | 2871 | fn fill_account(view: &mut InstallView, user: &str, pass: &str, confirm: &str) { | |
| 2621 | 2872 | let mut log = CommandLog::new(); | |
| 2622 | 2873 | view.fields.focus(FIELD_USERNAME); | |
| @@ -2625,6 +2876,7 @@ | |||
| 2625 | 2876 | type_into(view, pass, &mut log); | |
| 2626 | 2877 | view.fields.focus(FIELD_CONFIRM); | |
| 2627 | 2878 | type_into(view, confirm, &mut log); | |
| 2879 | + | view.fields.focus(FIELD_PUBKEY); | |
| 2628 | 2880 | } | |
| 2629 | 2881 | ||
| 2630 | 2882 | #[test] | |
| @@ -2837,7 +3089,7 @@ | |||
| 2837 | 3089 | /// Without the location lookup, which is the default and what almost every | |
| 2838 | 3090 | /// assertion below is about. `configured_locating` is the other one. | |
| 2839 | 3091 | fn configured() -> Vec<String> { | |
| 2840 | - | configure_plan("workshop", "max", "hunter2", false, DEPLOYMENT) | |
| 3092 | + | configure_plan("workshop", "max", "hunter2", None, false, DEPLOYMENT) | |
| 2841 | 3093 | .expect("a well-formed deployment path") | |
| 2842 | 3094 | .iter() | |
| 2843 | 3095 | .map(Stage::display) | |
| @@ -3227,11 +3479,12 @@ | |||
| 3227 | 3479 | ||
| 3228 | 3480 | #[test] | |
| 3229 | 3481 | fn a_located_install_asks_and_bounds_the_asking() { | |
| 3230 | - | let shown: Vec<String> = configure_plan("workshop", "max", "hunter2", true, DEPLOYMENT) | |
| 3231 | - | .expect("a well-formed deployment path") | |
| 3232 | - | .iter() | |
| 3233 | - | .map(Stage::display) | |
| 3234 | - | .collect(); | |
| 3482 | + | let shown: Vec<String> = | |
| 3483 | + | configure_plan("workshop", "max", "hunter2", None, true, DEPLOYMENT) | |
| 3484 | + | .expect("a well-formed deployment path") | |
| 3485 | + | .iter() | |
| 3486 | + | .map(Stage::display) | |
| 3487 | + | .collect(); | |
| 3235 | 3488 | ||
| 3236 | 3489 | let lookup = shown | |
| 3237 | 3490 | .iter() | |
| @@ -3467,7 +3720,7 @@ | |||
| 3467 | 3720 | // partition table between bootc writing it and udev settling. | |
| 3468 | 3721 | #[test] | |
| 3469 | 3722 | fn the_plan_settles_udev_before_it_reads_the_partition_table() { | |
| 3470 | - | let plan = install_plan("/dev/sda", "host", "user", "pw", false); | |
| 3723 | + | let plan = install_plan("/dev/sda", "host", "user", "pw", None, false); | |
| 3471 | 3724 | let lines: Vec<String> = plan.iter().map(Stage::display).collect(); | |
| 3472 | 3725 | let settle = lines | |
| 3473 | 3726 | .iter() | |
| @@ -3483,6 +3736,229 @@ | |||
| 3483 | 3736 | ); | |
| 3484 | 3737 | } | |
| 3485 | 3738 | ||
| 3739 | + | // ---- the ssh key ---- | |
| 3740 | + | ||
| 3741 | + | // Optional by the minting decision: a minted image already carries the key, | |
| 3742 | + | // so this field is the recovery path. Empty must therefore pass validation | |
| 3743 | + | // rather than being the one answer that blocks the install. | |
| 3744 | + | #[test] | |
| 3745 | + | fn no_key_at_all_is_a_valid_answer() { | |
| 3746 | + | assert!(validate_pubkey("").is_ok()); | |
| 3747 | + | assert!(validate_pubkey(" ").is_ok()); | |
| 3748 | + | assert!(validate_pubkey("\t\n ").is_ok()); | |
| 3749 | + | } | |
| 3750 | + | ||
| 3751 | + | /// A well-formed ed25519 body: 68 base64 characters, which is what | |
| 3752 | + | /// `ssh-keygen` emits and what the length check is calibrated against. | |
| 3753 | + | const ED25519_BODY: &str = | |
| 3754 | + | "AAAAC3NzaC1lZDI1NTE5AAAAIABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq"; | |
| 3755 | + | ||
| 3756 | + | #[test] | |
| 3757 | + | fn the_shapes_a_pub_file_actually_holds_are_accepted() { | |
| 3758 | + | let ed = format!("ssh-ed25519 {ED25519_BODY}"); | |
| 3759 | + | let ecdsa = format!( | |
| 3760 | + | "ecdsa-sha2-nistp256 {}", | |
| 3761 | + | "AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBBBB" | |
| 3762 | + | ); | |
| 3763 | + | for key in [ | |
| 3764 | + | ed.clone(), | |
| 3765 | + | format!("{ed} max@fw13"), | |
| 3766 | + | // A comment with spaces in it, which is what a key generated on a Mac | |
| 3767 | + | // gets by default. |
Lines truncated