| 313 |
313 |
|
/// Longest single hostname label, per RFC 1123.
|
| 314 |
314 |
|
const HOSTNAME_MAX: usize = 63;
|
| 315 |
315 |
|
|
|
316 |
+ |
/// Where a minted medium carries the public key it was baked with.
|
|
317 |
+ |
///
|
|
318 |
+ |
/// Written by the Containerfile's identity step from the `ALLOY_SSH_KEY` build
|
|
319 |
+ |
/// argument, and read by `etc/ssh/sshd_config.d/20-alloy-installer.conf` as the
|
|
320 |
+ |
/// `installer` account's `AuthorizedKeysFile`. So on a headless install this
|
|
321 |
+ |
/// file holds the key the operator is connected with right now.
|
|
322 |
+ |
///
|
|
323 |
+ |
/// The installer reads it to seed the account step rather than growing a
|
|
324 |
+ |
/// second place a key can come from. The two paths are one key store: baking
|
|
325 |
+ |
/// is how a key reaches the medium, and this is how it reaches the machine the
|
|
326 |
+ |
/// medium installs.
|
|
327 |
+ |
const BAKED_KEY: &str = "/usr/lib/alloy/authorized_keys";
|
|
328 |
+ |
|
|
329 |
+ |
/// The key a medium was minted with, if it carries a usable one.
|
|
330 |
+ |
///
|
|
331 |
+ |
/// Path taken as an argument so the tests can point it at a file they wrote;
|
|
332 |
+ |
/// [`InstallView::new`] passes [`BAKED_KEY`].
|
|
333 |
+ |
///
|
|
334 |
+ |
/// Only the first key. `authorized_keys` is a multi-key format and sshd reads
|
|
335 |
+ |
/// every line of it, but the identity step writes exactly one and a medium
|
|
336 |
+ |
/// carrying several is not a shape anything here produces. Taking the first is
|
|
337 |
+ |
/// then a choice between one key and a field the user cannot read to the end
|
|
338 |
+ |
/// of, and a seed is meant to be checked on screen.
|
|
339 |
+ |
///
|
|
340 |
+ |
/// Anything that does not validate is dropped rather than seeded. A field
|
|
341 |
+ |
/// pre-filled with a line sshd will ignore is worse than an empty one: the
|
|
342 |
+ |
/// summary would show a key, the install would plant it, and the machine would
|
|
343 |
+ |
/// refuse the login anyway.
|
|
344 |
+ |
fn baked_pubkey_at(path: &str) -> Option<String> {
|
|
345 |
+ |
let contents = std::fs::read_to_string(path).ok()?;
|
|
346 |
+ |
let key = contents
|
|
347 |
+ |
.lines()
|
|
348 |
+ |
.map(str::trim)
|
|
349 |
+ |
.find(|line| !line.is_empty() && !line.starts_with('#'))?;
|
|
350 |
+ |
(validate_pubkey(key).is_ok() && !key.is_empty()).then(|| key.to_string())
|
|
351 |
+ |
}
|
|
352 |
+ |
|
| 316 |
353 |
|
/// Check a hostname, returning why it is unacceptable.
|
| 317 |
354 |
|
///
|
| 318 |
355 |
|
/// RFC 1123 rules for a single label, which is what `/etc/hostname` holds:
|
| 3181 |
3218 |
|
password: TextField,
|
| 3182 |
3219 |
|
confirm: TextField,
|
| 3183 |
3220 |
|
pubkey: TextField,
|
|
3221 |
+ |
/// Whether the key field arrived seeded from the medium.
|
|
3222 |
+ |
///
|
|
3223 |
+ |
/// Only to say so on screen. The field is editable either way, and what
|
|
3224 |
+ |
/// gets planted is whatever it holds when the step is confirmed, so nothing
|
|
3225 |
+ |
/// downstream reads this.
|
|
3226 |
+ |
pubkey_from_medium: bool,
|
| 3184 |
3227 |
|
/// Which of the two hostname-step slots has focus.
|
| 3185 |
3228 |
|
machine: FocusRing,
|
| 3186 |
3229 |
|
/// The checkbox, until the step is confirmed and it becomes an answer.
|
| 3274 |
3317 |
|
// a field arriving pre-filled says what shape of answer is expected.
|
| 3275 |
3318 |
|
hostname.set(DEFAULT_HOSTNAME);
|
| 3276 |
3319 |
|
|
|
3320 |
+ |
// Seeded from the medium for the same reason the hostname is seeded
|
|
3321 |
+ |
// from its default, and for a sharper one besides. On the headless flow
|
|
3322 |
+ |
// the operator reached this wizard over ssh, authenticated by exactly
|
|
3323 |
+ |
// this key, so asking them to paste it again is asking them to retype a
|
|
3324 |
+ |
// credential they already proved they hold, into a TUI, over that same
|
|
3325 |
+ |
// connection. It is also the case with no console to recover from if
|
|
3326 |
+ |
// they get it wrong.
|
|
3327 |
+ |
//
|
|
3328 |
+ |
// A seed, not a decision: the field is editable and the account step
|
|
3329 |
+ |
// validates whatever it ends up holding.
|
|
3330 |
+ |
let mut pubkey = TextField::new();
|
|
3331 |
+ |
let baked = baked_pubkey_at(BAKED_KEY);
|
|
3332 |
+ |
if let Some(key) = &baked {
|
|
3333 |
+ |
pubkey.set(key);
|
|
3334 |
+ |
}
|
|
3335 |
+ |
|
| 3277 |
3336 |
|
let mut view = Self {
|
| 3278 |
3337 |
|
steps: Steps::new(STEPS.len()),
|
| 3279 |
3338 |
|
backend: detect(),
|
| 3283 |
3342 |
|
username: TextField::new(),
|
| 3284 |
3343 |
|
password: TextField::new(),
|
| 3285 |
3344 |
|
confirm: TextField::new(),
|
| 3286 |
|
- |
pubkey: TextField::new(),
|
|
3345 |
+ |
pubkey,
|
|
3346 |
+ |
pubkey_from_medium: baked.is_some(),
|
| 3287 |
3347 |
|
machine: FocusRing::new(HOSTNAME_SLOTS),
|
| 3288 |
3348 |
|
locate_timezone: false,
|
| 3289 |
3349 |
|
fields: FocusRing::new(ACCOUNT_FIELDS),
|
| 3709 |
3769 |
|
// console is a machine this field is the only way into.
|
| 3710 |
3770 |
|
let hint: &[&str] = if self.pubkey.value().trim().is_empty() {
|
| 3711 |
3771 |
|
&[
|
| 3712 |
|
- |
"optional, and paste is fine.",
|
|
3772 |
+ |
"paste is fine.",
|
| 3713 |
3773 |
|
"left empty, ssh into this machine will be impossible: the",
|
| 3714 |
3774 |
|
"image refuses password logins, so a key has to be added here",
|
| 3715 |
|
- |
"or later from the console.",
|
|
3775 |
+ |
"or later from the console. A machine with no console is a",
|
|
3776 |
+ |
"machine nobody can reach.",
|
|
3777 |
+ |
]
|
|
3778 |
+ |
} else if self.pubkey_from_medium {
|
|
3779 |
+ |
// Said because a field that filled itself in is a field people
|
|
3780 |
+ |
// scroll past. Where it came from is also the check worth making:
|
|
3781 |
+ |
// if this is not the key you expect, the medium is not the one you
|
|
3782 |
+ |
// think it is.
|
|
3783 |
+ |
&[
|
|
3784 |
+ |
"authorized for the account above.",
|
|
3785 |
+ |
"taken from the key this medium was minted with. Edit it if",
|
|
3786 |
+ |
"the machine should answer to a different one.",
|
| 3716 |
3787 |
|
]
|
| 3717 |
3788 |
|
} else {
|
| 3718 |
3789 |
|
&["authorized for the account above."]
|
| 4327 |
4398 |
|
return Flow::Continue;
|
| 4328 |
4399 |
|
};
|
| 4329 |
4400 |
|
|
|
4401 |
+ |
// The no-key case, carried onto the gate rather than left on the
|
|
4402 |
+ |
// summary alone. The summary names it, and the summary is the screen
|
|
4403 |
+ |
// people read once and then answer from memory on the second install.
|
|
4404 |
+ |
// This one is the screen nobody gets past without reading, and what it
|
|
4405 |
+ |
// is warning about is not recoverable from the far side: the image
|
|
4406 |
+ |
// refuses password logins (etc/ssh/sshd_config.d/10-alloy.conf), so an
|
|
4407 |
+ |
// installed machine with no key in its authorized_keys answers no ssh
|
|
4408 |
+ |
// login at all, and the only way back in is its own console.
|
|
4409 |
+ |
//
|
|
4410 |
+ |
// Stated, not blocked. A desktop install with a person sitting at it is
|
|
4411 |
+ |
// a legitimate no-key install: greetd is on the console, they log in
|
|
4412 |
+ |
// and add a key afterwards. Refusing here would fail the ordinary case
|
|
4413 |
+ |
// to protect the headless one, and the headless one is now seeded from
|
|
4414 |
+ |
// the medium's own key ([`BAKED_KEY`]), so it arrives with a key
|
|
4415 |
+ |
// already in the field.
|
|
4416 |
+ |
let reachable = if self.answers.pubkey.is_some() {
|
|
4417 |
+ |
String::new()
|
|
4418 |
+ |
} else {
|
|
4419 |
+ |
" No ssh key: this machine will refuse every ssh login, and its console is the only way in."
|
|
4420 |
+ |
.to_string()
|
|
4421 |
+ |
};
|
|
4422 |
+ |
|
| 4330 |
4423 |
|
Flow::Confirm(Confirm::destructive(
|
| 4331 |
4424 |
|
"erase disk",
|
| 4332 |
|
- |
format!("Erase {disk} and install Alloy? This cannot be undone."),
|
|
4425 |
+ |
format!("Erase {disk} and install Alloy? This cannot be undone.{reachable}"),
|
| 4333 |
4426 |
|
))
|
| 4334 |
4427 |
|
}
|
| 4335 |
4428 |
|
|
| 5047 |
5140 |
|
password: TextField::new(),
|
| 5048 |
5141 |
|
confirm: TextField::new(),
|
| 5049 |
5142 |
|
pubkey: TextField::new(),
|
|
5143 |
+ |
pubkey_from_medium: false,
|
| 5050 |
5144 |
|
machine: FocusRing::new(HOSTNAME_SLOTS),
|
| 5051 |
5145 |
|
locate_timezone: false,
|
| 5052 |
5146 |
|
fields: FocusRing::new(ACCOUNT_FIELDS),
|
| 5644 |
5738 |
|
assert!(confirm.message.contains("/dev/sda"), "{}", confirm.message);
|
| 5645 |
5739 |
|
}
|
| 5646 |
5740 |
|
|
|
5741 |
+ |
// The gate names the lockout. The image refuses password logins, so an
|
|
5742 |
+ |
// install with no key produces a machine that answers no ssh login at all,
|
|
5743 |
+ |
// and the last screen before the disk is written is the one nobody skips.
|
|
5744 |
+ |
#[test]
|
|
5745 |
+ |
fn the_gate_says_an_install_with_no_key_cannot_be_reached_over_ssh() {
|
|
5746 |
+ |
let (mut view, mut log) = at_credits();
|
|
5747 |
+ |
assert!(view.answers.pubkey.is_none(), "the fixture typed a key");
|
|
5748 |
+ |
|
|
5749 |
+ |
let Flow::Confirm(confirm) = view.handle(KeyEvent::from(KeyCode::Enter), &mut log) else {
|
|
5750 |
+ |
panic!("the credits page ran without confirming");
|
|
5751 |
+ |
};
|
|
5752 |
+ |
|
|
5753 |
+ |
assert!(
|
|
5754 |
+ |
confirm.message.contains("No ssh key"),
|
|
5755 |
+ |
"{}",
|
|
5756 |
+ |
confirm.message
|
|
5757 |
+ |
);
|
|
5758 |
+ |
assert!(
|
|
5759 |
+ |
confirm.message.contains("console"),
|
|
5760 |
+ |
"it did not say what the way in is: {}",
|
|
5761 |
+ |
confirm.message,
|
|
5762 |
+ |
);
|
|
5763 |
+ |
}
|
|
5764 |
+ |
|
|
5765 |
+ |
// The other half: a key was given, so the warning is absent. A gate that
|
|
5766 |
+ |
// said it every time would be read as boilerplate and stop being a warning.
|
|
5767 |
+ |
#[test]
|
|
5768 |
+ |
fn the_gate_is_silent_about_ssh_when_a_key_was_given() {
|
|
5769 |
+ |
let (mut view, mut log) = at_credits();
|
|
5770 |
+ |
view.answers.pubkey = Some(format!("ssh-ed25519 {ED25519_BODY} max@fw13"));
|
|
5771 |
+ |
|
|
5772 |
+ |
let Flow::Confirm(confirm) = view.handle(KeyEvent::from(KeyCode::Enter), &mut log) else {
|
|
5773 |
+ |
panic!("the credits page ran without confirming");
|
|
5774 |
+ |
};
|
|
5775 |
+ |
|
|
5776 |
+ |
assert!(
|
|
5777 |
+ |
!confirm.message.contains("No ssh key"),
|
|
5778 |
+ |
"{}",
|
|
5779 |
+ |
confirm.message
|
|
5780 |
+ |
);
|
|
5781 |
+ |
}
|
|
5782 |
+ |
|
| 5647 |
5783 |
|
// Esc from the credits is a way back to the answers, not a way out. A
|
| 5648 |
5784 |
|
// review screen you cannot return to from the last page would make the
|
| 5649 |
5785 |
|
// credits a trap rather than a step.
|
| 7346 |
7482 |
|
}
|
| 7347 |
7483 |
|
}
|
| 7348 |
7484 |
|
|
|
7485 |
+ |
// Three places name this path: the Containerfile's identity step writes it,
|
|
7486 |
+ |
// the installer's sshd drop-in reads it as the `installer` account's
|
|
7487 |
+ |
// authorized keys, and this constant seeds the wizard's key field from it.
|
|
7488 |
+ |
// The other two are pinned to each other in `tests/ssh_policy.rs`, which
|
|
7489 |
+ |
// cannot see a private constant. This is that pin's third leg.
|
|
7490 |
+ |
#[test]
|
|
7491 |
+ |
fn the_seeded_key_is_the_one_the_medium_was_baked_with() {
|
|
7492 |
+ |
let drop_in = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
7493 |
+ |
.join("../../etc/ssh/sshd_config.d/20-alloy-installer.conf");
|
|
7494 |
+ |
let text = std::fs::read_to_string(&drop_in)
|
|
7495 |
+ |
.unwrap_or_else(|error| panic!("reading {}: {error}", drop_in.display()));
|
|
7496 |
+ |
|
|
7497 |
+ |
assert!(
|
|
7498 |
+ |
text.contains(&format!("AuthorizedKeysFile {BAKED_KEY}")),
|
|
7499 |
+ |
"the installer seeds from {BAKED_KEY}, which the medium's sshd does not read",
|
|
7500 |
+ |
);
|
|
7501 |
+ |
}
|
|
7502 |
+ |
|
|
7503 |
+ |
/// A scratch file holding `contents`, named for the test that wrote it.
|
|
7504 |
+ |
fn scratch(name: &str, contents: &str) -> std::path::PathBuf {
|
|
7505 |
+ |
let dir = std::env::temp_dir().join(format!("alloy-baked-key-{}", std::process::id()));
|
|
7506 |
+ |
std::fs::create_dir_all(&dir).expect("scratch dir");
|
|
7507 |
+ |
let path = dir.join(name);
|
|
7508 |
+ |
std::fs::write(&path, contents).expect("write");
|
|
7509 |
+ |
path
|
|
7510 |
+ |
}
|
|
7511 |
+ |
|
|
7512 |
+ |
// The point of the whole seeding path: a medium minted with a key hands
|
|
7513 |
+ |
// that key to the install it performs, rather than asking the operator to
|
|
7514 |
+ |
// paste back the credential they are already connected with.
|
|
7515 |
+ |
#[test]
|
|
7516 |
+ |
fn a_baked_medium_hands_its_key_to_the_install() {
|
|
7517 |
+ |
let key = format!("ssh-ed25519 {ED25519_BODY} max@fw13");
|
|
7518 |
+ |
let path = scratch("baked", &format!("{key}\n"));
|
|
7519 |
+ |
|
|
7520 |
+ |
assert_eq!(
|
|
7521 |
+ |
baked_pubkey_at(&path.display().to_string()).as_deref(),
|
|
7522 |
+ |
Some(key.as_str()),
|
|
7523 |
+ |
);
|
|
7524 |
+ |
}
|
|
7525 |
+ |
|
|
7526 |
+ |
// An unbaked medium is the ordinary desktop mint, and it is not an error.
|
|
7527 |
+ |
// The field is simply empty and the person at the console types their own.
|
|
7528 |
+ |
#[test]
|
|
7529 |
+ |
fn a_medium_with_no_baked_key_seeds_nothing() {
|
|
7530 |
+ |
assert!(baked_pubkey_at("/nonexistent/authorized_keys").is_none());
|
|
7531 |
+ |
}
|
|
7532 |
+ |
|
|
7533 |
+ |
// Whitespace, comments and a trailing blank line all read as no key. An
|
|
7534 |
+ |
// authorized_keys that is present and holds nothing usable is the same
|
|
7535 |
+ |
// state as one that is absent, and seeding a blank into the field would
|
|
7536 |
+ |
// put an empty line where a key is meant to go.
|
|
7537 |
+ |
#[test]
|
|
7538 |
+ |
fn an_empty_or_commented_baked_file_seeds_nothing() {
|
|
7539 |
+ |
for (label, contents) in [
|
|
7540 |
+ |
("empty", ""),
|
|
7541 |
+ |
("blank lines", "\n\n \n"),
|
|
7542 |
+ |
("comments only", "# minted without a key\n"),
|
|
7543 |
+ |
] {
|
|
7544 |
+ |
let path = scratch("emptyish", contents);
|
|
7545 |
+ |
assert!(
|
|
7546 |
+ |
baked_pubkey_at(&path.display().to_string()).is_none(),
|
|
7547 |
+ |
"{label} seeded a key",
|
|
7548 |
+ |
);
|
|
7549 |
+ |
}
|
|
7550 |
+ |
}
|
|
7551 |
+ |
|
|
7552 |
+ |
// A seeded field is one people scroll past, so what lands in it has to be
|
|
7553 |
+ |
// a key sshd will actually read. Seeding a malformed line would show a key
|
|
7554 |
+ |
// on the summary, plant it, and still produce a machine that refuses the
|
|
7555 |
+ |
// login.
|
|
7556 |
+ |
#[test]
|
|
7557 |
+ |
fn a_baked_file_sshd_would_ignore_seeds_nothing() {
|
|
7558 |
+ |
let path = scratch("bad", "ssh-ed25519 not-base64!!!\n");
|
|
7559 |
+ |
assert!(baked_pubkey_at(&path.display().to_string()).is_none());
|
|
7560 |
+ |
}
|
|
7561 |
+ |
|
|
7562 |
+ |
// The first line only. The identity step writes exactly one key, and a
|
|
7563 |
+ |
// field the user cannot read to the end of is not a field they can check.
|
|
7564 |
+ |
#[test]
|
|
7565 |
+ |
fn only_the_first_baked_key_is_taken() {
|
|
7566 |
+ |
let first = format!("ssh-ed25519 {ED25519_BODY} first");
|
|
7567 |
+ |
let second = format!("ssh-ed25519 {ED25519_BODY} second");
|
|
7568 |
+ |
let path = scratch("two", &format!("{first}\n{second}\n"));
|
|
7569 |
+ |
|
|
7570 |
+ |
assert_eq!(
|
|
7571 |
+ |
baked_pubkey_at(&path.display().to_string()).as_deref(),
|
|
7572 |
+ |
Some(first.as_str()),
|
|
7573 |
+ |
);
|
|
7574 |
+ |
}
|
|
7575 |
+ |
|
| 7349 |
7576 |
|
// The failure this exists for. `id_ed25519` and `id_ed25519.pub` differ by
|
| 7350 |
7577 |
|
// four characters and the wrong one is the first shell completion, so a
|
| 7351 |
7578 |
|
// private key pasted onto a screen in front of whoever is standing there is a
|