hive-priv: fetch the swarm signature from attic before verifying a locally valid closure
This commit is contained in:
parent
3550cc0e1b
commit
c25cf3d48a
6 changed files with 393 additions and 51 deletions
|
|
@ -609,7 +609,12 @@ refusal and a path no substituter has each fail with their own message.
|
|||
|
||||
`Swap` then does the same lookup and pull, a no-op once the path is in the
|
||||
store, and gives `hive-priv` the store path to apply. `hive-priv` applies it only
|
||||
when `nix store verify --sigs-needed 1` finds the swarm key's signature on it. A stopped agent skips
|
||||
when `nix store verify --sigs-needed 1` finds the swarm key's signature on it.
|
||||
A path that was already in the store, such as one the builder built through the
|
||||
host's daemon, carries no signature there, and the pull leaves it so, so
|
||||
`hive-priv` first runs `nix store copy-sigs` for that one path against the
|
||||
cache in `/run/hyperhive-nix/cache.conf` (netrc `/run/hyperhive-nix/netrc`). The
|
||||
verify still needs the swarm key's signature; a failed copy only gets logged. A stopped agent skips
|
||||
`Prebuild`, so `Swap` downloads it. A first deploy's `Create` node does the
|
||||
same and creates the container from that path.
|
||||
|
||||
|
|
|
|||
|
|
@ -28,36 +28,36 @@ for every operation that genuinely requires root.
|
|||
**Narrow interface** — `PrivRequest` variants map 1:1 to specific
|
||||
known operations; there is no arbitrary command pass-through:
|
||||
|
||||
| Operation | What it runs |
|
||||
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `StartContainer` / `StopContainer` | `nixos-container start/stop <name>` |
|
||||
| `KillContainer` | `machinectl kill <machine> --signal=SIGKILL` (`nixos-container` has no kill verb) |
|
||||
| `StartFailureLog` | `journalctl _SYSTEMD_UNIT=container@h-<name>.service --since @<t> -n 30 -o cat --no-pager`, fixed argv, 5 s timeout, output capped at 8 KiB; hive-priv clamps `<t>` to the last hour. hive-c0re appends the lines to the failed start's build log, which only the dashboard serves. |
|
||||
| `CreateContainer` / `UpdateContainer` | `nixos-container create <name> --system-path <toplevel>`; for an update, `nix-env -p <profile> --set <toplevel>` and a reload of the unit. `<toplevel>` must already be in the store with a signature by the swarm cache's key: hive-priv runs `nix store verify --no-contents --sigs-needed 1` with `trusted-public-keys` set to the key in root-owned `/run/hyperhive-priv/attic/public-key`, and refuses a path that's absent or that the key didn't sign. hive-priv builds nothing. The check doesn't bind the path to the agent whose pull produced it: hive-c0re could already pull any swarm-signed path and apply it to any agent, so the check bounds the same risk (one agent gets another's closure), not an arbitrary-path one |
|
||||
| `RealiseClosure` | Kept for one release, for an older hive-c0re that still asks hive-priv to download. Downloads nothing: answers `Ok` when the path is in the store and signed by the swarm key (what `CreateContainer` needs), else `not available: <reason>`. The path must be `/nix/store/<32 chars>-<name>` and the agent name valid |
|
||||
| `RefreshCacheFacts` | `systemctl start hive-priv-cache-facts.service`, waited for up to 60 s. The request names no unit and has no field. hive-c0re asks for it when the fragment is missing or lacks a setting |
|
||||
| `DestroyContainer` | `nixos-container destroy <name>` |
|
||||
| `ListContainers` | `nixos-container list` |
|
||||
| `ReloadGatewayNginx` | `systemctl reload/start/reset-failed nginx` (host unit; the unit name is hard-coded, not a parameter) |
|
||||
| `WriteNspawnFlags` | write `/etc/nixos-containers/<container>.conf` (bind-mount list, network isolation vars, and `--ephemeral` after the root's salvage). hive-priv first copies a stopped container's root to the agent's `work/`, then renames it aside (it deletes nothing), following no link the container could have planted; for an agent whose closure record sets `private_users` it also adds `--private-users=pick`, mounts every bind `owneridmap`, and once chowns the agent's `state`, `harness`, `work` and `claude` dirs (never `config`, following no link) to the data owner |
|
||||
| `EnsureAgentSocketDir` | `mkdirat` `/run/hive-agent/<name>` `0751 root:root` under an `O_NOFOLLOW` parent fd; leaves an existing directory alone, refuses anything else; for an agent whose closure record sets `private_users` it then chowns the dir to the data owner |
|
||||
| `WriteResourceLimits` | write `CPUQuota=`/`MemoryMax=`/`CPUWeight=`/`IOWeight=` systemd drop-in for agent container, with an `ExecStartPre=` that runs `hive-priv check-rootfs <container>` (no-follow check of the root paths the start script touches; see persistence.md), and a GC root on the build it names |
|
||||
| `RemoveServiceDropin` | remove `container@<name>.service.d/` drop-in on destroy, and the GC root of the hive-priv build it named |
|
||||
| `DaemonReload` | `systemctl daemon-reload` |
|
||||
| `ControlInfraContainer` | `systemctl <action> nginx.service` for the gateway — the `InfraContainer` enum is the allowlist, and serde rejects unknown names at the wire boundary (`hive-c0re`, the forge and the matrix have no variant, so no request can name them) |
|
||||
| `SyncAgentTmpfiles` | unlink `/etc/tmpfiles.d/hyperhive-agents.conf` and return `Ok` |
|
||||
| `SetAgentPaused` | create / remove the `<state>/<name>/harness/paused` marker that parks an agent's turn loop |
|
||||
| `EnsureAgentSubvolume` | `btrfs subvolume create <state>/<name>` for a new agent — no-op when the path exists or the filesystem isn't btrfs |
|
||||
| `UpgradeAgentSubvolume` | migrate an existing plain state dir into a subvolume: create, `cp -a --reflink=auto`, atomic swap. Operator opt-in, and the caller stops the agent first |
|
||||
| `DeleteAgentSubvolume` | `btrfs subvolume delete <state>/<name>` — purge path only, never a plain destroy |
|
||||
| `EnsureBtrfsQuota` | `btrfs quota enable <AGENT_STATE_ROOT>`. Operator opt-in — enabling forces a full rescan |
|
||||
| `ReadSubvolumeUsage` | `btrfs qgroup show -f --raw <state>/<name>` |
|
||||
| `SetSubvolumeQuota` | `btrfs qgroup limit <bytes\|none> <state>/<name>` |
|
||||
| `MeasureAgentDisk` | `du -s -x -b -c -P` over `<state>/<name>/state`, `<state>/<name>/work` and `/var/lib/nixos-containers/h-<name>`, killed after 10 min. hive-priv derives the paths from the name; the request carries none |
|
||||
| `SnapshotAgentSubvolume` | `btrfs subvolume snapshot -r <agent_root> <snapshot_path>` — the frozen point-in-time copy a migration streams from |
|
||||
| `DeleteAgentSnapshot` | `btrfs subvolume delete <snapshot_path>` |
|
||||
| `SendAgentSnapshotToFile` | `btrfs send [-p <parent>] <snapshot>` into a bare filename under `MIGRATE_STAGING_ROOT`; refuses to overwrite an existing export |
|
||||
| `SendAgentSnapshotToFd` | `btrfs send [-p <parent>] <snapshot>` into a **file descriptor passed with the request** (`SCM_RIGHTS`). hive-c0re connects to the peer and hands over the connected socket, so hive-priv never learns an address or protocol. hive-priv requires exactly one descriptor here, and refuses one that arrives alongside any other operation |
|
||||
| Operation | What it runs |
|
||||
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `StartContainer` / `StopContainer` | `nixos-container start/stop <name>` |
|
||||
| `KillContainer` | `machinectl kill <machine> --signal=SIGKILL` (`nixos-container` has no kill verb) |
|
||||
| `StartFailureLog` | `journalctl _SYSTEMD_UNIT=container@h-<name>.service --since @<t> -n 30 -o cat --no-pager`, fixed argv, 5 s timeout, output capped at 8 KiB; hive-priv clamps `<t>` to the last hour. hive-c0re appends the lines to the failed start's build log, which only the dashboard serves. |
|
||||
| `CreateContainer` / `UpdateContainer` | `nixos-container create <name> --system-path <toplevel>`; for an update, `nix-env -p <profile> --set <toplevel>` and a reload of the unit. `<toplevel>` must already be in the store with a signature by the swarm cache's key: hive-priv first runs `nix store copy-sigs --store daemon` for that path from the cache named in `/run/hyperhive-nix/cache.conf`, with the netrc beside it, which imports the cache's signature for a path that was already in the store (on a failure it logs a warning and the check goes on); it then runs `nix store verify --no-contents --sigs-needed 1` with `trusted-public-keys` set to the key in root-owned `/run/hyperhive-priv/attic/public-key`, and refuses a path that's absent or that the key didn't sign. hive-priv builds nothing. The check doesn't bind the path to the agent whose pull produced it: hive-c0re could already pull any swarm-signed path and apply it to any agent, so the check bounds the same risk (one agent gets another's closure), not an arbitrary-path one |
|
||||
| `RealiseClosure` | Kept for one release, for an older hive-c0re that still asks hive-priv to download. Downloads nothing: answers `Ok` when the path is in the store and signed by the swarm key (what `CreateContainer` needs), else `not available: <reason>`. The path must be `/nix/store/<32 chars>-<name>` and the agent name valid |
|
||||
| `RefreshCacheFacts` | `systemctl start hive-priv-cache-facts.service`, waited for up to 60 s. The request names no unit and has no field. hive-c0re asks for it when the fragment is missing or lacks a setting |
|
||||
| `DestroyContainer` | `nixos-container destroy <name>` |
|
||||
| `ListContainers` | `nixos-container list` |
|
||||
| `ReloadGatewayNginx` | `systemctl reload/start/reset-failed nginx` (host unit; the unit name is hard-coded, not a parameter) |
|
||||
| `WriteNspawnFlags` | write `/etc/nixos-containers/<container>.conf` (bind-mount list, network isolation vars, and `--ephemeral` after the root's salvage). hive-priv first copies a stopped container's root to the agent's `work/`, then renames it aside (it deletes nothing), following no link the container could have planted; for an agent whose closure record sets `private_users` it also adds `--private-users=pick`, mounts every bind `owneridmap`, and once chowns the agent's `state`, `harness`, `work` and `claude` dirs (never `config`, following no link) to the data owner |
|
||||
| `EnsureAgentSocketDir` | `mkdirat` `/run/hive-agent/<name>` `0751 root:root` under an `O_NOFOLLOW` parent fd; leaves an existing directory alone, refuses anything else; for an agent whose closure record sets `private_users` it then chowns the dir to the data owner |
|
||||
| `WriteResourceLimits` | write `CPUQuota=`/`MemoryMax=`/`CPUWeight=`/`IOWeight=` systemd drop-in for agent container, with an `ExecStartPre=` that runs `hive-priv check-rootfs <container>` (no-follow check of the root paths the start script touches; see persistence.md), and a GC root on the build it names |
|
||||
| `RemoveServiceDropin` | remove `container@<name>.service.d/` drop-in on destroy, and the GC root of the hive-priv build it named |
|
||||
| `DaemonReload` | `systemctl daemon-reload` |
|
||||
| `ControlInfraContainer` | `systemctl <action> nginx.service` for the gateway — the `InfraContainer` enum is the allowlist, and serde rejects unknown names at the wire boundary (`hive-c0re`, the forge and the matrix have no variant, so no request can name them) |
|
||||
| `SyncAgentTmpfiles` | unlink `/etc/tmpfiles.d/hyperhive-agents.conf` and return `Ok` |
|
||||
| `SetAgentPaused` | create / remove the `<state>/<name>/harness/paused` marker that parks an agent's turn loop |
|
||||
| `EnsureAgentSubvolume` | `btrfs subvolume create <state>/<name>` for a new agent — no-op when the path exists or the filesystem isn't btrfs |
|
||||
| `UpgradeAgentSubvolume` | migrate an existing plain state dir into a subvolume: create, `cp -a --reflink=auto`, atomic swap. Operator opt-in, and the caller stops the agent first |
|
||||
| `DeleteAgentSubvolume` | `btrfs subvolume delete <state>/<name>` — purge path only, never a plain destroy |
|
||||
| `EnsureBtrfsQuota` | `btrfs quota enable <AGENT_STATE_ROOT>`. Operator opt-in — enabling forces a full rescan |
|
||||
| `ReadSubvolumeUsage` | `btrfs qgroup show -f --raw <state>/<name>` |
|
||||
| `SetSubvolumeQuota` | `btrfs qgroup limit <bytes\|none> <state>/<name>` |
|
||||
| `MeasureAgentDisk` | `du -s -x -b -c -P` over `<state>/<name>/state`, `<state>/<name>/work` and `/var/lib/nixos-containers/h-<name>`, killed after 10 min. hive-priv derives the paths from the name; the request carries none |
|
||||
| `SnapshotAgentSubvolume` | `btrfs subvolume snapshot -r <agent_root> <snapshot_path>` — the frozen point-in-time copy a migration streams from |
|
||||
| `DeleteAgentSnapshot` | `btrfs subvolume delete <snapshot_path>` |
|
||||
| `SendAgentSnapshotToFile` | `btrfs send [-p <parent>] <snapshot>` into a bare filename under `MIGRATE_STAGING_ROOT`; refuses to overwrite an existing export |
|
||||
| `SendAgentSnapshotToFd` | `btrfs send [-p <parent>] <snapshot>` into a **file descriptor passed with the request** (`SCM_RIGHTS`). hive-c0re connects to the peer and hands over the connected socket, so hive-priv never learns an address or protocol. hive-priv requires exactly one descriptor here, and refuses one that arrives alongside any other operation |
|
||||
|
||||
**Strings that reach root-loaded config** — `WriteResourceLimits` and
|
||||
`WriteNspawnFlags` copy request strings into a systemd drop-in and an env
|
||||
|
|
@ -106,7 +106,9 @@ the daemon accepts:
|
|||
substituters. `--max-jobs 0` forbids a build, so a path no substituter has
|
||||
fails.
|
||||
- `hive-priv` verifies. Before `CreateContainer` or `UpdateContainer` applies a
|
||||
toplevel, it runs `nix store verify --no-contents --sigs-needed 1` with
|
||||
toplevel, it first imports the cache's signature for that path with
|
||||
`nix store copy-sigs` (a path the host built carries none), then runs
|
||||
`nix store verify --no-contents --sigs-needed 1` with
|
||||
`trusted-public-keys` set to the key in the root file, and refuses a path that
|
||||
is absent or that the key didn't sign. The daemon's trusted keys also include
|
||||
the host's other caches, so this check is the one that counts only the swarm's.
|
||||
|
|
|
|||
|
|
@ -47,7 +47,9 @@ Each module's `//!` header lists its ops.
|
|||
`container_toplevel_action` (in `src/container.rs`) applies a closure that is
|
||||
already in the store; hive-priv builds nothing. hive-c0re names the closure
|
||||
(`toplevel` on `CreateContainer` / `UpdateContainer`), pulled beforehand by
|
||||
hive-c0re through the nix daemon. hive-priv applies a path only when
|
||||
hive-c0re through the nix daemon. hive-priv first imports the cache's signature for the path with
|
||||
`nix store copy-sigs` (from `/run/hyperhive-nix/cache.conf`; a failure logs a
|
||||
warning), then applies it only when
|
||||
`nix store verify --no-contents --sigs-needed 1`, with the key in root-owned
|
||||
`/run/hyperhive-priv/attic/public-key`, finds the swarm cache's signature on it.
|
||||
It refuses a path that is absent, unsigned, signed by another key or not a store
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@
|
|||
//! key, which `nix store verify` checks against the key in [`ROOT_FACTS_DIR`].
|
||||
//! The key is a file a root unit keeps; nothing in the request, and nothing
|
||||
//! hive-c0re writes, chooses it.
|
||||
//!
|
||||
//! A path already in the store from a local build has no signature in the
|
||||
//! store database, and the daemon's pull leaves it so. Before the check,
|
||||
//! [`fetch_signature`] imports the signature the swarm cache holds for it; the
|
||||
//! check still decides.
|
||||
|
||||
use std::os::unix::fs::MetadataExt as _;
|
||||
use std::path::Path;
|
||||
|
|
@ -24,6 +29,13 @@ const ROOT_FACTS_DIR: &str = "/run/hyperhive-priv/attic";
|
|||
/// The root unit that reads this hive's cache facts from the store.
|
||||
const FACTS_UNIT: &str = "hive-priv-cache-facts.service";
|
||||
|
||||
/// Where the facts unit writes `cache.conf` (the cache's address) and `netrc`
|
||||
/// (the pull token), both root-only to write.
|
||||
const CONF_DIR: &str = "/run/hyperhive-nix";
|
||||
|
||||
/// Wall-clock bound on one `nix store copy-sigs`, which fetches one narinfo.
|
||||
const COPY_SIGS_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(1);
|
||||
|
||||
/// Wall-clock bound on one `nix store verify`, which reads a path's metadata.
|
||||
const VERIFY_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(1);
|
||||
|
||||
|
|
@ -116,12 +128,95 @@ fn verdict(code: Option<i32>, toplevel: &str, stderr: &str) -> Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether `toplevel` is in the store with a signature by the key in `dir`.
|
||||
async fn check_signed(spawn: &impl Spawn, dir: &Path, owner: u32, toplevel: &str) -> Result<()> {
|
||||
/// The swarm cache's address in a `cache.conf` fragment: the value of its
|
||||
/// `extra-substituters` line, when that is one word.
|
||||
fn cache_url(fragment: &str) -> Option<&str> {
|
||||
fragment
|
||||
.lines()
|
||||
.filter_map(|line| line.split_once('='))
|
||||
.find(|(name, _)| name.trim() == "extra-substituters")
|
||||
.map(|(_, value)| value.trim())
|
||||
.filter(|url| !url.is_empty() && !url.contains(char::is_whitespace))
|
||||
}
|
||||
|
||||
/// The `nix` arguments that import the signatures the cache at `url` holds for
|
||||
/// `toplevel` into the daemon's store database, authenticating with `netrc`.
|
||||
///
|
||||
/// - `--store daemon` is the store the verify reads, whatever the environment.
|
||||
/// - No `--recursive`: only the path that is verified is touched.
|
||||
fn copy_sigs_args(toplevel: &str, url: &str, netrc: &Path) -> Vec<String> {
|
||||
[
|
||||
"--extra-experimental-features",
|
||||
"nix-command",
|
||||
"store",
|
||||
"copy-sigs",
|
||||
"--store",
|
||||
"daemon",
|
||||
"--substituter",
|
||||
url,
|
||||
"--option",
|
||||
"netrc-file",
|
||||
&netrc.to_string_lossy(),
|
||||
toplevel,
|
||||
]
|
||||
.map(str::to_owned)
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Import the signature the swarm cache holds for `toplevel`, so that a path
|
||||
/// already in the store without it can pass the verify. Nothing is trusted
|
||||
/// here: the verify still needs the swarm key's signature.
|
||||
///
|
||||
/// A failure (no `cache.conf`, the cache unreachable, no narinfo for the path)
|
||||
/// is logged and the verify runs anyway: the path may already carry the
|
||||
/// signature.
|
||||
async fn fetch_signature(spawn: &impl Spawn, conf_dir: &Path, toplevel: &str) {
|
||||
let conf = conf_dir.join("cache.conf");
|
||||
let fragment = match std::fs::read_to_string(&conf) {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
tracing::warn!(path = %conf.display(), error = %e, "not fetching the swarm signature: cannot read the cache address");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(url) = cache_url(&fragment) else {
|
||||
tracing::warn!(path = %conf.display(), "not fetching the swarm signature: no single extra-substituters address");
|
||||
return;
|
||||
};
|
||||
let mut cmd = spawn.command("nix");
|
||||
cmd.args(copy_sigs_args(toplevel, url, &conf_dir.join("netrc")));
|
||||
match run_bounded(cmd, COPY_SIGS_TIMEOUT).await {
|
||||
Ok(BoundedRun::Exited { status, .. }) if status.success() => {}
|
||||
Ok(BoundedRun::Exited { status, stderr }) => tracing::warn!(
|
||||
%toplevel, %status, stderr = stderr.lines().last().unwrap_or("").trim(),
|
||||
"nix store copy-sigs failed; verifying without it"
|
||||
),
|
||||
Ok(BoundedRun::TimedOut) => tracing::warn!(
|
||||
%toplevel,
|
||||
"nix store copy-sigs timed out after {}s; verifying without it",
|
||||
COPY_SIGS_TIMEOUT.as_secs()
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
%toplevel, error = %format!("{e:#}"),
|
||||
"could not run nix store copy-sigs; verifying without it"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `toplevel` is in the store with a signature by the key in `dir`,
|
||||
/// after importing the cache's signature for it ([`fetch_signature`]).
|
||||
async fn check_signed(
|
||||
spawn: &impl Spawn,
|
||||
dir: &Path,
|
||||
conf_dir: &Path,
|
||||
owner: u32,
|
||||
toplevel: &str,
|
||||
) -> Result<()> {
|
||||
if !valid_closure_path(toplevel) {
|
||||
bail!("{toplevel:?} is not a store path");
|
||||
}
|
||||
let key = swarm_key(dir, owner)?;
|
||||
fetch_signature(spawn, conf_dir, toplevel).await;
|
||||
let mut cmd = spawn.command("nix");
|
||||
cmd.args(verify_args(toplevel, &key));
|
||||
match run_bounded(cmd, VERIFY_TIMEOUT)
|
||||
|
|
@ -148,17 +243,26 @@ async fn check_signed(spawn: &impl Spawn, dir: &Path, owner: u32, toplevel: &str
|
|||
/// `toplevel` is not a store path, is not in the store, or carries no
|
||||
/// signature by the swarm cache's key, or the key cannot be read.
|
||||
pub(crate) async fn applicable_for<'a>(agent: &str, toplevel: &'a str) -> Result<&'a str> {
|
||||
applicable_with(&RealSpawn, Path::new(ROOT_FACTS_DIR), 0, agent, toplevel).await
|
||||
applicable_with(
|
||||
&RealSpawn,
|
||||
Path::new(ROOT_FACTS_DIR),
|
||||
Path::new(CONF_DIR),
|
||||
0,
|
||||
agent,
|
||||
toplevel,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn applicable_with<'a>(
|
||||
spawn: &impl Spawn,
|
||||
dir: &Path,
|
||||
conf_dir: &Path,
|
||||
owner: u32,
|
||||
agent: &str,
|
||||
toplevel: &'a str,
|
||||
) -> Result<&'a str> {
|
||||
match check_signed(spawn, dir, owner, toplevel).await {
|
||||
match check_signed(spawn, dir, conf_dir, owner, toplevel).await {
|
||||
Ok(()) => Ok(toplevel),
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
|
|
@ -180,7 +284,15 @@ pub(crate) async fn realise_closure(agent: &str, store_path: &str) -> Result<(St
|
|||
if !valid_closure_path(store_path) {
|
||||
bail!("{store_path:?} is not a store path");
|
||||
}
|
||||
match check_signed(&RealSpawn, Path::new(ROOT_FACTS_DIR), 0, store_path).await {
|
||||
match check_signed(
|
||||
&RealSpawn,
|
||||
Path::new(ROOT_FACTS_DIR),
|
||||
Path::new(CONF_DIR),
|
||||
0,
|
||||
store_path,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(("present".to_owned(), String::new())),
|
||||
Err(e) => bail!(
|
||||
"not available: {e:#}; hive-priv no longer downloads, hive-c0re pulls through the nix daemon"
|
||||
|
|
@ -254,26 +366,66 @@ mod tests {
|
|||
dir
|
||||
}
|
||||
|
||||
/// A `nix` that behaves like `nix store verify --sigs-needed 1` over a store
|
||||
/// holding `PATH`, signed by `KEY` only: exit 0 for that path under that key,
|
||||
/// 2 for that path under any other key, 4 for any other path. It exits 99
|
||||
/// for anything but the verify invocation, and logs its arguments.
|
||||
fn nix(log: &Path) -> ScriptSpawn {
|
||||
const URL: &str = "https://cache.example/swarm";
|
||||
|
||||
/// A `cache.conf` directory as the facts unit leaves it.
|
||||
fn conf() -> PathBuf {
|
||||
let dir = scratch();
|
||||
put(
|
||||
&dir.join("cache.conf"),
|
||||
&format!(
|
||||
"extra-substituters = {URL}\nextra-trusted-public-keys = {KEY}\nnetrc-file = {}/netrc\n",
|
||||
dir.display()
|
||||
),
|
||||
0o644,
|
||||
);
|
||||
dir
|
||||
}
|
||||
|
||||
/// A `nix` over a store holding `PATH`, which `nix store verify
|
||||
/// --sigs-needed 1` finds signed by `KEY` only: exit 0 for that path under
|
||||
/// that key, 2 for that path under any other key, 4 for any other path. It
|
||||
/// exits 99 for anything but the verify and copy-sigs invocations, and logs
|
||||
/// its arguments.
|
||||
///
|
||||
/// `signed` is whether `PATH` carries the signature before anything runs;
|
||||
/// otherwise the verify exits 2 until a copy-sigs has exited 0, which
|
||||
/// imports it. `copy_exit` is that copy-sigs' exit status.
|
||||
fn nix_with(log: &Path, signed: bool, copy_exit: i32) -> ScriptSpawn {
|
||||
let marker = log.with_extension("signed");
|
||||
if signed {
|
||||
std::fs::write(&marker, "").expect("marker");
|
||||
}
|
||||
ScriptSpawn(format!(
|
||||
r#"echo "$@" >>{log}
|
||||
if [ "$3 $4" = "store copy-sigs" ]; then
|
||||
[ "$5 $6 $7" = "--store daemon --substituter" ] || exit 99
|
||||
[ "$8" = {URL} ] || exit 99
|
||||
[ "$9 ${{10}}" = "--option netrc-file" ] || exit 99
|
||||
[ "${{12}}" = {PATH} ] || exit 99
|
||||
[ {copy_exit} = 0 ] && touch {marker}
|
||||
exit {copy_exit}
|
||||
fi
|
||||
[ "$1 $2 $3 $4 $5" = "--extra-experimental-features nix-command store verify --no-contents" ] || exit 99
|
||||
[ "$6 $7 $8 $9" = "--sigs-needed 1 --option trusted-public-keys" ] || exit 99
|
||||
[ "${{11}}" = {PATH} ] || exit 4
|
||||
[ "${{10}}" = {KEY} ] || exit 2
|
||||
[ -e {marker} ] || exit 2
|
||||
exit 0"#,
|
||||
log = log.display()
|
||||
log = log.display(),
|
||||
marker = marker.display()
|
||||
))
|
||||
}
|
||||
|
||||
/// A `nix` whose path is signed already and whose copy-sigs succeeds.
|
||||
fn nix(log: &Path) -> ScriptSpawn {
|
||||
nix_with(log, true, 0)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_toplevel_signed_by_the_swarm_key_is_applied() {
|
||||
let log = scratch().join("calls");
|
||||
let got = applicable_with(&nix(&log), &facts(KEY), me(), "atlas", PATH)
|
||||
let got = applicable_with(&nix(&log), &facts(KEY), &conf(), me(), "atlas", PATH)
|
||||
.await
|
||||
.expect("signed");
|
||||
assert_eq!(got, PATH);
|
||||
|
|
@ -300,7 +452,7 @@ exit 0"#,
|
|||
"has no signature by the swarm cache's key",
|
||||
),
|
||||
] {
|
||||
let err = applicable_with(&nix(&log), &dir, me(), "atlas", path)
|
||||
let err = applicable_with(&nix(&log), &dir, &conf(), me(), "atlas", path)
|
||||
.await
|
||||
.expect_err(case);
|
||||
let msg = format!("{err:#}");
|
||||
|
|
@ -316,7 +468,7 @@ exit 0"#,
|
|||
#[tokio::test]
|
||||
async fn a_failing_verify_refuses() {
|
||||
let spawn = ScriptSpawn("echo 'error: cannot connect' >&2; exit 1".to_owned());
|
||||
let err = applicable_with(&spawn, &facts(KEY), me(), "atlas", PATH)
|
||||
let err = applicable_with(&spawn, &facts(KEY), &conf(), me(), "atlas", PATH)
|
||||
.await
|
||||
.expect_err("refused");
|
||||
assert!(format!("{err:#}").contains("cannot connect"), "{err:#}");
|
||||
|
|
@ -333,14 +485,14 @@ exit 0"#,
|
|||
"/nix/store/x",
|
||||
&format!("{PATH}/bin"),
|
||||
] {
|
||||
let err = applicable_with(&yes, &facts(KEY), me(), "atlas", bad)
|
||||
let err = applicable_with(&yes, &facts(KEY), &conf(), me(), "atlas", bad)
|
||||
.await
|
||||
.expect_err(bad);
|
||||
assert!(format!("{err:#}").contains("not a store path"), "{bad}");
|
||||
}
|
||||
// Control: the same stand-in accepts a store path.
|
||||
assert!(
|
||||
applicable_with(&yes, &facts(KEY), me(), "atlas", PATH)
|
||||
applicable_with(&yes, &facts(KEY), &conf(), me(), "atlas", PATH)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
|
|
@ -352,7 +504,7 @@ exit 0"#,
|
|||
async fn without_the_root_key_nothing_is_applied() {
|
||||
let log = scratch().join("calls");
|
||||
let nowhere = scratch().join("nowhere");
|
||||
let err = applicable_with(&nix(&log), &nowhere, 0, "atlas", PATH)
|
||||
let err = applicable_with(&nix(&log), &nowhere, &conf(), 0, "atlas", PATH)
|
||||
.await
|
||||
.expect_err("no key");
|
||||
assert!(format!("{err:#}").contains("missing"), "{err:#}");
|
||||
|
|
@ -457,4 +609,139 @@ exit 0"#,
|
|||
.expect_err("timed out");
|
||||
assert!(format!("{err:#}").contains("did not finish"), "{err:#}");
|
||||
}
|
||||
|
||||
fn calls(log: &Path) -> Vec<String> {
|
||||
std::fs::read_to_string(log)
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A path in the store without the swarm signature passes once the cache's
|
||||
/// signature is imported, and the import runs before the verify, with the
|
||||
/// cache's address and netrc from the facts unit's directory.
|
||||
#[tokio::test]
|
||||
async fn the_cache_signature_is_imported_before_the_verify() {
|
||||
let log = scratch().join("calls");
|
||||
let conf = conf();
|
||||
applicable_with(
|
||||
&nix_with(&log, false, 0),
|
||||
&facts(KEY),
|
||||
&conf,
|
||||
me(),
|
||||
"atlas",
|
||||
PATH,
|
||||
)
|
||||
.await
|
||||
.expect("signed after the import");
|
||||
let calls = calls(&log);
|
||||
assert_eq!(calls.len(), 2, "{calls:?}");
|
||||
assert_eq!(
|
||||
calls[0],
|
||||
format!(
|
||||
"--extra-experimental-features nix-command store copy-sigs --store daemon --substituter {URL} --option netrc-file {}/netrc {PATH}",
|
||||
conf.display()
|
||||
)
|
||||
);
|
||||
assert!(calls[1].contains("store verify"), "{calls:?}");
|
||||
}
|
||||
|
||||
/// A failed import is not the verdict: the verify runs and decides, so an
|
||||
/// unsigned path is refused for its missing signature and a path that
|
||||
/// already carries it is applied.
|
||||
#[tokio::test]
|
||||
async fn a_failed_import_still_runs_the_verify() {
|
||||
let log = scratch().join("calls");
|
||||
let err = applicable_with(
|
||||
&nix_with(&log, false, 1),
|
||||
&facts(KEY),
|
||||
&conf(),
|
||||
me(),
|
||||
"atlas",
|
||||
PATH,
|
||||
)
|
||||
.await
|
||||
.expect_err("still unsigned");
|
||||
assert!(
|
||||
format!("{err:#}").contains("has no signature by the swarm cache's key"),
|
||||
"{err:#}"
|
||||
);
|
||||
let calls = calls(&log);
|
||||
assert!(
|
||||
calls.len() == 2 && calls[0].contains("copy-sigs") && calls[1].contains("store verify"),
|
||||
"{calls:?}"
|
||||
);
|
||||
// Control: the same failing import leaves a signed path applicable.
|
||||
let log = scratch().join("calls");
|
||||
applicable_with(
|
||||
&nix_with(&log, true, 1),
|
||||
&facts(KEY),
|
||||
&conf(),
|
||||
me(),
|
||||
"atlas",
|
||||
PATH,
|
||||
)
|
||||
.await
|
||||
.expect("signed already");
|
||||
}
|
||||
|
||||
/// Without a readable `cache.conf` naming one address there is no import,
|
||||
/// and the verify decides as before.
|
||||
#[tokio::test]
|
||||
async fn without_a_cache_address_the_verify_runs_alone() {
|
||||
for (case, text) in [
|
||||
("no file", None),
|
||||
("no setting", Some("netrc-file = /x\n")),
|
||||
("empty", Some("extra-substituters =\n")),
|
||||
(
|
||||
"two addresses",
|
||||
Some("extra-substituters = https://a https://b\n"),
|
||||
),
|
||||
] {
|
||||
let conf = scratch();
|
||||
if let Some(text) = text {
|
||||
put(&conf.join("cache.conf"), text, 0o644);
|
||||
}
|
||||
let log = scratch().join("calls");
|
||||
let err = applicable_with(
|
||||
&nix_with(&log, false, 0),
|
||||
&facts(KEY),
|
||||
&conf,
|
||||
me(),
|
||||
"atlas",
|
||||
PATH,
|
||||
)
|
||||
.await
|
||||
.expect_err(case);
|
||||
assert!(format!("{err:#}").contains("has no signature"), "{case}");
|
||||
let calls = calls(&log);
|
||||
assert!(
|
||||
calls.len() == 1 && calls[0].contains("store verify"),
|
||||
"{case}: {calls:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_cache_address_is_the_extra_substituters_value() {
|
||||
assert_eq!(
|
||||
cache_url("extra-substituters = https://c.example/swarm\nnetrc-file = /n\n"),
|
||||
Some("https://c.example/swarm")
|
||||
);
|
||||
// `substituters` is the host's own list, not the swarm cache.
|
||||
assert_eq!(cache_url("substituters = https://c.example\n"), None);
|
||||
assert_eq!(cache_url(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_import_is_for_the_one_path_and_never_trusts_a_key() {
|
||||
let args = copy_sigs_args(PATH, URL, Path::new("/run/hyperhive-nix/netrc"));
|
||||
assert_eq!(args.last().map(String::as_str), Some(PATH));
|
||||
assert!(!args.iter().any(|a| a == "--recursive" || a == "-r"));
|
||||
assert!(
|
||||
!args.iter().any(|a| a.contains("trusted-public-keys")),
|
||||
"{args:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@
|
|||
# (`nix-store --realise <top> --max-jobs 0`, hive-c0re/src/cache_pull.rs)
|
||||
# against two file caches: a swarm cache that has the top path and lacks a
|
||||
# dependency, and the host's own substituter that has the dependency. The cases
|
||||
# are in ./cache-pull-substitute.sh.
|
||||
# are in ./cache-pull-substitute.sh, which also runs hive-priv's
|
||||
# `nix store copy-sigs` step against a path built in a store.
|
||||
{ pkgs, ... }:
|
||||
pkgs.runCommand "hyperhive-script-test-cache-pull-substitute"
|
||||
{
|
||||
|
|
@ -11,6 +12,7 @@ pkgs.runCommand "hyperhive-script-test-cache-pull-substitute"
|
|||
pkgs.coreutils
|
||||
pkgs.gnugrep
|
||||
];
|
||||
HIVE_TEST_BASH = "${pkgs.bash}/bin/bash";
|
||||
}
|
||||
''
|
||||
${pkgs.bash}/bin/bash ${./cache-pull-substitute.sh}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@
|
|||
# holds the dependency. The stores are chroot stores, so the realise runs in
|
||||
# this process where a host runs it in the daemon; the substitution logic is
|
||||
# the same.
|
||||
#
|
||||
# The last cases are hive-priv's step before its `nix store verify`
|
||||
# (hive-priv/src/closure.rs): a path the host built itself is valid in its store
|
||||
# without the swarm signature, the pull leaves it so, and `nix store copy-sigs`
|
||||
# imports the signature from the cache.
|
||||
set -uo pipefail
|
||||
|
||||
failures=0
|
||||
|
|
@ -91,5 +96,44 @@ conf plain ""
|
|||
if pull plain d2; then fail "realised without the swarm cache"; fi
|
||||
expect "no top path" "$(valid d2 "$top")" no
|
||||
|
||||
# A path built on the host is input-addressed and so, unlike the toFile paths
|
||||
# above, has no content address that lets `nix store verify` pass it unsigned.
|
||||
# The stores here share one store directory (`store=`), as cache and host do.
|
||||
case="setup: a path built in the host store, and the swarm cache holding it signed"
|
||||
: "${HIVE_TEST_BASH:=$(command -v bash)}"
|
||||
host="local?store=$T/hs&state=$T/hs-state&log=$T/hs-log"
|
||||
built=$(nix build --store "$host" --no-link --print-out-paths --impure --expr "derivation { name = \"agent-closure\"; system = builtins.currentSystem; builder = \"$HIVE_TEST_BASH\"; args = [ \"-c\" \"echo agent > \$out\" ]; }")
|
||||
nix copy --from "$host" --to "file://$T/signed?store=$T/hs&secret-key=$T/swarm.sec" "$built"
|
||||
swarm_key=$(cat "$T/swarm.pub")
|
||||
other_key=$(cat "$T/up.pub")
|
||||
|
||||
# verify <key>: hive-priv's check, against the host store.
|
||||
verify() {
|
||||
nix store verify --store "$host" --no-contents --sigs-needed 1 --option trusted-public-keys "$1" "$built" >/dev/null 2>&1
|
||||
echo $?
|
||||
}
|
||||
# copy_sigs <cache url>: the exact arguments of copy_sigs_args, bar the store.
|
||||
copy_sigs() {
|
||||
nix store copy-sigs --store "$host" --substituter "$1" --option netrc-file "$T/netrc" "$built" >/dev/null 2>"$T/err"
|
||||
}
|
||||
|
||||
case="control: the path is valid in the host store and the cache serves it signed"
|
||||
expect "valid" "$(nix-store --store "$host" --check-validity "$built" && echo yes || echo no)" yes
|
||||
expect "the cache has its narinfo" "$(if [ -e "$T/signed/$(basename "$built" | cut -c1-32).narinfo" ]; then echo yes; else echo no; fi)" yes
|
||||
|
||||
case="a path valid in the host store without the signature fails the verify"
|
||||
expect "verify exit" "$(verify "$swarm_key")" 2
|
||||
|
||||
case="a cache without the path imports nothing and the verify still refuses"
|
||||
copy_sigs "file://$T/empty-cache?store=$T/hs"
|
||||
expect "copy-sigs exit" $? 0
|
||||
expect "verify exit" "$(verify "$swarm_key")" 2
|
||||
|
||||
case="the cache serving the path imports the signature and the verify passes"
|
||||
copy_sigs "file://$T/signed?store=$T/hs"
|
||||
expect "copy-sigs exit" $? 0
|
||||
expect "verify exit" "$(verify "$swarm_key")" 0
|
||||
expect "another key still fails" "$(verify "$other_key")" 2
|
||||
|
||||
echo "$count checks, $failures failed"
|
||||
[ "$failures" = 0 ]
|
||||
|
|
|
|||
Loading…
Reference in a new issue