| 24 |
24 |
|
//! mountpoint instead would write a hostname where nothing reads it and boot a
|
| 25 |
25 |
|
//! system with none of the answers applied and no error to say why.
|
| 26 |
26 |
|
//!
|
| 27 |
|
- |
//! **Nothing here has run against real bootc.** The shape follows upstream's
|
|
27 |
+ |
//! The configure step has now been run against a real target, and two of its
|
|
28 |
+ |
//! commands did not survive contact. `chpasswd --root` hashes through PAM, and
|
|
29 |
+ |
//! the host's PAM cannot service the target's configuration ("pam_chauthtok()
|
|
30 |
+ |
//! failed, error: Module is unknown"), so the password is hashed separately and
|
|
31 |
+ |
//! handed over with `--encrypted`. `useradd --create-home` resolves the home
|
|
32 |
+ |
//! against `--root`, which is the deployment, whose `var` the stateroot's var
|
|
33 |
+ |
//! is mounted over at boot; the home is made in the stateroot var instead. Both
|
|
34 |
+ |
//! are documented where they happen.
|
|
35 |
+ |
//!
|
|
36 |
+ |
//! **The rest has not run against real bootc.** The shape follows upstream's
|
| 28 |
37 |
|
//! documentation, and the pieces that are guesses rather than quotes are the
|
| 29 |
38 |
|
//! arguments to `bootc install finalize`, which that page does not document.
|
| 30 |
39 |
|
//!
|
| 39 |
48 |
|
use ratatui::text::{Line, Span};
|
| 40 |
49 |
|
use ratatui::widgets::Paragraph;
|
| 41 |
50 |
|
use serde::Deserialize;
|
|
51 |
+ |
use sha_crypt::{PasswordHasher, ShaCrypt};
|
| 42 |
52 |
|
|
| 43 |
53 |
|
use alloy_tui::{Cursor, FocusRing};
|
| 44 |
54 |
|
|
| 242 |
252 |
|
.arg("--print-current-dir")
|
| 243 |
253 |
|
}
|
| 244 |
254 |
|
|
| 245 |
|
- |
/// The commands an install runs, in order.
|
| 246 |
|
- |
///
|
| 247 |
|
- |
/// Built as a list rather than executed inline for the reason the backends
|
| 248 |
|
- |
/// return [`Invocation`]s: it keeps the sequence testable on a machine with
|
| 249 |
|
- |
/// neither bootc nor a spare disk, and it lets the summary step show the user
|
| 250 |
|
- |
/// exactly what is about to run. The console's promise is that every action
|
| 251 |
|
- |
/// shows its invocation; here the invocations are shown *before* the action,
|
| 252 |
|
- |
/// which is the strongest form of it the installer can offer.
|
| 253 |
|
- |
///
|
| 254 |
|
- |
/// `chpasswd` takes the password on stdin rather than as an argument. See
|
| 255 |
|
- |
/// [`Secret`] for why argv is not an option.
|
| 256 |
255 |
|
/// DPS partition type GUID for the root filesystem on this architecture.
|
| 257 |
256 |
|
///
|
| 258 |
257 |
|
/// From bootc 1.11 the default layout follows the Discoverable Partitions
|
| 319 |
318 |
|
})
|
| 320 |
319 |
|
}
|
| 321 |
320 |
|
|
|
321 |
+ |
/// Where home directories live on the installed system.
|
|
322 |
+ |
///
|
|
323 |
+ |
/// Fedora bootc puts them under `/var/home`; `/home` is a symlink to it. The
|
|
324 |
+ |
/// path recorded in `/etc/passwd` is written out rather than left to useradd's
|
|
325 |
+ |
/// default so that the entry names the real location instead of the symlink.
|
|
326 |
+ |
const HOME_PARENT: &str = "/var/home";
|
|
327 |
+ |
|
|
328 |
+ |
/// Mode for a new home directory, matching Fedora's `HOME_MODE`.
|
|
329 |
+ |
const HOME_MODE: &str = "700";
|
|
330 |
+ |
|
|
331 |
+ |
/// Where the stateroot's `/var` is, given the deployment directory.
|
|
332 |
+ |
///
|
|
333 |
+ |
/// **A deployment's own `var` is not the system's `/var`.** It is an empty
|
|
334 |
+ |
/// directory that ostree bind-mounts the stateroot's var over at boot, so
|
|
335 |
+ |
/// anything written into `<deployment>/var` is invisible to the installed
|
|
336 |
+ |
/// system. That is the second half of the same mistake [`deployment_dir`]
|
|
337 |
+ |
/// documents: `--root <deployment>` is right for `/etc` and wrong for `/var`.
|
|
338 |
+ |
///
|
|
339 |
+ |
/// The real one is two levels up, at
|
|
340 |
+ |
/// `<mount>/ostree/deploy/<stateroot>/var` — a sibling of the `deploy`
|
|
341 |
+ |
/// directory the checksum lives in.
|
|
342 |
+ |
fn stateroot_var(deployment: &str) -> Result<String, String> {
|
|
343 |
+ |
let stateroot = deployment
|
|
344 |
+ |
.trim_end_matches('/')
|
|
345 |
+ |
.rsplit_once('/')
|
|
346 |
+ |
.and_then(|(parent, _checksum)| parent.strip_suffix("/deploy"))
|
|
347 |
+ |
.ok_or_else(|| format!("not an ostree deployment directory: {deployment}"))?;
|
|
348 |
+ |
Ok(format!("{stateroot}/var"))
|
|
349 |
+ |
}
|
|
350 |
+ |
|
|
351 |
+ |
/// Pull a user's numeric ids out of an `/etc/passwd`, as `uid:gid`.
|
|
352 |
+ |
///
|
|
353 |
+ |
/// Numeric because the only `chown` available is the host's, and a name would
|
|
354 |
+ |
/// be resolved against the host's passwd rather than the target's. On a live
|
|
355 |
+ |
/// installer those are different files, and the wrong one gives an install
|
|
356 |
+ |
/// whose home directory belongs to whoever happens to hold that name here.
|
|
357 |
+ |
///
|
|
358 |
+ |
/// Formatted as `uid:gid` because that is what `chown` takes, and building the
|
|
359 |
+ |
/// pair anywhere else would mean two places knowing the shape.
|
|
360 |
+ |
fn passwd_ids(listing: &str, username: &str) -> Result<String, String> {
|
|
361 |
+ |
listing
|
|
362 |
+ |
.lines()
|
|
363 |
+ |
.filter_map(|line| {
|
|
364 |
+ |
let mut fields = line.split(':');
|
|
365 |
+ |
(fields.next()? == username).then_some(())?;
|
|
366 |
+ |
let (_password, uid, gid) = (fields.next()?, fields.next()?, fields.next()?);
|
|
367 |
+ |
Some(format!("{uid}:{gid}"))
|
|
368 |
+ |
})
|
|
369 |
+ |
.next()
|
|
370 |
+ |
.ok_or_else(|| format!("useradd left no passwd entry for {username}"))
|
|
371 |
+ |
}
|
|
372 |
+ |
|
|
373 |
+ |
/// Hash a password into the form `/etc/shadow` stores.
|
|
374 |
+ |
///
|
|
375 |
+ |
/// **`chpasswd --root` cannot do this itself.** It hashes through PAM, and PAM
|
|
376 |
+ |
/// on a live installer is the host's: it fails with "pam_chauthtok() failed,
|
|
377 |
+ |
/// error: Module is unknown", because the modules the target's configuration
|
|
378 |
+ |
/// names are not installed on the machine doing the installing. `--encrypted`
|
|
379 |
+ |
/// takes an already-hashed password and skips PAM entirely, which is the only
|
|
380 |
+ |
/// part of chpasswd that was ever going to work across that boundary.
|
|
381 |
+ |
///
|
|
382 |
+ |
/// Hashed in-process rather than by shelling out to `openssl passwd -6`. The
|
|
383 |
+ |
/// installer would otherwise depend on a binary the image is not obliged to
|
|
384 |
+ |
/// carry, and would find out it was missing at the one moment there is no
|
|
385 |
+ |
/// recovering from — target deployed, disk rewritten, no account. It also keeps
|
|
386 |
+ |
/// the password from crossing a process boundary at all, which is a stronger
|
|
387 |
+ |
/// version of what [`Secret`] is for than piping it to a child.
|
|
388 |
+ |
///
|
|
389 |
+ |
/// SHA-512 (`$6$`) because that is what Fedora's shadow-utils writes and what
|
|
390 |
+ |
/// glibc on the target will verify against.
|
|
391 |
+ |
///
|
|
392 |
+ |
/// The salt is generated here rather than left to `hash_password`, and the
|
|
393 |
+ |
/// length is the whole reason. **crypt(3) takes at most 16 salt characters and
|
|
394 |
+ |
/// silently ignores the rest**, while sha-crypt's own salt is 22. glibc hashes
|
|
395 |
+ |
/// the same digest either way — it truncates before hashing — but it returns
|
|
396 |
+ |
/// the string it used, with the salt cut down. PAM authenticates by hashing the
|
|
397 |
+ |
/// typed password and comparing the whole string against the stored one, so a
|
|
398 |
+ |
/// 22-character salt gives an entry that no correct password ever matches: an
|
|
399 |
+ |
/// account locked out from the moment it is created, on a machine that has
|
|
400 |
+ |
/// already rebooted into it. Verified against glibc's `crypt`, which is what
|
|
401 |
+ |
/// does the comparing on the installed system.
|
|
402 |
+ |
///
|
|
403 |
+ |
/// [`SALT_BYTES`] is chosen so the encoded salt lands exactly on that limit.
|
|
404 |
+ |
fn hash_password(password: &str) -> Result<String, String> {
|
|
405 |
+ |
let mut salt = [0u8; SALT_BYTES];
|
|
406 |
+ |
// Fails only if the system RNG does. Reported as itself rather than as a
|
|
407 |
+ |
// password that mysteriously did not take.
|
|
408 |
+ |
getrandom::fill(&mut salt).map_err(|err| format!("could not generate a salt: {err}"))?;
|
|
409 |
+ |
|
|
410 |
+ |
ShaCrypt::default()
|
|
411 |
+ |
.hash_password_with_salt(password.as_bytes(), &salt)
|
|
412 |
+ |
.map(|hash| hash.as_str().to_string())
|
|
413 |
+ |
.map_err(|err| format!("could not hash the password: {err}"))
|
|
414 |
+ |
}
|
|
415 |
+ |
|
|
416 |
+ |
/// Longest salt crypt(3) reads. Anything past this is discarded.
|
|
417 |
+ |
const SALT_MAX: usize = 16;
|
|
418 |
+ |
|
|
419 |
+ |
/// Raw salt bytes, chosen for what they encode to.
|
|
420 |
+ |
///
|
|
421 |
+ |
/// The salt is base64'd before it reaches the hash, four characters per three
|
|
422 |
+ |
/// bytes, so this is [`SALT_MAX`] worked backwards: the most randomness that
|
|
423 |
+ |
/// fits in a salt crypt(3) will read whole. See [`hash_password`] for what
|
|
424 |
+ |
/// overshooting costs.
|
|
425 |
+ |
const SALT_BYTES: usize = SALT_MAX / 4 * 3;
|
|
426 |
+ |
|
| 322 |
427 |
|
/// The commands that configure an already-deployed system.
|
| 323 |
428 |
|
///
|
| 324 |
429 |
|
/// `root` is the ostree deployment directory, not the mountpoint. See
|
| 325 |
|
- |
/// [`deployment_dir`] for why that distinction is the whole ballgame.
|
| 326 |
|
- |
fn configure_plan(hostname: &str, username: &str, password: &str, root: &str) -> Vec<Invocation> {
|
| 327 |
|
- |
vec![
|
| 328 |
|
- |
Invocation::new("systemd-firstboot")
|
| 329 |
|
- |
.arg(format!("--root={root}"))
|
| 330 |
|
- |
.arg(format!("--hostname={hostname}"))
|
| 331 |
|
- |
// Without --force firstboot skips any setting already present in
|
| 332 |
|
- |
// the image, and etc/hostname ships with "alloy" in it. The user's
|
| 333 |
|
- |
// answer would be silently discarded.
|
| 334 |
|
- |
.arg("--force"),
|
| 335 |
|
- |
// -m creates the home directory; sysusers-style entries do not, which
|
| 336 |
|
- |
// is why this is useradd. wheel is what sudo grants on Fedora, and an
|
|
430 |
+ |
/// [`deployment_dir`] for why that distinction is the whole ballgame, and
|
|
431 |
+ |
/// [`stateroot_var`] for the half of it that `--root` does not cover.
|
|
432 |
+ |
///
|
|
433 |
+ |
/// [`Stage`]s rather than plain [`Invocation`]s because two of these depend on
|
|
434 |
+ |
/// values that only exist once the ones before them have run: the account's
|
|
435 |
+ |
/// numeric ids, and the password hash.
|
|
436 |
+ |
fn configure_plan(
|
|
437 |
+ |
hostname: &str,
|
|
438 |
+ |
username: &str,
|
|
439 |
+ |
password: &str,
|
|
440 |
+ |
root: &str,
|
|
441 |
+ |
) -> Result<Vec<Stage>, String> {
|
|
442 |
+ |
let var = stateroot_var(root)?;
|
|
443 |
+ |
// Where the home is now, through the mounted target...
|
|
444 |
+ |
let home = format!("{var}/home/{username}");
|
|
445 |
+ |
// ...and where it will be once the system it belongs to is running.
|
|
446 |
+ |
let installed_home = format!("{HOME_PARENT}/{username}");
|
|
447 |
+ |
|
|
448 |
+ |
let hash = hash_password(password)?;
|
|
449 |
+ |
|
|
450 |
+ |
let ids_root = root.to_string();
|
|
451 |
+ |
let ids_user = username.to_string();
|
|
452 |
+ |
let ids_home = home.clone();
|
|
453 |
+ |
|
|
454 |
+ |
Ok(vec![
|
|
455 |
+ |
Stage::Run(
|
|
456 |
+ |
Invocation::new("systemd-firstboot")
|
|
457 |
+ |
.arg(format!("--root={root}"))
|
|
458 |
+ |
.arg(format!("--hostname={hostname}"))
|
|
459 |
+ |
// Without --force firstboot skips any setting already present in
|
|
460 |
+ |
// the image, and etc/hostname ships with "alloy" in it. The user's
|
|
461 |
+ |
// answer would be silently discarded.
|
|
462 |
+ |
.arg("--force"),
|
|
463 |
+ |
),
|
|
464 |
+ |
// useradd rather than a sysusers entry because this account needs a home
|
|
465 |
+ |
// and a supplementary group. wheel is what sudo grants on Fedora, and an
|
| 337 |
466 |
|
// install whose only account cannot escalate is one with no way to
|
| 338 |
467 |
|
// administer itself.
|
| 339 |
|
- |
Invocation::new("useradd")
|
| 340 |
|
- |
.args(["--root", root, "--create-home", "--groups", "wheel"])
|
| 341 |
|
- |
.arg(username),
|
| 342 |
|
- |
// `chpasswd` reads `user:password` lines, so the username is part of the
|
| 343 |
|
- |
// secret rather than an argument. Built here so exactly one place knows
|
|
468 |
+ |
//
|
|
469 |
+ |
// --no-create-home despite the account needing one: --create-home
|
|
470 |
+ |
// resolves the path against --root, which puts the directory in the
|
|
471 |
+ |
// deployment's own var, where nothing will ever look for it. useradd
|
|
472 |
+ |
// says as much — "chown on '/var/home/<user>' failed" — because the
|
|
473 |
+ |
// stateroot var ships with cache, lib, log, run and tmp and no home at
|
|
474 |
+ |
// all. The directory is made below, in the var that is real.
|
|
475 |
+ |
Stage::Run(
|
|
476 |
+ |
Invocation::new("useradd")
|
|
477 |
+ |
.args(["--root", root, "--no-create-home"])
|
|
478 |
+ |
.args(["--home-dir", &installed_home])
|
|
479 |
+ |
.args(["--groups", "wheel"])
|
|
480 |
+ |
.arg(username),
|
|
481 |
+ |
),
|
|
482 |
+ |
// -p because /var/home does not exist yet either.
|
|
483 |
+ |
Stage::Run(Invocation::new("mkdir").args(["-p", &home])),
|
|
484 |
+ |
// The dotfiles --create-home would have copied. Without them an
|
|
485 |
+ |
// interactive shell never sources /etc/bashrc.
|
|
486 |
+ |
Stage::Run(
|
|
487 |
+ |
Invocation::new("cp")
|
|
488 |
+ |
.arg("-a")
|
|
489 |
+ |
.arg(format!("{root}/etc/skel/."))
|
|
490 |
+ |
.arg(&home),
|
|
491 |
+ |
),
|
|
492 |
+ |
// First discovery: which uid and gid useradd picked. Reading the file
|
|
493 |
+ |
// rather than asking getent, because getent answers about this machine.
|
|
494 |
+ |
Stage::Resolve {
|
|
495 |
+ |
invocation: Invocation::new("cat").arg(format!("{ids_root}/etc/passwd")),
|
|
496 |
+ |
then: Box::new(move |listing| {
|
|
497 |
+ |
let ids = passwd_ids(listing, &ids_user)?;
|
|
498 |
+ |
Ok(vec![
|
|
499 |
+ |
Stage::Run(Invocation::new("chown").args(["-R", &ids, &ids_home])),
|
|
500 |
+ |
Stage::Run(Invocation::new("chmod").args([HOME_MODE, &ids_home])),
|
|
501 |
+ |
])
|
|
502 |
+ |
}),
|
|
503 |
+ |
},
|
|
504 |
+ |
// `chpasswd` reads `user:hash` lines, so the username is part of the
|
|
505 |
+ |
// input rather than an argument. Built here so exactly one place knows
|
| 344 |
506 |
|
// the wire format, and with a trailing newline because chpasswd parses
|
| 345 |
507 |
|
// lines and a final one without it is silently ignored by some builds.
|
| 346 |
|
- |
Invocation::new("chpasswd")
|
| 347 |
|
- |
.args(["--root", root])
|
| 348 |
|
- |
.stdin(Secret::new(format!("{username}:{password}\n"))),
|
|
508 |
+ |
Stage::Run(
|
|
509 |
+ |
Invocation::new("chpasswd")
|
|
510 |
+ |
.args(["--encrypted", "--root", root])
|
|
511 |
+ |
.stdin(Secret::new(format!("{username}:{hash}\n"))),
|
|
512 |
+ |
),
|
| 349 |
513 |
|
// Upstream: "optional, but recommended to run as the penultimate step
|
| 350 |
514 |
|
// before unmounting the target filesystem. This command will perform
|
| 351 |
515 |
|
// some basic sanity checks and may also perform fixups on the target
|
| 352 |
516 |
|
// root." Its arguments are not documented on that page; the mountpoint
|
| 353 |
517 |
|
// is passed positionally, which is the one thing it plausibly wants and
|
| 354 |
518 |
|
// is flagged in the module header as unverified.
|
| 355 |
|
- |
Invocation::new("bootc")
|
| 356 |
|
- |
.args(["install", "finalize"])
|
| 357 |
|
- |
.arg(TARGET_MOUNT),
|
|
519 |
+ |
Stage::Run(
|
|
520 |
+ |
Invocation::new("bootc")
|
|
521 |
+ |
.args(["install", "finalize"])
|
|
522 |
+ |
.arg(TARGET_MOUNT),
|
|
523 |
+ |
),
|
| 358 |
524 |
|
// Leaving the target mounted would strand the filesystem dirty across
|
| 359 |
525 |
|
// the reboot the user is about to perform.
|
| 360 |
|
- |
Invocation::new("umount").arg(TARGET_MOUNT),
|
| 361 |
|
- |
]
|
|
526 |
+ |
Stage::Run(Invocation::new("umount").arg(TARGET_MOUNT)),
|
|
527 |
+ |
])
|
| 362 |
528 |
|
}
|
| 363 |
529 |
|
|
| 364 |
530 |
|
/// The whole install, as stages.
|
| 398 |
564 |
|
if deployment.is_empty() {
|
| 399 |
565 |
|
return Err("ostree reported no current deployment".into());
|
| 400 |
566 |
|
}
|
| 401 |
|
- |
Ok(configure_plan(&hostname, &username, &password, deployment)
|
| 402 |
|
- |
.into_iter()
|
| 403 |
|
- |
.map(Stage::Run)
|
| 404 |
|
- |
.collect())
|
|
567 |
+ |
configure_plan(&hostname, &username, &password, deployment)
|
| 405 |
568 |
|
}),
|
| 406 |
569 |
|
},
|
| 407 |
570 |
|
])
|
| 1875 |
2038 |
|
for stage in view.plan() {
|
| 1876 |
2039 |
|
assert!(!stage.display().contains("hunter2"), "{}", stage.display());
|
| 1877 |
2040 |
|
}
|
| 1878 |
|
- |
for invocation in configure_plan("alloy", "max", "hunter2", "/deploy") {
|
| 1879 |
|
- |
let shown = invocation.display();
|
|
2041 |
+ |
for shown in configured() {
|
| 1880 |
2042 |
|
assert!(!shown.contains("hunter2"), "password on screen: {shown}");
|
| 1881 |
2043 |
|
}
|
| 1882 |
2044 |
|
}
|
| 1883 |
2045 |
|
|
|
2046 |
+ |
/// A deployment directory shaped the way ostree names them.
|
|
2047 |
+ |
const DEPLOYMENT: &str = "/mnt/alloy-target/ostree/deploy/default/deploy/abc123.0";
|
|
2048 |
+ |
|
| 1884 |
2049 |
|
/// The configure half, against a deployment directory as discovered.
|
| 1885 |
2050 |
|
fn configured() -> Vec<String> {
|
| 1886 |
|
- |
configure_plan(
|
| 1887 |
|
- |
"workshop",
|
| 1888 |
|
- |
"max",
|
| 1889 |
|
- |
"hunter2",
|
| 1890 |
|
- |
"/mnt/alloy-target/ostree/deploy/x/deploy/abc",
|
| 1891 |
|
- |
)
|
| 1892 |
|
- |
.iter()
|
| 1893 |
|
- |
.map(Invocation::display)
|
| 1894 |
|
- |
.collect()
|
|
2051 |
+ |
configure_plan("workshop", "max", "hunter2", DEPLOYMENT)
|
|
2052 |
+ |
.expect("a well-formed deployment path")
|
|
2053 |
+ |
.iter()
|
|
2054 |
+ |
.map(Stage::display)
|
|
2055 |
+ |
.collect()
|
| 1895 |
2056 |
|
}
|
| 1896 |
2057 |
|
|
| 1897 |
2058 |
|
// --force matters: etc/hostname ships with "alloy" already in it, and
|
| 1911 |
2072 |
|
#[test]
|
| 1912 |
2073 |
|
fn configuration_targets_the_deployment_not_the_mountpoint() {
|
| 1913 |
2074 |
|
for line in configured() {
|
|
2075 |
+ |
// finalize and umount take the mountpoint by definition.
|
| 1914 |
2076 |
|
if line.starts_with("bootc") || line.starts_with("umount") {
|
| 1915 |
2077 |
|
continue;
|
| 1916 |
2078 |
|
}
|
| 1922 |
2084 |
|
}
|
| 1923 |
2085 |
|
|
| 1924 |
2086 |
|
// An account that cannot escalate leaves an install with no way to
|
| 1925 |
|
- |
// administer itself, and --create-home is what makes the home exist.
|
|
2087 |
+ |
// administer itself.
|
| 1926 |
2088 |
|
#[test]
|
| 1927 |
|
- |
fn the_account_gets_a_home_and_a_way_to_sudo() {
|
|
2089 |
+ |
fn the_account_can_sudo() {
|
| 1928 |
2090 |
|
let useradd = configured().remove(1);
|
| 1929 |
2091 |
|
|
| 1930 |
|
- |
assert!(useradd.contains("--create-home"), "{useradd}");
|
| 1931 |
2092 |
|
assert!(useradd.contains("wheel"), "{useradd}");
|
| 1932 |
2093 |
|
assert!(useradd.ends_with("max"), "{useradd}");
|
| 1933 |
2094 |
|
}
|
| 1934 |
2095 |
|
|
|
2096 |
+ |
// --create-home resolves its path against --root, which is the deployment,
|
|
2097 |
+ |
// whose var is an empty directory the stateroot's var is mounted over at
|
|
2098 |
+ |
// boot. A home made there is a home the installed system never sees.
|
|
2099 |
+ |
#[test]
|
|
2100 |
+ |
fn the_home_is_made_in_the_stateroot_var_not_the_deployments() {
|
|
2101 |
+ |
let lines = configured();
|
|
2102 |
+ |
let useradd = &lines[1];
|
|
2103 |
+ |
let mkdir = &lines[2];
|
|
2104 |
+ |
|
|
2105 |
+ |
assert!(useradd.contains("--no-create-home"), "{useradd}");
|
|
2106 |
+ |
assert!(useradd.contains("--home-dir /var/home/max"), "{useradd}");
|
|
2107 |
+ |
assert_eq!(
|
|
2108 |
+ |
mkdir,
|
|
2109 |
+ |
"mkdir -p /mnt/alloy-target/ostree/deploy/default/var/home/max"
|
|
2110 |
+ |
);
|
|
2111 |
+ |
}
|
|
2112 |
+ |
|
|
2113 |
+ |
// The password reaches chpasswd already hashed: `chpasswd --root` hashes
|
|
2114 |
+ |
// through the host's PAM, and the host's PAM cannot service the target
|
|
2115 |
+ |
// ("Module is unknown"). --encrypted skips PAM entirely.
|
|
2116 |
+ |
#[test]
|
|
2117 |
+ |
fn the_password_is_handed_over_already_hashed() {
|
|
2118 |
+ |
let chpasswd = configured()
|
|
2119 |
+ |
.into_iter()
|
|
2120 |
+ |
.find(|line| line.starts_with("chpasswd"))
|
|
2121 |
+ |
.expect("the plan sets a password");
|
|
2122 |
+ |
|
|
2123 |
+ |
assert!(chpasswd.contains("--encrypted"), "{chpasswd}");
|
|
2124 |
+ |
// The user:hash line goes in on stdin, never in argv.
|
|
2125 |
+ |
assert!(chpasswd.contains("# input withheld"), "{chpasswd}");
|
|
2126 |
+ |
}
|
|
2127 |
+ |
|
|
2128 |
+ |
// Nothing shells out to hash. An installer that depends on a binary the
|
|
2129 |
+ |
// image is not obliged to carry finds out it is missing at the one moment
|
|
2130 |
+ |
// there is no recovering from: target deployed, no account.
|
|
2131 |
+ |
#[test]
|
|
2132 |
+ |
fn hashing_needs_no_external_program() {
|
|
2133 |
+ |
assert!(
|
|
2134 |
+ |
!configured().iter().any(|line| line.starts_with("openssl")),
|
|
2135 |
+ |
"the plan shells out to hash"
|
|
2136 |
+ |
);
|
|
2137 |
+ |
}
|
|
2138 |
+ |
|
|
2139 |
+ |
// $6$ is SHA-512, which is what Fedora's shadow-utils writes and what glibc
|
|
2140 |
+ |
// on the target verifies against. A hash in any other format is one nobody
|
|
2141 |
+ |
// can log in with.
|
|
2142 |
+ |
#[test]
|
|
2143 |
+ |
fn the_hash_is_sha_512_crypt_and_matches_the_password() {
|
|
2144 |
+ |
let hash = hash_password("hunter2").expect("hashing succeeds");
|
|
2145 |
+ |
|
|
2146 |
+ |
assert!(hash.starts_with("$6$"), "{hash}");
|
|
2147 |
+ |
assert!(
|
|
2148 |
+ |
sha_crypt::PasswordVerifier::<str>::verify_password(
|
|
2149 |
+ |
&ShaCrypt::default(),
|
|
2150 |
+ |
b"hunter2",
|
|
2151 |
+ |
hash.as_str()
|
|
2152 |
+ |
)
|
|
2153 |
+ |
.is_ok(),
|
|
2154 |
+ |
"the hash does not verify against its own password: {hash}"
|
|
2155 |
+ |
);
|
|
2156 |
+ |
}
|
|
2157 |
+ |
|
|
2158 |
+ |
// crypt(3) reads at most 16 salt characters and ignores the rest, but it
|
|
2159 |
+ |
// returns the string it used — with the salt truncated. PAM compares that
|
|
2160 |
+ |
// whole string against the stored one, so a longer salt is an account no
|
|
2161 |
+ |
// correct password can log into, discovered only after the reboot.
|
|
2162 |
+ |
// sha-crypt's own salt is 22 characters, which is why this is not left to
|
|
2163 |
+ |
// its default.
|
|
2164 |
+ |
#[test]
|
|
2165 |
+ |
fn the_salt_is_no_longer_than_crypt_will_read() {
|
|
2166 |
+ |
let hash = hash_password("hunter2").expect("hashing succeeds");
|
|
2167 |
+ |
let salt = hash.split('$').nth(3).expect("$6$rounds=N$salt$digest");
|
|
2168 |
+ |
|
|
2169 |
+ |
assert_eq!(
|
|
2170 |
+ |
salt.len(),
|
|
2171 |
+ |
SALT_MAX,
|
|
2172 |
+ |
"a salt crypt(3) would truncate: {hash}"
|
|
2173 |
+ |
);
|
|
2174 |
+ |
}
|
|
2175 |
+ |
|
|
2176 |
+ |
// A salt per install, so two machines with the same password do not get the
|
|
2177 |
+ |
// same shadow entry.
|
|
2178 |
+ |
#[test]
|
|
2179 |
+ |
fn every_hash_gets_its_own_salt() {
|
|
2180 |
+ |
assert_ne!(
|
|
2181 |
+ |
hash_password("hunter2").unwrap(),
|
|
2182 |
+ |
hash_password("hunter2").unwrap()
|
|
2183 |
+ |
);
|
|
2184 |
+ |
}
|
|
2185 |
+ |
|
|
2186 |
+ |
// The stateroot var is a sibling of the `deploy` directory holding the
|
|
2187 |
+ |
// checksummed deployment, not a child of the deployment.
|
|
2188 |
+ |
#[test]
|
|
2189 |
+ |
fn the_stateroot_var_is_two_levels_above_the_deployment() {
|
|
2190 |
+ |
assert_eq!(
|
|
2191 |
+ |
stateroot_var(DEPLOYMENT).unwrap(),
|
|
2192 |
+ |
"/mnt/alloy-target/ostree/deploy/default/var"
|
|
2193 |
+ |
);
|
|
2194 |
+ |
}
|
|
2195 |
+ |
|
|
2196 |
+ |
// A path that is not shaped like a deployment means ostree printed
|
|
2197 |
+ |
// something unexpected, which is worth failing on rather than deriving a
|
|
2198 |
+ |
// var directory from.
|
|
2199 |
+ |
#[test]
|
|
2200 |
+ |
fn a_path_that_is_not_a_deployment_is_a_named_failure() {
|
|
2201 |
+ |
let error = stateroot_var("/mnt/alloy-target").unwrap_err();
|
|
2202 |
+ |
assert!(error.contains("not an ostree deployment"), "{error}");
|
|
2203 |
+ |
}
|
|
2204 |
+ |
|
|
2205 |
+ |
// chown takes numbers because the only chown available is the host's, and a
|
|
2206 |
+ |
// name would resolve against the host's passwd rather than the target's.
|
|
2207 |
+ |
#[test]
|
|
2208 |
+ |
fn the_accounts_ids_come_from_the_targets_passwd() {
|
|
2209 |
+ |
let passwd = "root:x:0:0:root:/root:/bin/bash\n\
|
|
2210 |
+ |
max:x:1000:1001:,,,:/var/home/max:/bin/bash\n";
|
|
2211 |
+ |
assert_eq!(passwd_ids(passwd, "max").unwrap(), "1000:1001");
|
|
2212 |
+ |
}
|
|
2213 |
+ |
|
|
2214 |
+ |
// A prefix of another name is not that name.
|
|
2215 |
+ |
#[test]
|
|
2216 |
+ |
fn a_passwd_lookup_matches_the_whole_username() {
|
|
2217 |
+ |
let passwd = "maxwell:x:1000:1000::/var/home/maxwell:/bin/bash\n";
|
|
2218 |
+ |
assert!(passwd_ids(passwd, "max").is_err());
|
|
2219 |
+ |
}
|
|
2220 |
+ |
|
|
2221 |
+ |
#[test]
|
|
2222 |
+ |
fn a_missing_passwd_entry_is_a_named_failure() {
|
|
2223 |
+ |
let error = passwd_ids("root:x:0:0:root:/root:/bin/bash", "max").unwrap_err();
|
|
2224 |
+ |
assert!(error.contains("no passwd entry"), "{error}");
|
|
2225 |
+ |
}
|
|
2226 |
+ |
|
| 1935 |
2227 |
|
// Upstream tells installers that make changes to run finalize before
|
| 1936 |
2228 |
|
// unmounting, and leaving the target mounted would strand it dirty across
|
| 1937 |
2229 |
|
// the reboot.
|