Watch
0
0
Fork
You've already forked hyperhive
0

check the container root before each start

This commit is contained in:
atlas 2026-10-28 20:57:27 +01:00 • committed by mara
commit a44a7256a7

454
hive-priv/src/prestart.rs Normal file
View file

@ -0,0 +1,454 @@
//! The pre-start check of a container's root, run by systemd as root.
//!
//! nixpkgs' `container@.service` start script runs as host root against the
//! container's own root, which is persistent and writable by the container.
//! `mkdir -p`, `chmod`, `touch` and `cp --remove-destination` there follow a
//! link the container planted at any of the names the script touches. The
//! per-container drop-in ([`crate::container_config`]) puts
//! `hive-priv check-rootfs <container>` ahead of that script as an
//! `ExecStartPre=`; a non-zero exit fails the start. The container is stopped
//! while this runs, so nothing changes under the walk.
//!
//! Every component is looked at with `symlink_metadata`: a link is reported,
//! never followed. Nothing is deleted or repaired, so whatever is there stays
//! for the operator.
//!
//! The entry point is [`run`]; [`check`] holds the rules.
use std::fs::{FileType, Metadata};
use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result, bail};
use crate::container_config::{container_rootfs, validate_container_system_name};
/// The sub-command the drop-in's `ExecStartPre=` names.
pub(crate) const SUBCOMMAND: &str = "check-rootfs";
/// The per-container nix profile directories on the host.
const PROFILES_ROOT: &str = "/nix/var/nix/profiles/per-container";
/// The nix store: where the profile's `system` link has to end up.
const STORE: &str = "/nix/store";
/// Directories the start script creates or `chmod`s under the root. Each,
/// with all its parents, has to be a real directory when it exists.
const DIRS: [&str; 7] = [
"usr",
"usr/bin",
"etc",
"var",
"var/lib",
"var/lib/private",
"root",
];
/// Files under the root the start script `touch`es or replaces.
///
/// `machine-id` and `resolv.conf` have to be regular files. A link at
/// `machine-id` that dangles makes `touch` create its target, and no guest
/// writes a link there. The script's `cp --remove-destination` replaces a link
/// at `resolv.conf` rather than following it, but the guest keeps a regular
/// file there too (`nix/agent-modules/network.nix`), so a link is not expected.
const REGULAR_ONLY: [&str; 2] = ["etc/machine-id", "etc/resolv.conf"];
/// The one file allowed to be a link as well: the script skips it when
/// `-h` says it is one, and a NixOS guest has `etc/os-release` as a link into
/// `/etc/static` once it has booted.
const OS_RELEASE: &str = "etc/os-release";
/// Handle `hive-priv <subcommand> ...` when the first argument names one.
/// `None` means it is not a sub-command and the daemon starts.
///
/// Prints one line to stderr and returns exit code 1 on a violation, so the
/// reason lands in the journal next to the failed unit.
pub(crate) fn run(args: &[String]) -> Option<i32> {
if args.first().map(String::as_str) != Some(SUBCOMMAND) {
return None;
}
let result = match args {
[_, container] => check_container(container),
_ => Err(anyhow::anyhow!("usage: hive-priv {SUBCOMMAND} <container>")),
};
Some(match result {
Ok(()) => 0,
Err(e) => {
eprintln!("hive-priv {SUBCOMMAND}: refusing to start: {e:#}");
1
}
})
}
/// [`check`] on the host paths of `container`.
fn check_container(container: &str) -> Result<()> {
validate_container_system_name(container)?;
check(
&container_rootfs(container),
&Path::new(PROFILES_ROOT).join(container).join("system"),
Path::new(STORE),
)
}
/// Check the paths the start script touches as root, returning an error that
/// names the first path to hold something it must not.
///
/// `root` is the container's root, `system` its profile link
/// (`<profiles>/<container>/system`) and `store` the nix store.
///
/// A path that does not exist passes: a first start has nothing yet, and the
/// script creates it.
fn check(root: &Path, system: &Path, store: &Path) -> Result<()> {
for dir in DIRS {
check_dir(root, dir)?;
}
for file in REGULAR_ONLY {
check_file(root, file, false)?;
}
check_file(root, OS_RELEASE, true)?;
check_system(system, store)
}
/// `symlink_metadata`, with `None` for a path that does not exist. The one
/// place the walk touches the file system, so nothing in it can follow a link.
fn lstat(path: &Path) -> Result<Option<Metadata>> {
match std::fs::symlink_metadata(path) {
Ok(meta) => Ok(Some(meta)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).with_context(|| format!("stat {}", path.display())),
}
}
/// What a file type is, for the error line.
fn kind(ty: FileType) -> &'static str {
if ty.is_symlink() {
"a symlink"
} else if ty.is_dir() {
"a directory"
} else if ty.is_file() {
"a regular file"
} else {
"not a regular file or directory"
}
}
/// `rel` under `root` and every parent below `root` must be a real directory
/// or absent. A link or a file in the way is an error.
fn check_dir(root: &Path, rel: &str) -> Result<()> {
let mut path = root.to_path_buf();
for part in rel.split('/') {
path.push(part);
let Some(meta) = lstat(&path)? else {
return Ok(());
};
if !meta.file_type().is_dir() {
bail!(
"{} is {}, not a directory",
path.display(),
kind(meta.file_type())
);
}
}
Ok(())
}
/// `rel` under `root` must be absent or a regular file, or a link when
/// `link_ok`. Its parents are covered by [`check_dir`] on `etc`.
fn check_file(root: &Path, rel: &str, link_ok: bool) -> Result<()> {
let path = root.join(rel);
let Some(meta) = lstat(&path)? else {
return Ok(());
};
let ty = meta.file_type();
if ty.is_file() || (link_ok && ty.is_symlink()) {
return Ok(());
}
bail!("{} is {}, not a regular file", path.display(), kind(ty))
}
/// The profile's `system` must be absent, or a link that resolves to a
/// directory in `store` whose `system` is a regular file (the script reads it
/// as root). The link is followed on purpose here: nix points it at a
/// generation link, which points at the store.
fn check_system(system: &Path, store: &Path) -> Result<()> {
let Some(meta) = lstat(system)? else {
return Ok(());
};
if !meta.file_type().is_symlink() {
bail!(
"{} is {}, not a symlink into {}",
system.display(),
kind(meta.file_type()),
store.display()
);
}
let target: PathBuf = std::fs::canonicalize(system)
.with_context(|| format!("{} does not resolve", system.display()))?;
let store =
std::fs::canonicalize(store).with_context(|| format!("resolve {}", store.display()))?;
if !target.starts_with(&store) || target == store {
bail!(
"{} resolves to {}, outside {}",
system.display(),
target.display(),
store.display()
);
}
let file = target.join("system");
match lstat(&file)? {
Some(meta) if meta.file_type().is_file() => Ok(()),
Some(meta) => bail!(
"{} is {}, not a regular file",
file.display(),
kind(meta.file_type())
),
None => bail!("{} is missing", file.display()),
}
}
#[cfg(test)]
mod tests {
use std::os::unix::fs::symlink;
use std::path::PathBuf;
use super::{check, run};
use crate::shared::test_support::scratch;
/// A scratch container: root, the profile link's path and a store with one
/// toplevel in it. Nothing is linked yet.
struct Fixture {
root: PathBuf,
system: PathBuf,
store: PathBuf,
toplevel: PathBuf,
}
impl Fixture {
fn new() -> Self {
let base = scratch();
let store = base.join("store");
let toplevel = store.join("abc-nixos-system");
std::fs::create_dir_all(&toplevel).unwrap();
std::fs::write(toplevel.join("system"), "x86_64-linux").unwrap();
let root = base.join("root");
std::fs::create_dir_all(&root).unwrap();
let profile = base.join("profile");
std::fs::create_dir_all(&profile).unwrap();
Self {
root,
system: profile.join("system"),
store,
toplevel,
}
}
/// A root as a booted guest leaves it, and a profile that resolves
/// into the store through a generation link.
fn clean() -> Self {
let f = Self::new();
for dir in ["usr/bin", "etc", "var/lib/private", "root"] {
std::fs::create_dir_all(f.root.join(dir)).unwrap();
}
std::fs::write(f.root.join("etc/machine-id"), "").unwrap();
std::fs::write(f.root.join("etc/resolv.conf"), "nameserver 1.1.1.1\n").unwrap();
symlink("/etc/static/os-release", f.root.join("etc/os-release")).unwrap();
let generation = f.system.with_file_name("system-1-link");
symlink(&f.toplevel, &generation).unwrap();
symlink("system-1-link", &f.system).unwrap();
f
}
fn check(&self) -> anyhow::Result<()> {
check(&self.root, &self.system, &self.store)
}
/// The error text of a check that has to fail.
fn refused(&self) -> String {
format!("{:#}", self.check().unwrap_err())
}
}
/// A target outside every fixture dir, for planted links to point at.
fn victim() -> PathBuf {
let dir = scratch();
let path = dir.join("victim");
std::fs::create_dir_all(&path).unwrap();
path
}
#[test]
fn a_clean_root_passes() {
Fixture::clean().check().unwrap();
}
#[test]
fn a_first_start_with_nothing_there_passes() {
let f = Fixture::new();
f.check().unwrap();
// The other half of the control: an empty root with the profile
// missing is the first-create case, and the profile dir is empty.
assert!(!f.system.exists());
}
#[test]
fn a_link_at_any_directory_is_refused_and_named() {
for dir in [
"usr",
"usr/bin",
"etc",
"var",
"var/lib",
"var/lib/private",
"root",
] {
let f = Fixture::clean();
let path = f.root.join(dir);
// Replace the real directory by a link to an outside one. Parents
// of nested ones are real, so the link sits exactly at `dir`.
std::fs::remove_dir_all(&path).unwrap();
symlink(victim(), &path).unwrap();
let msg = f.refused();
assert!(
msg.contains(&format!("{} is a symlink", path.display())),
"{dir}: {msg}"
);
}
}
#[test]
fn a_file_where_a_directory_belongs_is_refused() {
let f = Fixture::clean();
std::fs::remove_dir_all(f.root.join("root")).unwrap();
std::fs::write(f.root.join("root"), "").unwrap();
let msg = f.refused();
assert!(
msg.contains("root is a regular file, not a directory"),
"{msg}"
);
}
#[test]
fn a_missing_directory_below_a_real_one_passes() {
let f = Fixture::new();
std::fs::create_dir_all(f.root.join("var")).unwrap();
f.check().unwrap();
}
#[test]
fn a_link_at_machine_id_is_refused_even_when_dangling() {
let f = Fixture::clean();
let path = f.root.join("etc/machine-id");
std::fs::remove_file(&path).unwrap();
symlink(f.root.join("nowhere"), &path).unwrap();
let msg = f.refused();
assert!(msg.contains("machine-id is a symlink"), "{msg}");
}
#[test]
fn a_link_at_resolv_conf_is_refused() {
let f = Fixture::clean();
let path = f.root.join("etc/resolv.conf");
std::fs::remove_file(&path).unwrap();
symlink("/etc/hosts", &path).unwrap();
let msg = f.refused();
assert!(msg.contains("resolv.conf is a symlink"), "{msg}");
}
#[test]
fn a_directory_at_a_file_name_is_refused() {
let f = Fixture::clean();
let path = f.root.join("etc/resolv.conf");
std::fs::remove_file(&path).unwrap();
std::fs::create_dir(&path).unwrap();
let msg = f.refused();
assert!(msg.contains("resolv.conf is a directory"), "{msg}");
}
#[test]
fn os_release_may_be_a_link_a_file_or_absent_but_not_a_directory() {
// Link: what the clean fixture has.
let f = Fixture::clean();
f.check().unwrap();
let path = f.root.join("etc/os-release");
std::fs::remove_file(&path).unwrap();
f.check().unwrap();
std::fs::write(&path, "").unwrap();
f.check().unwrap();
std::fs::remove_file(&path).unwrap();
std::fs::create_dir(&path).unwrap();
let msg = f.refused();
assert!(msg.contains("os-release is a directory"), "{msg}");
}
#[test]
fn a_profile_system_that_is_a_directory_is_refused() {
let f = Fixture::clean();
std::fs::remove_file(&f.system).unwrap();
std::fs::create_dir_all(f.system.join("system")).unwrap();
let msg = f.refused();
assert!(
msg.contains("system is a directory, not a symlink"),
"{msg}"
);
}
#[test]
fn a_profile_system_pointing_outside_the_store_is_refused() {
let f = Fixture::clean();
let outside = victim();
std::fs::write(outside.join("system"), "x86_64-linux").unwrap();
std::fs::remove_file(&f.system).unwrap();
symlink(&outside, &f.system).unwrap();
let msg = f.refused();
assert!(msg.contains("outside"), "{msg}");
}
#[test]
fn a_dangling_profile_system_is_refused() {
let f = Fixture::clean();
std::fs::remove_file(&f.system).unwrap();
symlink(f.store.join("gone"), &f.system).unwrap();
let msg = f.refused();
assert!(msg.contains("does not resolve"), "{msg}");
}
#[test]
fn a_toplevel_whose_system_is_not_a_regular_file_is_refused() {
let f = Fixture::clean();
std::fs::remove_file(f.toplevel.join("system")).unwrap();
std::fs::create_dir(f.toplevel.join("system")).unwrap();
let msg = f.refused();
assert!(
msg.contains("system is a directory, not a regular file"),
"{msg}"
);
std::fs::remove_dir(f.toplevel.join("system")).unwrap();
let msg = f.refused();
assert!(msg.contains("is missing"), "{msg}");
}
#[test]
fn a_check_leaves_everything_as_it_found_it() {
let f = Fixture::clean();
let planted = f.root.join("root");
std::fs::remove_dir_all(&planted).unwrap();
let target = victim();
symlink(&target, &planted).unwrap();
f.check().unwrap_err();
assert!(std::fs::symlink_metadata(&planted).unwrap().is_symlink());
assert!(target.is_dir());
}
#[test]
fn only_the_check_rootfs_sub_command_is_taken() {
assert_eq!(run(&[]), None);
assert_eq!(run(&["serve".to_string()]), None);
// Wrong arity and a name that is not an agent container are refused,
// whatever is on disk.
assert_eq!(run(&["check-rootfs".to_string()]), Some(1));
assert_eq!(
run(&["check-rootfs".to_string(), "not-hive".to_string()]),
Some(1)
);
}
}