hive-c0re pulls closures through the nix daemon; hive-priv verifies the swarm signature before applying
This commit is contained in:
parent
448b703995
commit
ecd4445680
21 changed files with 1257 additions and 584 deletions
|
|
@ -154,11 +154,11 @@ the swarm builder built the agent from the updated repo (the
|
||||||
`MetaSync`; its `Create` reads the record once.
|
`MetaSync`; its `Create` reads the record once.
|
||||||
- **The closure.** The hive builds nothing. `Prebuild`, `Swap` and `Create` read
|
- **The closure.** The hive builds nothing. `Prebuild`, `Swap` and `Create` read
|
||||||
the agent's record in the swarm's `agent-closures` bucket (the controller
|
the agent's record in the swarm's `agent-closures` bucket (the controller
|
||||||
writes it from the builder's reply) and have `hive-priv` download the closure
|
writes it from the builder's reply) and pull the closure it names from the
|
||||||
it names from the swarm cache (`RealiseClosure`). `Swap` and `Create` then
|
swarm cache through the host's nix daemon. `Swap` and `Create` then give
|
||||||
give `hive-priv` the store path (`toplevel` on `UpdateContainer` and
|
`hive-priv` the store path (`toplevel` on `UpdateContainer` and
|
||||||
`CreateContainer`), which accepts only the path its last `RealiseClosure` for
|
`CreateContainer`), which accepts only a path in the store that carries the
|
||||||
that agent returned. On the wire the
|
swarm cache's signature. On the wire the
|
||||||
`hive_priv_sock::PrivRequest` variants carry a required field:
|
`hive_priv_sock::PrivRequest` variants carry a required field:
|
||||||
`{"op":"update_container","name":"atlas","stream":true,"toplevel":"/nix/store/<hash>-nixos-system-atlas"}`.
|
`{"op":"update_container","name":"atlas","stream":true,"toplevel":"/nix/store/<hash>-nixos-system-atlas"}`.
|
||||||
A rebuild with no record, or none the builder pushed, fails with an error
|
A rebuild with no record, or none the builder pushed, fails with an error
|
||||||
|
|
|
||||||
|
|
@ -151,16 +151,16 @@ pieces fit, and the reasoning behind them, is in [`credentials.md`](credentials.
|
||||||
|
|
||||||
### Hive-level — one of each per hive
|
### Hive-level — one of each per hive
|
||||||
|
|
||||||
| secret | generated by | lives at |
|
| secret | generated by | lives at |
|
||||||
| ---------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| hive leaf certs | `hive-tls.nix`, issued by the secret store's `pki` mount | `<deploy.hive-controller.tls.stateDir>/<name>.pem` |
|
| hive leaf certs | `hive-tls.nix`, issued by the secret store's `pki` mount | `<deploy.hive-controller.tls.stateDir>/<name>.pem` |
|
||||||
| the hive's own store identity | `glue-bao-tls.nix` on the store's host; placed by hand on every other host ([`ca.md`](../../4-implementation/swarm/ca.md#the-gateway-leaf-comes-from-the-secret-store)) | `deploy.hive-controller.tls.baoHiveIdentityCertFile` / `baoHiveIdentityKeyFile` |
|
| the hive's own store identity | `glue-bao-tls.nix` on the store's host; placed by hand on every other host ([`ca.md`](../../4-implementation/swarm/ca.md#the-gateway-leaf-comes-from-the-secret-store)) | `deploy.hive-controller.tls.baoHiveIdentityCertFile` / `baoHiveIdentityKeyFile` |
|
||||||
| the cache's public key and pull token, for hive-priv | `swarm-controller` copies them to `swarm/hives/<hive>/attic/{public-key,pull-token}` (see the cache-facts row above) | `hive-priv-cache-facts` (root) reads those two under the hive's own store identity every ten minutes and writes `/run/hyperhive-priv/attic/public-key` and `netrc` (tmpfs, root, each `0600`, directory `0700`); the cache's address comes from `services.hyperhive.swarm.attic.domain` and `cache`, not from the store. A store that holds no facts, or that doesn't answer, leaves the files as they stand, and a hive that never got them has `hive-priv` answer `not available`. |
|
| the cache's public key and pull token, for the host's nix daemon and hive-priv | `swarm-controller` copies them to `swarm/hives/<hive>/attic/{public-key,pull-token}` (see the cache-facts row above) | `hive-priv-cache-facts` (root) reads those two under the hive's own store identity every ten minutes and writes `/run/hyperhive-priv/attic/public-key` and `netrc` (tmpfs, root, each `0600`, directory `0700`); it also writes `/run/hyperhive-nix/cache.conf` (tmpfs, root, `0644`, no secret in it): the cache's address, the key and the netrc's path as nix.conf settings that the host's nix.conf includes. The cache's address comes from `services.hyperhive.swarm.attic.domain` and `cache`, not from the store. A store that holds no facts, or that doesn't answer, leaves the files as they stand; a hive that never got them has `hive-c0re` ask for a refresh and then fail the pull with `this hive has no cache facts yet`. `hive-core` reads only the fragment, for the address. |
|
||||||
| the forge's copy of its OIDC secret | `swarm-bao-forge-oidc.service` reads it out of the swarm secret store under the forge publisher's leaf, wherever authelia runs | `/run/hive-forge-oidc/<id>.secret` on the host (tmpfs), bound into the forge container, handed to `forgejo-sso-source` through `LoadCredential` |
|
| the forge's copy of its OIDC secret | `swarm-bao-forge-oidc.service` reads it out of the swarm secret store under the forge publisher's leaf, wherever authelia runs | `/run/hive-forge-oidc/<id>.secret` on the host (tmpfs), bound into the forge container, handed to `forgejo-sso-source` through `LoadCredential` |
|
||||||
| the homeserver's copy of its OIDC secret | `swarm-bao-matrix-oidc.service` reads it out of the swarm secret store under matrix-ctl's leaf, wherever authelia runs | `/run/hive-matrix-oidc/<id>.secret` on the host (tmpfs), bound into the homeserver container, handed to tuwunel through `LoadCredential` |
|
| the homeserver's copy of its OIDC secret | `swarm-bao-matrix-oidc.service` reads it out of the swarm secret store under matrix-ctl's leaf, wherever authelia runs | `/run/hive-matrix-oidc/<id>.secret` on the host (tmpfs), bound into the homeserver container, handed to tuwunel through `LoadCredential` |
|
||||||
| this hive's forge read token | `swarm-controller`, into the secret store at `swarm/hives/<hive>/forge/token`, for the `hive-<hive>` account; a pass at start and every five minutes re-mints a missing or stale token | `hive-c0re` reads it under the hive's own store identity for each forge read, and hands it to the `git` commands it runs. It keeps no copy on disk |
|
| this hive's forge read token | `swarm-controller`, into the secret store at `swarm/hives/<hive>/forge/token`, for the `hive-<hive>` account; a pass at start and every five minutes re-mints a missing or stale token | `hive-c0re` reads it under the hive's own store identity for each forge read, and hands it to the `git` commands it runs. It keeps no copy on disk |
|
||||||
| squid's users file | `hive-c0re` (`workers::squid_users`), every 30 seconds, from the proxy credentials of its agents and the self-check | `services.hyperhive.deploy.hive-controller.squid.usersFile` (`/var/lib/hive-squid/users`, `hive-core:squid`, `0640`): `<user>:<hash>` lines, SHA-512 crypt hashes only, renamed into place when it changed. `unrestricted` beside it lists the users whose agent holds the swarm's `unrestricted_internet` flag, names only; it lists every agent the hive never read as off `systemd-tmpfiles` creates it empty so squid's helper never sees a missing file. A hive whose agents have no credential yet (an older controller) has an empty file: every login fails and the self-check reports `ok=false`; an agent without one sends none, so it goes direct |
|
| squid's users file | `hive-c0re` (`workers::squid_users`), every 30 seconds, from the proxy credentials of its agents and the self-check | `services.hyperhive.deploy.hive-controller.squid.usersFile` (`/var/lib/hive-squid/users`, `hive-core:squid`, `0640`): `<user>:<hash>` lines, SHA-512 crypt hashes only, renamed into place when it changed. `unrestricted` beside it lists the users whose agent holds the swarm's `unrestricted_internet` flag, names only; it lists every agent the hive never read as off `systemd-tmpfiles` creates it empty so squid's helper never sees a missing file. A hive whose agents have no credential yet (an older controller) has an empty file: every login fails and the self-check reports `ok=false`; an agent without one sends none, so it goes direct |
|
||||||
| the squid self-check's password | `swarm-controller` mints `swarm/hives/<hive>/squid-selfcheck`; `squid-selfcheck-credential` (root, every 10 minutes) reads it as the hive | `/run/credstore/hive-squid.selfcheck` (tmpfs, root, `0400`), imported by `squid-selfcheck.service` as a systemd credential and handed to `curl` on its stdin. Both self-check users (`selfcheck.squid` and the always-unrestricted `selfcheck-unrestricted.squid`) use it, so whoever holds it has unrestricted egress through this squid Missing, or different from the users file: the self-check logs `credential=absent` or a 407 and fails, and nothing else breaks |
|
| the squid self-check's password | `swarm-controller` mints `swarm/hives/<hive>/squid-selfcheck`; `squid-selfcheck-credential` (root, every 10 minutes) reads it as the hive | `/run/credstore/hive-squid.selfcheck` (tmpfs, root, `0400`), imported by `squid-selfcheck.service` as a systemd credential and handed to `curl` on its stdin. Both self-check users (`selfcheck.squid` and the always-unrestricted `selfcheck-unrestricted.squid`) use it, so whoever holds it has unrestricted egress through this squid Missing, or different from the users file: the self-check logs `credential=absent` or a 407 and fails, and nothing else breaks |
|
||||||
|
|
||||||
### Store identities, one per principal
|
### Store identities, one per principal
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,11 +42,11 @@ because there is no malformed spec to reject.
|
||||||
|
|
||||||
Nix-heavy — hold one of the `buildSlots` permits for the node's duration:
|
Nix-heavy — hold one of the `buildSlots` permits for the node's duration:
|
||||||
|
|
||||||
| Node | Wraps |
|
| Node | Wraps |
|
||||||
| ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `Prebuild` | download the swarm's closure for the agent into the store while the container keeps serving (its meta preamble is the upstream `MetaSync` node). With no closure to deploy it fails here, before the stop. Skipped when the container is already down; `Swap` downloads it instead |
|
| `Prebuild` | pull the swarm's closure for the agent into the store through the host's nix daemon while the container keeps serving (its meta preamble is the upstream `MetaSync` node). With no closure to deploy it fails here, before the stop. Skipped when the container is already down; `Swap` downloads it instead |
|
||||||
| `Swap` | drop-in rewrite + profile-swap onto the swarm's closure (requires the container stopped); the post-swap bookkeeping tail lives in the sibling `RebuildBookkeeping` node |
|
| `Swap` | drop-in rewrite + profile-swap onto the swarm's closure (requires the container stopped); the post-swap bookkeeping tail lives in the sibling `RebuildBookkeeping` node |
|
||||||
| `Create` | first-spawn `nixos-container create` proper; assumes the upstream `Provision` node already registered the agent; downloads the closure the swarm built for the agent and creates the container from it (with none to deploy it fails and creates nothing), then marks the closure applied |
|
| `Create` | first-spawn `nixos-container create` proper; assumes the upstream `Provision` node already registered the agent; pulls the closure the swarm built for the agent and creates the container from it (with none to deploy it fails and creates nothing), then marks the closure applied |
|
||||||
|
|
||||||
Cheap — no build slot:
|
Cheap — no build slot:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -452,7 +452,8 @@ you change `swarm.attic.domain`, delete the stale `url` leaf and restart
|
||||||
replaces the stored one with the key `attic cache info` prints, so a cache
|
replaces the stored one with the key `attic cache info` prints, so a cache
|
||||||
that loses its database and gets a new key needs no step: the next run of the
|
that loses its database and gets a new key needs no step: the next run of the
|
||||||
unit stores the new key, the controller copies it to the hives within five
|
unit stores the new key, the controller copies it to the hives within five
|
||||||
minutes, and `hive-priv-cache-facts` reads it within ten.
|
minutes, and `hive-priv-cache-facts` reads it within ten (or at once, when a
|
||||||
|
rebuild finds the host without facts).
|
||||||
|
|
||||||
`swarm.attic.domain` and `swarm.attic.cache` default to `attic.<swarm domain>`
|
`swarm.attic.domain` and `swarm.attic.cache` default to `attic.<swarm domain>`
|
||||||
and `swarm` on every host, whether or not it runs the cache, so a builder on any
|
and `swarm` on every host, whether or not it runs the cache, so a builder on any
|
||||||
|
|
@ -532,14 +533,17 @@ identity and writes `/run/hyperhive/attic/netrc`, `substituter` and
|
||||||
token goes out in a header and in the netrc; a hive that finds another scheme
|
token goes out in a header and in the netrc; a hive that finds another scheme
|
||||||
writes nothing.
|
writes nothing.
|
||||||
|
|
||||||
`hive-priv` doesn't read those files. The key that must have signed a download and
|
`hive-priv` and the nix daemon don't read those files. The key that must have
|
||||||
the pull token come from `hive-priv-cache-facts`, a root unit that reads the
|
signed a download and the pull token come from `hive-priv-cache-facts`, a root
|
||||||
hive's own `attic/public-key` and `attic/pull-token` from the store under the
|
unit that reads the hive's own `attic/public-key` and `attic/pull-token` from
|
||||||
hive's own store identity every ten minutes and writes them to
|
the store under the hive's own store identity every ten minutes and writes them
|
||||||
`/run/hyperhive-priv/attic/` (`0700`, root; each file `0600`). The cache's
|
to `/run/hyperhive-priv/attic/` (`0700`, root; each file `0600`). It also writes
|
||||||
address is the host's `services.hyperhive.swarm.attic.domain` and `cache`,
|
the nix.conf fragment `/run/hyperhive-nix/cache.conf`, which the host's nix.conf
|
||||||
handed to `hive-priv` in its unit's environment. A file `hive-c0re` writes
|
includes: the cache's address (the host's `services.hyperhive.swarm.attic.domain`
|
||||||
decides nothing `hive-priv` trusts.
|
and `cache`) as a trusted substituter, the key as a trusted key and the netrc's
|
||||||
|
path. It restarts `nix-daemon` only when the fragment changed after the daemon
|
||||||
|
started, which matters for CppNix; Lix re-reads nix.conf on every connection. A
|
||||||
|
file `hive-c0re` writes decides nothing the daemon or `hive-priv` trusts.
|
||||||
|
|
||||||
The same pass checks each of the hive's agents. It reads the agent's record in
|
The same pass checks each of the hive's agents. It reads the agent's record in
|
||||||
`agent-closures`, then fetches the closure's narinfo from
|
`agent-closures`, then fetches the closure's narinfo from
|
||||||
|
|
@ -581,17 +585,20 @@ means check attic's fingerprint format; it says nothing about an attacker. A
|
||||||
### Rebuilds download the closure
|
### Rebuilds download the closure
|
||||||
|
|
||||||
A hive builds nothing. A rebuild's `Prebuild` node, which runs while the agent is
|
A hive builds nothing. A rebuild's `Prebuild` node, which runs while the agent is
|
||||||
still up, reads the agent's record in `agent-closures` and asks `hive-priv` to
|
still up, reads the agent's record in `agent-closures` and has `hive-c0re` pull
|
||||||
realise the record's store path (`RealiseClosure`). `hive-priv` runs
|
the record's store path. `hive-c0re` isn't root and needs no root: it runs
|
||||||
`nix-store --realise` with `--max-jobs 0`, so nix can't build the path, and with
|
`nix-store --realise <path> --max-jobs 0 --option substituters <cache address>`
|
||||||
the host's configured cache address, and the public key and netrc from the
|
through the host's nix daemon. The address is the one in the fragment above, the
|
||||||
root-owned `/run/hyperhive-priv/attic/`, as per-call settings. The settings replace the host's `substituters` and
|
only substituter the daemon lets an unprivileged client name. The daemon
|
||||||
`trusted-public-keys` and set `require-sigs true`: the swarm cache is the only
|
ignores a client's keys, `require-sigs` and netrc, so it stores the path only
|
||||||
source, and nix stores the path only when the cache's key signed it. The host's
|
when a key it trusts signed it. When the fragment is missing, `hive-c0re` first
|
||||||
nix configuration stays as it was.
|
asks `hive-priv` to run `hive-priv-cache-facts` (up to 60 s) instead of waiting for
|
||||||
|
its timer. A host that still has no facts, a signature refusal and a path the
|
||||||
|
cache lacks each fail with their own message.
|
||||||
|
|
||||||
`Swap` then does the same lookup and download, a no-op once the path is in the
|
`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. A stopped agent skips
|
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
|
||||||
`Prebuild`, so `Swap` downloads it. A first deploy's `Create` node does the
|
`Prebuild`, so `Swap` downloads it. A first deploy's `Create` node does the
|
||||||
same and creates the container from that path.
|
same and creates the container from that path.
|
||||||
|
|
||||||
|
|
@ -608,13 +615,13 @@ an unreachable cache costs is one function.
|
||||||
|
|
||||||
Mixed versions:
|
Mixed versions:
|
||||||
|
|
||||||
| hive | controller or record | what happens |
|
| hive | controller or record | what happens |
|
||||||
| ---- | -------------------- | ------------------------------------------------------------------------------------------ |
|
| ---- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| old | any | builds locally, as before |
|
| old | any | builds locally, as before |
|
||||||
| new | old, or no record | refuses the create; a running agent keeps running, and the rebuild logs an error naming it |
|
| new | old, or no record | refuses the create; a running agent keeps running, and the rebuild logs an error naming it |
|
||||||
| new | cache down | the download fails; same as no record, with the download's error in the log |
|
| new | cache down | the download fails; same as no record, with the download's error in the log |
|
||||||
| new | signature mismatch | nix refuses the path; same as no record, and `hive-priv` logs the refusal as well |
|
| new | signature mismatch | nix refuses the path; same as no record, and `hive-priv` logs the refusal as well |
|
||||||
| new | old `hive-priv` | it refuses the request, which has no `meta_rev`, so the swap or create fails |
|
| new | old `hive-priv` | it refuses the apply of a path it didn't download; `hive-c0re` has it download the path (already in the store) and applies again |
|
||||||
|
|
||||||
## Credentials
|
## Credentials
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,35 +28,36 @@ for every operation that genuinely requires root.
|
||||||
**Narrow interface** — `PrivRequest` variants map 1:1 to specific
|
**Narrow interface** — `PrivRequest` variants map 1:1 to specific
|
||||||
known operations; there is no arbitrary command pass-through:
|
known operations; there is no arbitrary command pass-through:
|
||||||
|
|
||||||
| Operation | What it runs |
|
| Operation | What it runs |
|
||||||
| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `StartContainer` / `StopContainer` | `nixos-container start/stop <name>` |
|
| `StartContainer` / `StopContainer` | `nixos-container start/stop <name>` |
|
||||||
| `KillContainer` | `machinectl kill <machine> --signal=SIGKILL` (`nixos-container` has no kill verb) |
|
| `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. |
|
| `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 be the store path the last successful `RealiseClosure` returned for that agent (in memory, per agent), so hive-c0re can't name a path never realised for the agent; hive-priv builds nothing. The binding doesn't mean hive-c0re can't choose the closure: it can still ask hive-priv to download any swarm-signed path for any agent and then apply it. The swarm key and the cache bound that to a cross-assignment risk (one agent gets another's closure), not an arbitrary-path one |
|
| `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` | `nix-store --realise <store_path> --max-jobs 0` against the swarm cache, with the host's configured cache address, and the public key and netrc from root-owned `/run/hyperhive-priv/attic/`, as per-call settings that replace the host's substituters and trusted keys and require a signature. The path must be `/nix/store/<32 chars>-<name>` and the agent name valid. It downloads and never builds |
|
| `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 |
|
||||||
| `DestroyContainer` | `nixos-container destroy <name>` |
|
| `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 its cache fragment is missing |
|
||||||
| `ListContainers` | `nixos-container list` |
|
| `DestroyContainer` | `nixos-container destroy <name>` |
|
||||||
| `ReloadGatewayNginx` | `systemctl reload/start/reset-failed nginx` (host unit; the unit name is hard-coded, not a parameter) |
|
| `ListContainers` | `nixos-container list` |
|
||||||
| `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 |
|
| `ReloadGatewayNginx` | `systemctl reload/start/reset-failed nginx` (host unit; the unit name is hard-coded, not a parameter) |
|
||||||
| `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 |
|
| `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 |
|
||||||
| `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 |
|
| `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 |
|
||||||
| `RemoveServiceDropin` | remove `container@<name>.service.d/` drop-in on destroy, and the GC root of the hive-priv build it named |
|
| `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 |
|
||||||
| `DaemonReload` | `systemctl daemon-reload` |
|
| `RemoveServiceDropin` | remove `container@<name>.service.d/` drop-in on destroy, and the GC root of the hive-priv build it named |
|
||||||
| `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) |
|
| `DaemonReload` | `systemctl daemon-reload` |
|
||||||
| `SyncAgentTmpfiles` | unlink `/etc/tmpfiles.d/hyperhive-agents.conf` and return `Ok` |
|
| `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) |
|
||||||
| `SetAgentPaused` | create / remove the `<state>/<name>/harness/paused` marker that parks an agent's turn loop |
|
| `SyncAgentTmpfiles` | unlink `/etc/tmpfiles.d/hyperhive-agents.conf` and return `Ok` |
|
||||||
| `EnsureAgentSubvolume` | `btrfs subvolume create <state>/<name>` for a new agent — no-op when the path exists or the filesystem isn't btrfs |
|
| `SetAgentPaused` | create / remove the `<state>/<name>/harness/paused` marker that parks an agent's turn loop |
|
||||||
| `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 |
|
| `EnsureAgentSubvolume` | `btrfs subvolume create <state>/<name>` for a new agent — no-op when the path exists or the filesystem isn't btrfs |
|
||||||
| `DeleteAgentSubvolume` | `btrfs subvolume delete <state>/<name>` — purge path only, never a plain destroy |
|
| `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 |
|
||||||
| `EnsureBtrfsQuota` | `btrfs quota enable <AGENT_STATE_ROOT>`. Operator opt-in — enabling forces a full rescan |
|
| `DeleteAgentSubvolume` | `btrfs subvolume delete <state>/<name>` — purge path only, never a plain destroy |
|
||||||
| `ReadSubvolumeUsage` | `btrfs qgroup show -f --raw <state>/<name>` |
|
| `EnsureBtrfsQuota` | `btrfs quota enable <AGENT_STATE_ROOT>`. Operator opt-in — enabling forces a full rescan |
|
||||||
| `SetSubvolumeQuota` | `btrfs qgroup limit <bytes\|none> <state>/<name>` |
|
| `ReadSubvolumeUsage` | `btrfs qgroup show -f --raw <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 |
|
| `SetSubvolumeQuota` | `btrfs qgroup limit <bytes\|none> <state>/<name>` |
|
||||||
| `SnapshotAgentSubvolume` | `btrfs subvolume snapshot -r <agent_root> <snapshot_path>` — the frozen point-in-time copy a migration streams from |
|
| `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 |
|
||||||
| `DeleteAgentSnapshot` | `btrfs subvolume delete <snapshot_path>` |
|
| `SnapshotAgentSubvolume` | `btrfs subvolume snapshot -r <agent_root> <snapshot_path>` — the frozen point-in-time copy a migration streams from |
|
||||||
| `SendAgentSnapshotToFile` | `btrfs send [-p <parent>] <snapshot>` into a bare filename under `MIGRATE_STAGING_ROOT`; refuses to overwrite an existing export |
|
| `DeleteAgentSnapshot` | `btrfs subvolume delete <snapshot_path>` |
|
||||||
| `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 |
|
| `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
|
**Strings that reach root-loaded config** — `WriteResourceLimits` and
|
||||||
`WriteNspawnFlags` copy request strings into a systemd drop-in and an env
|
`WriteNspawnFlags` copy request strings into a systemd drop-in and an env
|
||||||
|
|
@ -68,39 +69,61 @@ refuses, never escapes, any value outside its shape. `memory_max` is
|
||||||
bind or credential path is absolute with no whitespace, control byte, `\`,
|
bind or credential path is absolute with no whitespace, control byte, `\`,
|
||||||
`$`, backtick, `"` or `:`. The size limits of a value stay with systemd.
|
`$`, backtick, `"` or `:`. The size limits of a value stay with systemd.
|
||||||
|
|
||||||
**Closures from the swarm cache** — `RealiseClosure` is how a hive's rebuild
|
**Closures from the swarm cache** — `hive-c0re` pulls the closure the swarm
|
||||||
takes the closure the swarm built instead of building it. Root-owned sources
|
built through the host's nix daemon, and `hive-priv` applies it only when the
|
||||||
alone decide what root accepts:
|
swarm's key signed it. Pulling needs no root: `nix-store --realise` of a store
|
||||||
|
path is a request to the daemon. Root-owned sources alone decide what the
|
||||||
|
daemon accepts:
|
||||||
|
|
||||||
- The cache signs. The swarm's cache (attic) holds the signing key and signs
|
- The cache signs. The swarm's cache (attic) holds the signing key and signs
|
||||||
each narinfo it serves; the private key never leaves it. The builder pushes
|
each narinfo it serves; the private key never leaves it. The builder pushes
|
||||||
with a push token and holds no signing key.
|
with a push token and holds no signing key.
|
||||||
- The address is the host's configuration:
|
- The address is the host's configuration:
|
||||||
`services.hyperhive.swarm.attic.domain` and `cache` become
|
`services.hyperhive.swarm.attic.domain` and `cache`. The root unit
|
||||||
`HIVE_PRIV_CACHE_URL` in the `hive-priv` unit. No request and no file chooses
|
`hive-priv-cache-facts` lists it as `extra-trusted-substituters` in a nix.conf
|
||||||
it, and `hive-priv` refuses an address that isn't `https`.
|
fragment, `/run/hyperhive-nix/cache.conf`, that the host's nix.conf includes
|
||||||
- The key and the pull token come from the secret store. The root unit
|
(`!include`, so a host whose unit hasn't run yet still starts).
|
||||||
`hive-priv-cache-facts` reads the hive's own copies under the hive's own store
|
- The key and the pull token come from the secret store. The unit reads the
|
||||||
identity and writes them to `/run/hyperhive-priv/attic/` (directory `0700`,
|
hive's own copies under the hive's own store identity and writes the key to
|
||||||
files `0600`, root). `hive-priv` reads nothing from `/run/hyperhive/attic/`,
|
`/run/hyperhive-priv/attic/public-key` and the token to `netrc` there
|
||||||
which `hive-c0re` writes for the probe, and it refuses a file in the root
|
(directory `0700`, files `0600`, root), and the key into the fragment as
|
||||||
directory that isn't a plain file owned by root or that others can read, and
|
`extra-trusted-public-keys` and the netrc's path as `netrc-file`. The fragment
|
||||||
the directory under the same rule.
|
holds no secret. `hive-c0re` reads its cache address, and nothing else, from
|
||||||
- Nix verifies. For that one call `hive-priv` sets `substituters` and
|
it; `hive-priv` reads nothing from `/run/hyperhive/attic/`, which `hive-c0re`
|
||||||
`trusted-public-keys` to the swarm cache and its key, and `require-sigs true`.
|
writes for the probe.
|
||||||
A path without a signature by that key isn't stored; `hive-priv` answers
|
- The daemon enforces. `hive-core` is an unprivileged client: the daemon takes
|
||||||
`not available` and logs the refusal at error level. `--max-jobs 0` means no
|
its `--option substituters` only for an address in `trusted-substituters`,
|
||||||
answer, whatever the cache sends, starts a build as root.
|
ignores its `trusted-public-keys`, `require-sigs` and `netrc-file`, and
|
||||||
- A hive realises only paths whose signature checks. The request carries an
|
refuses a path no trusted key signed. `--max-jobs 0` means no answer, whatever
|
||||||
agent name and a store path, both validated, and no address, key or file; any
|
the cache sends, starts a build.
|
||||||
other field never reaches the verb.
|
- `hive-priv` verifies. Before `CreateContainer` or `UpdateContainer` applies a
|
||||||
|
toplevel, it 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.
|
||||||
|
- When the fragment is missing, `hive-c0re` asks `hive-priv` for `RefreshCacheFacts`,
|
||||||
|
which starts the facts unit and waits up to 60 s, instead of waiting for the
|
||||||
|
unit's timer. A host the swarm hasn't given facts to answers
|
||||||
|
`this hive has no cache facts yet`.
|
||||||
|
|
||||||
What a compromised `hive-c0re` can do with this verb: ask for any store path
|
What a compromised `hive-c0re` can do: pull any store path that the swarm cache
|
||||||
that has the shape of one and that the swarm cache signed, which fills the
|
signed, which fills the store with paths the swarm vouches for, and then ask
|
||||||
store with paths the swarm vouches for. It can't make root accept a path from
|
`hive-priv` to apply any such path to any agent. Before this change it could
|
||||||
a cache or under a key of its choosing, because it writes none of the places
|
already do both through `RealiseClosure` and an apply; the former check bound an
|
||||||
those come from. The trust anchor is the cache's signing key and the store that
|
apply to the path `hive-priv` itself downloaded for that agent, and not to the
|
||||||
hands out its public key, as for every other credential.
|
agent the swarm built it for. It can't make root accept a path from a cache or
|
||||||
|
under a key of its choosing, because it writes none of the places those come
|
||||||
|
from. The trust anchor is the cache's signing key and the store that hands out
|
||||||
|
its public key, as for every other credential.
|
||||||
|
|
||||||
|
**Mixed versions on one host.** `hive-priv` and `hive-c0re` restart separately.
|
||||||
|
A new `hive-c0re` with an older `hive-priv`: the older one refuses an apply of a
|
||||||
|
path it didn't download, so `hive-c0re` answers that refusal by sending the
|
||||||
|
older `RealiseClosure`, which downloads and pins the path (already in the
|
||||||
|
store), and applies again. A new `hive-priv` with an older `hive-c0re`: it still
|
||||||
|
accepts `RealiseClosure`, answers `Ok` for a path already in the store with the
|
||||||
|
swarm's signature and `not available` for one that isn't, and applies. Both
|
||||||
|
shims go in the release after this one.
|
||||||
|
|
||||||
**Container allowlist** — `hive-priv` validates every request against
|
**Container allowlist** — `hive-priv` validates every request against
|
||||||
an allowlist before any operation: the allowlist accepts only names
|
an allowlist before any operation: the allowlist accepts only names
|
||||||
|
|
|
||||||
484
hive-c0re/src/cache_pull.rs
Normal file
484
hive-c0re/src/cache_pull.rs
Normal file
|
|
@ -0,0 +1,484 @@
|
||||||
|
//! Pull the swarm's closure into the store through the host's nix daemon.
|
||||||
|
//!
|
||||||
|
//! hive-c0re is not root and does not need to be: `nix-store --realise` of a
|
||||||
|
//! store path is a request to the daemon, which substitutes it. What the daemon
|
||||||
|
//! accepts is root's: the root unit `hive-priv-cache-facts` writes a nix.conf
|
||||||
|
//! fragment ([`CONF_FRAGMENT`]) that lists the swarm cache as a
|
||||||
|
//! `trusted-substituter`, adds its key to `trusted-public-keys` and names the
|
||||||
|
//! netrc. An unprivileged client may name a substituter only from that list,
|
||||||
|
//! cannot lower `require-sigs` or replace the keys, and a path without a
|
||||||
|
//! signature by a trusted key is refused. hive-priv then applies the toplevel
|
||||||
|
//! only if the swarm's key signed it.
|
||||||
|
//!
|
||||||
|
//! The cache's address is read from the fragment, so the address named is
|
||||||
|
//! byte for byte the one the daemon trusts. A missing fragment means this host
|
||||||
|
//! has not been given its cache facts; [`pull`] first asks hive-priv to run the
|
||||||
|
//! facts unit, which is how the swarm's copy of the facts reaches the host
|
||||||
|
//! without waiting for the unit's timer.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use hive_priv_sock::valid_closure_path;
|
||||||
|
|
||||||
|
/// The nix.conf fragment `hive-priv-cache-facts` writes (root-owned, readable).
|
||||||
|
pub const CONF_FRAGMENT: &str = "/run/hyperhive-nix/cache.conf";
|
||||||
|
|
||||||
|
/// The fragment's line that lists the cache the daemon lets a client name.
|
||||||
|
const URL_SETTING: &str = "extra-trusted-substituters";
|
||||||
|
|
||||||
|
/// Wall-clock bound on one pull. A stalled transfer is bounded by nix's own
|
||||||
|
/// `stalled-download-timeout`; this covers the rest.
|
||||||
|
const PULL_TIMEOUT: Duration = Duration::from_mins(30);
|
||||||
|
|
||||||
|
/// Why a closure was not pulled.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum PullError {
|
||||||
|
/// This host has no cache facts: no fragment, no address in it, or a
|
||||||
|
/// daemon that has not loaded it. Waiting for the facts unit fixes it.
|
||||||
|
FactsMissing(String),
|
||||||
|
/// Nix refused the path: no signature by a key this host trusts.
|
||||||
|
SignatureRefused(String),
|
||||||
|
/// The cache does not have the path.
|
||||||
|
NotFound(String),
|
||||||
|
/// Anything else nix said, or could not do.
|
||||||
|
Failed(String),
|
||||||
|
/// Nix was still running at [`PULL_TIMEOUT`].
|
||||||
|
TimedOut,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for PullError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::FactsMissing(why) => write!(
|
||||||
|
f,
|
||||||
|
"this hive has no cache facts yet ({why}); a later deploy retries"
|
||||||
|
),
|
||||||
|
Self::SignatureRefused(last) => write!(
|
||||||
|
f,
|
||||||
|
"nix refused the closure, which carries no signature by a key this host trusts: {last}"
|
||||||
|
),
|
||||||
|
Self::NotFound(last) => write!(f, "the swarm cache does not have the closure: {last}"),
|
||||||
|
Self::Failed(last) => f.write_str(last),
|
||||||
|
Self::TimedOut => write!(f, "nix-store timed out after {}s", PULL_TIMEOUT.as_secs()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for PullError {}
|
||||||
|
|
||||||
|
/// What a `nix-store` run came to.
|
||||||
|
pub enum Run {
|
||||||
|
Exited { success: bool, stderr: String },
|
||||||
|
TimedOut,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What [`pull`] does on the host, so a test can stand in for it.
|
||||||
|
pub trait Effects: Sync {
|
||||||
|
/// The fragment's text; `None` when it cannot be read.
|
||||||
|
async fn fragment(&self) -> Option<String>;
|
||||||
|
/// Run the facts unit through hive-priv and wait for it.
|
||||||
|
async fn refresh(&self) -> Result<()>;
|
||||||
|
/// Run `nix-store` with `args`, bounded by [`PULL_TIMEOUT`].
|
||||||
|
async fn nix_store(&self, args: Vec<String>) -> std::io::Result<Run>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cache's address in `fragment`: the value of its
|
||||||
|
/// `extra-trusted-substituters` line, when that is one https address.
|
||||||
|
#[must_use]
|
||||||
|
pub fn cache_url(fragment: &str) -> Option<&str> {
|
||||||
|
fragment.lines().find_map(|line| {
|
||||||
|
let (name, value) = line.split_once('=')?;
|
||||||
|
let value = value.trim();
|
||||||
|
(name.trim() == URL_SETTING
|
||||||
|
&& value.starts_with("https://")
|
||||||
|
&& !value.contains(char::is_whitespace))
|
||||||
|
.then_some(value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `nix-store` arguments that fetch `store_path` and nothing else.
|
||||||
|
///
|
||||||
|
/// - `--option substituters <url>` names the one cache the daemon lists as
|
||||||
|
/// trusted. An unprivileged client's `substituters` that is not on that list
|
||||||
|
/// empties the list and fails the pull, so the address must be the fragment's.
|
||||||
|
/// - `--max-jobs 0` forbids a local build, so a path the cache lacks fails.
|
||||||
|
/// - No `trusted-public-keys`, `require-sigs` or `netrc-file`: the daemon
|
||||||
|
/// ignores them from an unprivileged client and applies its own.
|
||||||
|
#[must_use]
|
||||||
|
pub fn pull_args(store_path: &str, url: &str) -> Vec<String> {
|
||||||
|
[
|
||||||
|
"--realise",
|
||||||
|
store_path,
|
||||||
|
"--max-jobs",
|
||||||
|
"0",
|
||||||
|
"--option",
|
||||||
|
"substituters",
|
||||||
|
url,
|
||||||
|
]
|
||||||
|
.map(str::to_owned)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Name nix's refusal from its stderr. A refusal for want of a signature
|
||||||
|
/// or a daemon that does not trust the address is told apart from a path the
|
||||||
|
/// cache lacks and from a cache that is down.
|
||||||
|
fn classify(stderr: &str) -> PullError {
|
||||||
|
let last = stderr
|
||||||
|
.lines()
|
||||||
|
.rev()
|
||||||
|
.find(|l| !l.trim().is_empty())
|
||||||
|
.unwrap_or("nix-store failed with no message")
|
||||||
|
.trim()
|
||||||
|
.to_owned();
|
||||||
|
let lower = stderr.to_lowercase();
|
||||||
|
if lower.contains("untrusted substituter") {
|
||||||
|
PullError::FactsMissing(
|
||||||
|
"the nix daemon does not list the swarm cache as a trusted substituter".to_owned(),
|
||||||
|
)
|
||||||
|
} else if lower.contains("not signed by any of the keys") || lower.contains("signature") {
|
||||||
|
PullError::SignatureRefused(last)
|
||||||
|
} else if lower.contains("don't know how to build")
|
||||||
|
|| lower.contains("no substituter that can build")
|
||||||
|
{
|
||||||
|
PullError::NotFound(last)
|
||||||
|
} else {
|
||||||
|
PullError::Failed(last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pull `store_path` into the store.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`PullError`]: no cache facts on this host (after one refresh), nix
|
||||||
|
/// refusing the signature, the cache lacking the path, nix failing otherwise,
|
||||||
|
/// or a timeout.
|
||||||
|
pub async fn pull(store_path: &str) -> Result<(), PullError> {
|
||||||
|
pull_with(&Real, store_path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pull_with(fx: &impl Effects, store_path: &str) -> Result<(), PullError> {
|
||||||
|
if !valid_closure_path(store_path) {
|
||||||
|
return Err(PullError::Failed(format!(
|
||||||
|
"{store_path:?} is not a store path"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let url = address_after_refresh(fx).await?;
|
||||||
|
match fx
|
||||||
|
.nix_store(pull_args(store_path, &url))
|
||||||
|
.await
|
||||||
|
.map_err(|e| PullError::Failed(format!("running nix-store: {e}")))?
|
||||||
|
{
|
||||||
|
Run::Exited { success: true, .. } => Ok(()),
|
||||||
|
Run::Exited { stderr, .. } => Err(classify(&stderr)),
|
||||||
|
Run::TimedOut => Err(PullError::TimedOut),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cache's address, after one facts refresh when the host has none.
|
||||||
|
async fn address_after_refresh(fx: &impl Effects) -> Result<String, PullError> {
|
||||||
|
if let Some(url) = address(fx).await {
|
||||||
|
return Ok(url);
|
||||||
|
}
|
||||||
|
let refreshed = fx.refresh().await;
|
||||||
|
address(fx).await.ok_or_else(|| {
|
||||||
|
PullError::FactsMissing(match refreshed {
|
||||||
|
Ok(()) => "the facts unit ran and the swarm has given this hive none".to_owned(),
|
||||||
|
Err(e) => format!("refreshing them failed: {e:#}"),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn address(fx: &impl Effects) -> Option<String> {
|
||||||
|
fx.fragment()
|
||||||
|
.await
|
||||||
|
.as_deref()
|
||||||
|
.and_then(cache_url)
|
||||||
|
.map(str::to_owned)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The host: the fragment on disk, hive-priv for the refresh, `nix-store` from
|
||||||
|
/// the system profile through the daemon.
|
||||||
|
struct Real;
|
||||||
|
|
||||||
|
impl Effects for Real {
|
||||||
|
async fn fragment(&self) -> Option<String> {
|
||||||
|
tokio::fs::read_to_string(CONF_FRAGMENT).await.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh(&self) -> Result<()> {
|
||||||
|
crate::priv_client::refresh_cache_facts().await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn nix_store(&self, args: Vec<String>) -> std::io::Result<Run> {
|
||||||
|
let mut cmd = tokio::process::Command::new("nix-store");
|
||||||
|
// Explicit, so the store is the daemon's whatever the environment.
|
||||||
|
cmd.args(args)
|
||||||
|
.env("NIX_REMOTE", "daemon")
|
||||||
|
.stdin(std::process::Stdio::null())
|
||||||
|
.stdout(std::process::Stdio::null())
|
||||||
|
.kill_on_drop(true);
|
||||||
|
match tokio::time::timeout(PULL_TIMEOUT, cmd.output()).await {
|
||||||
|
Ok(out) => {
|
||||||
|
let out = out?;
|
||||||
|
Ok(Run::Exited {
|
||||||
|
success: out.status.success(),
|
||||||
|
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
Err(_) => Ok(Run::TimedOut),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const PATH: &str = "/nix/store/0123456789abcdfghijklmnpqrsvwxyz-nixos-system-atlas";
|
||||||
|
const URL: &str = "https://cache.example/swarm";
|
||||||
|
|
||||||
|
fn fragment(url: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"extra-trusted-substituters = {url}\nextra-trusted-public-keys = swarm:AAAA\nnetrc-file = /run/hyperhive-priv/attic/netrc"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A host that answers from a script: the fragment as it is now (and the
|
||||||
|
/// one a refresh leaves), what `nix-store` comes to, and a log of calls.
|
||||||
|
struct Fake {
|
||||||
|
fragment: Mutex<Option<String>>,
|
||||||
|
after_refresh: Option<String>,
|
||||||
|
refresh_fails: bool,
|
||||||
|
nix: Mutex<Option<Run>>,
|
||||||
|
calls: Mutex<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Fake {
|
||||||
|
fn new(fragment: Option<String>, nix: Run) -> Self {
|
||||||
|
Self {
|
||||||
|
fragment: Mutex::new(fragment),
|
||||||
|
after_refresh: None,
|
||||||
|
refresh_fails: false,
|
||||||
|
nix: Mutex::new(Some(nix)),
|
||||||
|
calls: Mutex::new(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn calls(&self) -> Vec<String> {
|
||||||
|
self.calls.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Effects for Fake {
|
||||||
|
async fn fragment(&self) -> Option<String> {
|
||||||
|
self.calls.lock().unwrap().push("fragment".to_owned());
|
||||||
|
self.fragment.lock().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn refresh(&self) -> Result<()> {
|
||||||
|
self.calls.lock().unwrap().push("refresh".to_owned());
|
||||||
|
if self.refresh_fails {
|
||||||
|
anyhow::bail!("hive-priv unreachable");
|
||||||
|
}
|
||||||
|
*self.fragment.lock().unwrap() = self.after_refresh.clone();
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn nix_store(&self, args: Vec<String>) -> std::io::Result<Run> {
|
||||||
|
self.calls
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.push(format!("nix-store {}", args.join(" ")));
|
||||||
|
Ok(self.nix.lock().unwrap().take().expect("one run"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exited(success: bool, stderr: &str) -> Run {
|
||||||
|
Run::Exited {
|
||||||
|
success,
|
||||||
|
stderr: stderr.to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn option<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
|
||||||
|
args.windows(3)
|
||||||
|
.find(|w| w[0] == "--option" && w[1] == name)
|
||||||
|
.map(|w| w[2].as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An unprivileged client can name only the trusted address and cannot set
|
||||||
|
/// keys, signatures or the netrc, so the command carries exactly that, and
|
||||||
|
/// never lets nix build.
|
||||||
|
#[test]
|
||||||
|
fn the_command_names_only_the_swarm_cache_and_never_builds() {
|
||||||
|
let args = pull_args(PATH, URL);
|
||||||
|
assert_eq!(args[..2], ["--realise", PATH]);
|
||||||
|
let jobs = args.iter().position(|a| a == "--max-jobs").expect("flag");
|
||||||
|
assert_eq!(args[jobs + 1], "0");
|
||||||
|
assert_eq!(option(&args, "substituters"), Some(URL));
|
||||||
|
let options: Vec<_> = args.iter().filter(|a| *a == "--option").collect();
|
||||||
|
assert_eq!(options.len(), 1, "{args:?}");
|
||||||
|
for forbidden in ["trusted-public-keys", "require-sigs", "netrc-file"] {
|
||||||
|
assert_eq!(option(&args, forbidden), None, "{forbidden}");
|
||||||
|
}
|
||||||
|
assert!(!args.iter().any(|a| a.starts_with("extra-")), "{args:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_address_is_the_fragments_https_line() {
|
||||||
|
assert_eq!(cache_url(&fragment(URL)), Some(URL));
|
||||||
|
for bad in [
|
||||||
|
String::new(),
|
||||||
|
"netrc-file = /x".to_owned(),
|
||||||
|
fragment("http://cache.example/swarm"),
|
||||||
|
fragment("https://cache.example/a b"),
|
||||||
|
fragment(""),
|
||||||
|
// The key's line is not the address's.
|
||||||
|
"extra-trusted-public-keys = https://cache.example/swarm".to_owned(),
|
||||||
|
] {
|
||||||
|
assert_eq!(cache_url(&bad), None, "{bad:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn with_the_fragment_in_place_it_pulls_without_a_refresh() {
|
||||||
|
let fx = Fake::new(Some(fragment(URL)), exited(true, ""));
|
||||||
|
pull_with(&fx, PATH).await.expect("pulled");
|
||||||
|
assert_eq!(
|
||||||
|
fx.calls(),
|
||||||
|
[
|
||||||
|
"fragment".to_owned(),
|
||||||
|
format!("nix-store {}", pull_args(PATH, URL).join(" "))
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Missing facts are refreshed once, and the pull then goes ahead with the
|
||||||
|
/// address the refresh wrote.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_missing_fragment_is_refreshed_before_the_pull() {
|
||||||
|
let mut fx = Fake::new(None, exited(true, ""));
|
||||||
|
fx.after_refresh = Some(fragment(URL));
|
||||||
|
pull_with(&fx, PATH).await.expect("pulled");
|
||||||
|
assert_eq!(
|
||||||
|
fx.calls(),
|
||||||
|
[
|
||||||
|
"fragment".to_owned(),
|
||||||
|
"refresh".to_owned(),
|
||||||
|
"fragment".to_owned(),
|
||||||
|
format!("nix-store {}", pull_args(PATH, URL).join(" "))
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Facts that are still absent after the refresh, whether the unit ran or
|
||||||
|
/// hive-priv could not start it, stop the pull before nix is asked.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn facts_still_missing_after_the_refresh_are_reported_and_nix_is_not_run() {
|
||||||
|
for (case, refresh_fails, want) in [
|
||||||
|
("unit ran", false, "none"),
|
||||||
|
("refresh failed", true, "hive-priv unreachable"),
|
||||||
|
] {
|
||||||
|
let mut fx = Fake::new(None, exited(true, ""));
|
||||||
|
fx.refresh_fails = refresh_fails;
|
||||||
|
let err = pull_with(&fx, PATH).await.expect_err(case);
|
||||||
|
let PullError::FactsMissing(why) = &err else {
|
||||||
|
panic!("{case}: {err:?}");
|
||||||
|
};
|
||||||
|
assert!(why.contains(want), "{case}: {why}");
|
||||||
|
assert!(
|
||||||
|
format!("{err}").contains("a later deploy retries"),
|
||||||
|
"{case}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!fx.calls().iter().any(|c| c.starts_with("nix-store")),
|
||||||
|
"{case}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_fragment_without_an_address_is_refreshed_like_a_missing_one() {
|
||||||
|
let mut fx = Fake::new(Some("netrc-file = /x".to_owned()), exited(true, ""));
|
||||||
|
fx.after_refresh = Some(fragment(URL));
|
||||||
|
pull_with(&fx, PATH).await.expect("pulled");
|
||||||
|
assert!(fx.calls().contains(&"refresh".to_owned()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nixs_refusals_are_told_apart() {
|
||||||
|
for (stderr, want) in [
|
||||||
|
(
|
||||||
|
"warning: ignoring substitute for '/nix/store/x' from 'https://c', as it's not signed by any of the keys in 'trusted-public-keys'\nerror: build of '/nix/store/x' failed",
|
||||||
|
"signature",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"error: cannot add path 'x' because it lacks a signature by a trusted key",
|
||||||
|
"signature",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"warning: ignoring untrusted substituter 'https://c', you are not a trusted user.\ndon't know how to build these paths:\nerror: build of 'x' failed",
|
||||||
|
"facts",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"don't know how to build these paths:\n /nix/store/x\nerror: build of '/nix/store/x' failed",
|
||||||
|
"not found",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"error: path '/nix/store/x' is required, but there is no substituter that can build it",
|
||||||
|
"not found",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"error: unable to download 'https://c/x.narinfo': Couldn't connect to server",
|
||||||
|
"other",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let kind = match classify(stderr) {
|
||||||
|
PullError::SignatureRefused(_) => "signature",
|
||||||
|
PullError::FactsMissing(_) => "facts",
|
||||||
|
PullError::NotFound(_) => "not found",
|
||||||
|
PullError::Failed(_) => "other",
|
||||||
|
PullError::TimedOut => "timeout",
|
||||||
|
};
|
||||||
|
assert_eq!(kind, want, "{stderr}");
|
||||||
|
}
|
||||||
|
// The last line is what the operator reads.
|
||||||
|
let PullError::Failed(last) = classify("warning: a\n\nerror: Couldn't connect\n\n") else {
|
||||||
|
panic!("not a plain failure");
|
||||||
|
};
|
||||||
|
assert_eq!(last, "error: Couldn't connect");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_failed_run_is_mapped_and_a_timeout_is_a_timeout() {
|
||||||
|
let fx = Fake::new(
|
||||||
|
Some(fragment(URL)),
|
||||||
|
exited(
|
||||||
|
false,
|
||||||
|
"warning: ignoring substitute from 'https://c', as it's not signed by any of the keys",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
pull_with(&fx, PATH).await,
|
||||||
|
Err(PullError::SignatureRefused(_))
|
||||||
|
));
|
||||||
|
let fx = Fake::new(Some(fragment(URL)), Run::TimedOut);
|
||||||
|
assert_eq!(pull_with(&fx, PATH).await, Err(PullError::TimedOut));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nothing but a store path is ever handed to nix.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn only_a_store_path_is_pulled() {
|
||||||
|
for bad in ["", "main", "/etc/passwd", "--help", "/nix/store/x"] {
|
||||||
|
let fx = Fake::new(Some(fragment(URL)), exited(true, ""));
|
||||||
|
assert!(
|
||||||
|
matches!(pull_with(&fx, bad).await, Err(PullError::Failed(_))),
|
||||||
|
"{bad}"
|
||||||
|
);
|
||||||
|
assert!(fx.calls().is_empty(), "{bad}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -51,8 +51,8 @@ pub(super) trait Host: Sync {
|
||||||
fn pinned_closure(&self, name: &str) -> Option<String>;
|
fn pinned_closure(&self, name: &str) -> Option<String>;
|
||||||
/// Drop the agent's pin.
|
/// Drop the agent's pin.
|
||||||
fn unpin_closure(&self, name: &str);
|
fn unpin_closure(&self, name: &str);
|
||||||
/// Download `store_path` from the swarm's cache into the store, never
|
/// Download `store_path` from the swarm's cache into the store through the
|
||||||
/// building it.
|
/// host's nix daemon, never building it.
|
||||||
async fn realise_closure(&self, name: &str, store_path: &str) -> Result<()>;
|
async fn realise_closure(&self, name: &str, store_path: &str) -> Result<()>;
|
||||||
/// Re-apply drop-ins, then point the container at the closure `toplevel`.
|
/// Re-apply drop-ins, then point the container at the closure `toplevel`.
|
||||||
async fn swap_update(
|
async fn swap_update(
|
||||||
|
|
@ -128,7 +128,9 @@ impl Host for RealHost {
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn realise_closure(&self, name: &str, store_path: &str) -> Result<()> {
|
async fn realise_closure(&self, name: &str, store_path: &str) -> Result<()> {
|
||||||
crate::priv_client::realise_closure(name, store_path).await
|
crate::cache_pull::pull(store_path).await?;
|
||||||
|
tracing::debug!(%name, %store_path, "pulled the swarm's closure through the nix daemon");
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn swap_update(
|
async fn swap_update(
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ mod actions;
|
||||||
mod agent_config;
|
mod agent_config;
|
||||||
mod agent_set;
|
mod agent_set;
|
||||||
mod attic;
|
mod attic;
|
||||||
|
mod cache_pull;
|
||||||
mod container_view;
|
mod container_view;
|
||||||
mod coordinator;
|
mod coordinator;
|
||||||
mod dashboard;
|
mod dashboard;
|
||||||
|
|
|
||||||
|
|
@ -267,9 +267,15 @@ pub async fn kill_container(name: &str) -> Result<()> {
|
||||||
pub async fn update_container_streaming(
|
pub async fn update_container_streaming(
|
||||||
name: &str,
|
name: &str,
|
||||||
toplevel: &str,
|
toplevel: &str,
|
||||||
on_line: impl FnMut(PrivStream, &str),
|
mut on_line: impl FnMut(PrivStream, &str),
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
ok(call_streaming(&update_container_request(name, toplevel), on_line).await?)
|
let req = update_container_request(name, toplevel);
|
||||||
|
let first = ok(call_streaming(&req, &mut on_line).await?);
|
||||||
|
if !older_hive_priv_refused(&first) {
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
ask_older_hive_priv_to_download(name, toplevel).await?;
|
||||||
|
ok(call_streaming(&req, &mut on_line).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_container_request(name: &str, toplevel: &str) -> PrivRequest {
|
fn update_container_request(name: &str, toplevel: &str) -> PrivRequest {
|
||||||
|
|
@ -285,9 +291,15 @@ fn update_container_request(name: &str, toplevel: &str) -> PrivRequest {
|
||||||
pub async fn create_container_streaming(
|
pub async fn create_container_streaming(
|
||||||
name: &str,
|
name: &str,
|
||||||
toplevel: &str,
|
toplevel: &str,
|
||||||
on_line: impl FnMut(PrivStream, &str),
|
mut on_line: impl FnMut(PrivStream, &str),
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
ok(call_streaming(&create_container_request(name, toplevel), on_line).await?)
|
let req = create_container_request(name, toplevel);
|
||||||
|
let first = ok(call_streaming(&req, &mut on_line).await?);
|
||||||
|
if !older_hive_priv_refused(&first) {
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
ask_older_hive_priv_to_download(name, toplevel).await?;
|
||||||
|
ok(call_streaming(&req, &mut on_line).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_container_request(name: &str, toplevel: &str) -> PrivRequest {
|
fn create_container_request(name: &str, toplevel: &str) -> PrivRequest {
|
||||||
|
|
@ -298,17 +310,49 @@ fn create_container_request(name: &str, toplevel: &str) -> PrivRequest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// See [`PrivRequest::RealiseClosure`]. A path hive-priv could not download
|
/// What an older hive-priv, which applies only a path it downloaded itself,
|
||||||
/// reads as an error whose message starts `not available: `.
|
/// says to an apply of any other.
|
||||||
///
|
const OLDER_PIN_REFUSAL: &str = "is not the closure realised for this agent";
|
||||||
/// # Errors
|
|
||||||
/// The call failed, or hive-priv answered `not available`.
|
/// Whether `applied` is an older hive-priv refusing an apply because it did not
|
||||||
pub async fn realise_closure(agent: &str, store_path: &str) -> Result<()> {
|
/// download the path itself (hive-c0re pulled it through the nix daemon). A
|
||||||
|
/// hive-priv of the same release never refuses that way, so a retry through
|
||||||
|
/// [`ask_older_hive_priv_to_download`] happens only while the two restart out of
|
||||||
|
/// step.
|
||||||
|
fn older_hive_priv_refused(applied: &Result<()>) -> bool {
|
||||||
|
applied
|
||||||
|
.as_ref()
|
||||||
|
.is_err_and(|e| format!("{e:#}").contains(OLDER_PIN_REFUSAL))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ask an older hive-priv to download `toplevel`, as it did before, which also
|
||||||
|
/// pins the path for the apply that follows. The path is already in the store,
|
||||||
|
/// so this is a lookup.
|
||||||
|
async fn ask_older_hive_priv_to_download(name: &str, toplevel: &str) -> Result<()> {
|
||||||
|
tracing::warn!(%name, %toplevel, "an older hive-priv refused a closure it did not download; asking it to");
|
||||||
ok(call(&PrivRequest::RealiseClosure {
|
ok(call(&PrivRequest::RealiseClosure {
|
||||||
agent: agent.to_owned(),
|
agent: name.to_owned(),
|
||||||
store_path: store_path.to_owned(),
|
store_path: toplevel.to_owned(),
|
||||||
})
|
})
|
||||||
.await?)
|
.await?)
|
||||||
|
.context("asking the older hive-priv to download the closure")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How long hive-c0re waits for hive-priv's facts refresh: hive-priv bounds
|
||||||
|
/// the unit at 60 s.
|
||||||
|
const REFRESH_WAIT: std::time::Duration = std::time::Duration::from_secs(90);
|
||||||
|
|
||||||
|
/// Run the root unit that reads this hive's cache facts from the store and
|
||||||
|
/// writes the nix.conf fragment, and wait for it. An older hive-priv does not
|
||||||
|
/// know the op and answers an error.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// The call failed or did not answer in time, or the unit failed.
|
||||||
|
pub async fn refresh_cache_facts() -> Result<()> {
|
||||||
|
within(REFRESH_WAIT, async {
|
||||||
|
ok(call(&PrivRequest::RefreshCacheFacts).await?)
|
||||||
|
})
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn destroy_container(name: &str) -> Result<()> {
|
pub async fn destroy_container(name: &str) -> Result<()> {
|
||||||
|
|
@ -684,9 +728,11 @@ mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
BindMount, CredentialMount, HiveFacts, NetworkIsolation, NixBuilds, PrivRequest,
|
BindMount, CredentialMount, HiveFacts, NetworkIsolation, NixBuilds, PrivRequest,
|
||||||
PrivResponse, PrivStream, call_at, call_streaming_at, call_with_fd_at, check,
|
PrivResponse, PrivStream, call_at, call_streaming_at, call_with_fd_at, check,
|
||||||
create_container_request, parse_qgroup_usage, resource_limits_request,
|
create_container_request, older_hive_priv_refused, parse_qgroup_usage,
|
||||||
send_snapshot_request, update_container_request, within, write_nspawn_flags_request,
|
resource_limits_request, send_snapshot_request, update_container_request, within,
|
||||||
|
write_nspawn_flags_request,
|
||||||
};
|
};
|
||||||
|
use anyhow::Context as _;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use std::io::{Read as _, Write as _};
|
use std::io::{Read as _, Write as _};
|
||||||
use std::os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd, RawFd};
|
use std::os::fd::{AsRawFd as _, FromRawFd as _, OwnedFd, RawFd};
|
||||||
|
|
@ -697,6 +743,38 @@ mod tests {
|
||||||
serde_json::to_value(req).expect("a request serialises")
|
serde_json::to_value(req).expect("a request serialises")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const REFUSAL: &str = "atlas: /nix/store/x is not the closure realised for this agent (none)";
|
||||||
|
|
||||||
|
/// An older hive-priv's refusal of a path it did not download is the
|
||||||
|
/// signal to ask it to download; a success and any other failure are not.
|
||||||
|
#[test]
|
||||||
|
fn only_an_older_hive_privs_pin_refusal_asks_for_a_download() {
|
||||||
|
assert!(older_hive_priv_refused(&Err(anyhow::anyhow!(REFUSAL))));
|
||||||
|
// As the streaming call words it: the error wraps hive-priv's text.
|
||||||
|
assert!(older_hive_priv_refused(
|
||||||
|
&Err(anyhow::anyhow!(REFUSAL)).context("nixos-container create")
|
||||||
|
));
|
||||||
|
assert!(!older_hive_priv_refused(&Ok(())));
|
||||||
|
for other in [
|
||||||
|
"nixos-container create failed",
|
||||||
|
"atlas: refusing /nix/store/x: has no signature by the swarm cache's key",
|
||||||
|
"connect to hive-priv socket",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!older_hive_priv_refused(&Err(anyhow::anyhow!(other))),
|
||||||
|
"{other}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_cache_facts_refresh_names_no_unit() {
|
||||||
|
assert_eq!(
|
||||||
|
wire(&PrivRequest::RefreshCacheFacts),
|
||||||
|
json!({"op": "refresh_cache_facts"})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn create_and_update_keep_the_name_and_toplevel_apart_and_stream() {
|
fn create_and_update_keep_the_name_and_toplevel_apart_and_stream() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
|
||||||
|
|
@ -110,9 +110,10 @@ pub fn valid_meta_rev(rev: &str) -> bool {
|
||||||
|
|
||||||
/// Whether `path` is a store path in the shape nix prints one: `/nix/store/`,
|
/// Whether `path` is a store path in the shape nix prints one: `/nix/store/`,
|
||||||
/// 32 lowercase letters or digits, `-`, then a name of the characters store
|
/// 32 lowercase letters or digits, `-`, then a name of the characters store
|
||||||
/// names allow. The path crosses the wire in [`PrivRequest::RealiseClosure`],
|
/// names allow. The path crosses the wire in [`PrivRequest::UpdateContainer`]
|
||||||
/// [`PrivRequest::UpdateContainer`] and [`PrivRequest::CreateContainer`], so
|
/// and [`PrivRequest::CreateContainer`] (and the older
|
||||||
/// `hive-priv` checks it before it hands it to nix.
|
/// [`PrivRequest::RealiseClosure`]), so `hive-priv` checks it before it hands
|
||||||
|
/// it to nix, and hive-c0re before it names it to nix.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn valid_closure_path(path: &str) -> bool {
|
pub fn valid_closure_path(path: &str) -> bool {
|
||||||
let Some(rest) = path.strip_prefix("/nix/store/") else {
|
let Some(rest) = path.strip_prefix("/nix/store/") else {
|
||||||
|
|
@ -131,11 +132,6 @@ pub fn valid_closure_path(path: &str) -> bool {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What a failed [`PrivRequest::RealiseClosure`] says when nix refused a path
|
|
||||||
/// because no trusted key signed it, so a caller can tell that apart from a
|
|
||||||
/// cache that is down.
|
|
||||||
pub const SIGNATURE_REFUSED: &str = "no signature by the swarm cache's key";
|
|
||||||
|
|
||||||
/// Root of per-agent state directories on the host.
|
/// Root of per-agent state directories on the host.
|
||||||
/// Subdirectory layout: `<AGENT_STATE_ROOT>/<name>/state/<file>`.
|
/// Subdirectory layout: `<AGENT_STATE_ROOT>/<name>/state/<file>`.
|
||||||
/// Used by `WriteAgentStateFile` to derive the write path so the
|
/// Used by `WriteAgentStateFile` to derive the write path so the
|
||||||
|
|
@ -269,8 +265,8 @@ pub enum PrivRequest {
|
||||||
KillContainer { name: String },
|
KillContainer { name: String },
|
||||||
|
|
||||||
/// Point `name`'s container profile at `toplevel`, then reload the unit if
|
/// Point `name`'s container profile at `toplevel`, then reload the unit if
|
||||||
/// it runs. hive-priv never builds: `toplevel` must be the path the last
|
/// it runs. hive-priv never builds: `toplevel` must already be in the store
|
||||||
/// successful `RealiseClosure` for `name` returned, and it refuses any other.
|
/// and signed by the swarm cache's key, and it refuses any other.
|
||||||
/// When `stream` is `true`, hive-priv sends `PrivEvent::Line` messages
|
/// When `stream` is `true`, hive-priv sends `PrivEvent::Line` messages
|
||||||
/// as the process runs, then a terminal `PrivEvent::Done`.
|
/// as the process runs, then a terminal `PrivEvent::Done`.
|
||||||
UpdateContainer {
|
UpdateContainer {
|
||||||
|
|
@ -292,18 +288,24 @@ pub enum PrivRequest {
|
||||||
toplevel: String,
|
toplevel: String,
|
||||||
},
|
},
|
||||||
|
|
||||||
/// `nix-store --realise <store_path>` from the swarm's binary cache, never
|
/// Kept for an older hive-c0re that still asks hive-priv to download:
|
||||||
/// building: the closure the swarm built for `agent`, downloaded into the
|
/// hive-c0re now pulls through the nix daemon and hive-priv downloads
|
||||||
/// store ahead of the update that applies it.
|
/// nothing. The answer is `Ok` when `store_path` is already in the store
|
||||||
|
/// and signed by the swarm cache's key (what `CreateContainer` and
|
||||||
|
/// `UpdateContainer` require), else an error that starts `not available: `.
|
||||||
|
/// `store_path` is checked by [`valid_closure_path`].
|
||||||
///
|
///
|
||||||
/// The request names no cache and no key: hive-priv takes the cache's
|
/// An older hive-priv answers the same request by downloading, and pins the
|
||||||
/// address from its own unit's environment and the key and the pull token
|
/// path for the apply that follows; hive-c0re sends it to one only when
|
||||||
/// from files only root can write, and nix accepts the path only with a
|
/// that older hive-priv refused an apply.
|
||||||
/// signature by that key. `store_path` is checked by
|
|
||||||
/// [`valid_closure_path`]. A path nix could not substitute answers with an
|
|
||||||
/// error that starts `not available: `.
|
|
||||||
RealiseClosure { agent: String, store_path: String },
|
RealiseClosure { agent: String, store_path: String },
|
||||||
|
|
||||||
|
/// `systemctl start hive-priv-cache-facts.service`, bounded at 60 s: the
|
||||||
|
/// root unit that reads this hive's cache facts from the store and writes
|
||||||
|
/// the nix.conf fragment the daemon trusts. hive-c0re asks for it when the
|
||||||
|
/// fragment is missing. The request names no unit.
|
||||||
|
RefreshCacheFacts,
|
||||||
|
|
||||||
/// `nixos-container destroy <name>`
|
/// `nixos-container destroy <name>`
|
||||||
DestroyContainer { name: String },
|
DestroyContainer { name: String },
|
||||||
|
|
||||||
|
|
@ -880,6 +882,21 @@ mod tests {
|
||||||
assert!(serde_json::from_str::<PrivRequest>(without).is_err());
|
assert!(serde_json::from_str::<PrivRequest>(without).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The facts refresh names no unit and carries no field: a stray field is
|
||||||
|
/// dropped when it is read, so a request cannot choose what to start.
|
||||||
|
#[test]
|
||||||
|
fn the_cache_facts_refresh_carries_nothing() {
|
||||||
|
let line = r#"{"op":"refresh_cache_facts","unit":"sshd.service"}"#;
|
||||||
|
assert!(matches!(
|
||||||
|
serde_json::from_str::<PrivRequest>(line),
|
||||||
|
Ok(PrivRequest::RefreshCacheFacts)
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_string(&PrivRequest::RefreshCacheFacts).unwrap(),
|
||||||
|
r#"{"op":"refresh_cache_facts"}"#
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// `toplevel` is required: hive-priv has no flake to build one from.
|
/// `toplevel` is required: hive-priv has no flake to build one from.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_request_without_a_toplevel_is_refused() {
|
fn a_request_without_a_toplevel_is_refused() {
|
||||||
|
|
|
||||||
|
|
@ -46,15 +46,14 @@ Each module's `//!` header lists its ops.
|
||||||
|
|
||||||
`container_toplevel_action` (in `src/container.rs`) applies a closure that is
|
`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
|
already in the store; hive-priv builds nothing. hive-c0re names the closure
|
||||||
(`toplevel` on `CreateContainer` / `UpdateContainer`), downloaded beforehand
|
(`toplevel` on `CreateContainer` / `UpdateContainer`), pulled beforehand by
|
||||||
with `RealiseClosure`. hive-priv remembers, per agent, the path its last
|
hive-c0re through the nix daemon. hive-priv applies a path only when
|
||||||
successful `RealiseClosure` returned (in memory), and applies only that path to
|
`nix store verify --no-contents --sigs-needed 1`, with the key in root-owned
|
||||||
that agent. It refuses a path never realised for the agent, another agent's
|
`/run/hyperhive-priv/attic/public-key`, finds the swarm cache's signature on it.
|
||||||
and the host's own, and logs an error naming the agent and both paths. A
|
It refuses a path that is absent, unsigned, signed by another key or not a store
|
||||||
restart between the download and the apply empties the record, so the apply
|
path, and logs an error naming the agent and the path. hive-priv keeps no record
|
||||||
fails and the job retries. hive-priv does not re-check the signature at apply
|
of what was pulled, so a restart between the pull and the apply loses nothing.
|
||||||
time: `RealiseClosure` enforces it, and the binding limits an apply to what that
|
`RefreshCacheFacts` starts the root unit that refreshes the cache facts.
|
||||||
call stored.
|
|
||||||
|
|
||||||
`create` passes the path to `nixos-container create --system-path`, so
|
`create` passes the path to `nixos-container create --system-path`, so
|
||||||
`nixos-container`'s own `buildFlake()` never runs. Without the flag, it builds
|
`nixos-container`'s own `buildFlake()` never runs. Without the flag, it builds
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,34 @@
|
||||||
//! `RealiseClosure`: download the closure the swarm built for an agent from
|
//! The check on a closure before it is applied, and the two small verbs around
|
||||||
//! the swarm's binary cache into the store, without ever building.
|
//! the swarm cache.
|
||||||
//!
|
//!
|
||||||
//! What this accepts is decided by root-owned sources alone: the cache's
|
//! hive-c0re (unprivileged) downloads the swarm's closure through the host's
|
||||||
//! address is the host's configuration, handed to the unit in
|
//! nix daemon. `CreateContainer` and `UpdateContainer` then apply a toplevel
|
||||||
//! [`CACHE_URL_ENV`], and the cache's public key and the pull token's netrc are
|
//! only when it is in the store and carries a signature by the swarm cache's
|
||||||
//! files a root unit keeps in [`ROOT_FACTS_DIR`]. Nothing in the request, and
|
//! key, which `nix store verify` checks against the key in [`ROOT_FACTS_DIR`].
|
||||||
//! nothing hive-c0re (unprivileged) writes, chooses a cache or a key. They go to
|
//! The key is a file a root unit keeps; nothing in the request, and nothing
|
||||||
//! nix's daemon as per-call settings, which the daemon applies for a trusted
|
//! hive-c0re writes, chooses it.
|
||||||
//! client such as this one (root), so the host's nix configuration is untouched.
|
|
||||||
|
|
||||||
use std::os::unix::fs::MetadataExt as _;
|
use std::os::unix::fs::MetadataExt as _;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::Path;
|
||||||
|
|
||||||
use anyhow::{Context as _, Result, bail};
|
use anyhow::{Context as _, Result, bail};
|
||||||
use hive_priv_sock::{SIGNATURE_REFUSED, valid_closure_path};
|
use hive_priv_sock::valid_closure_path;
|
||||||
|
|
||||||
use crate::container::{BoundedRun, run_bounded};
|
use crate::container::{BoundedRun, run_bounded};
|
||||||
use crate::shared::validate_agent_name;
|
use crate::shared::{RealSpawn, Spawn, validate_agent_name};
|
||||||
|
|
||||||
/// The unit's environment variable naming the cache: `https://<domain>/<cache>`
|
|
||||||
/// from the host's `services.hyperhive.swarm.attic` options.
|
|
||||||
pub(crate) const CACHE_URL_ENV: &str = "HIVE_PRIV_CACHE_URL";
|
|
||||||
|
|
||||||
/// Where the root unit `hive-priv-cache-facts` keeps `public-key` and `netrc`.
|
/// Where the root unit `hive-priv-cache-facts` keeps `public-key` and `netrc`.
|
||||||
/// Not `/run/hyperhive/attic`, which hive-c0re writes.
|
/// Not `/run/hyperhive/attic`, which hive-c0re writes.
|
||||||
const ROOT_FACTS_DIR: &str = "/run/hyperhive-priv/attic";
|
const ROOT_FACTS_DIR: &str = "/run/hyperhive-priv/attic";
|
||||||
|
|
||||||
/// Wall-clock bound on one download. A stalled transfer is bounded by nix's
|
/// The root unit that reads this hive's cache facts from the store.
|
||||||
/// own `stalled-download-timeout`; this covers the rest.
|
const FACTS_UNIT: &str = "hive-priv-cache-facts.service";
|
||||||
const REALISE_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(30);
|
|
||||||
|
|
||||||
/// The cache this host trusts.
|
/// Wall-clock bound on one `nix store verify`, which reads a path's metadata.
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
const VERIFY_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(1);
|
||||||
struct Cache {
|
|
||||||
substituter: String,
|
/// Wall-clock bound on the facts unit: a store login and three small writes.
|
||||||
public_key: String,
|
const REFRESH_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(1);
|
||||||
netrc: PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether `path` is a regular file or directory owned by `owner` that nobody
|
/// Whether `path` is a regular file or directory owned by `owner` that nobody
|
||||||
/// else can read or write.
|
/// else can read or write.
|
||||||
|
|
@ -64,305 +56,180 @@ fn private_to(path: &Path, owner: u32, directory: bool) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Cache {
|
/// The swarm cache's public key in `dir`, as `<name>:<key>`.
|
||||||
/// The cache at `url`, trusting the key in `dir`.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// No `url`, one that is not a plain `https` address (the token would go
|
|
||||||
/// out in clear), a `dir` or file that is missing, not owned by `owner` or
|
|
||||||
/// open to others, or a key that is not `<name>:<key>`.
|
|
||||||
fn read(url: Option<&str>, dir: &Path, owner: u32) -> Result<Self> {
|
|
||||||
let url = url
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|u| !u.is_empty())
|
|
||||||
.context("this host names no cache")?;
|
|
||||||
if !url.starts_with("https://") || url.contains(char::is_whitespace) {
|
|
||||||
bail!("the cache address is not an https URL");
|
|
||||||
}
|
|
||||||
private_to(dir, owner, true)?;
|
|
||||||
let key_file = dir.join("public-key");
|
|
||||||
private_to(&key_file, owner, false)?;
|
|
||||||
let netrc = dir.join("netrc");
|
|
||||||
private_to(&netrc, owner, false)?;
|
|
||||||
let key = std::fs::read_to_string(&key_file)
|
|
||||||
.with_context(|| format!("reading {}", key_file.display()))?;
|
|
||||||
let key = key.trim();
|
|
||||||
if key.contains(char::is_whitespace)
|
|
||||||
|| key
|
|
||||||
.split_once(':')
|
|
||||||
.is_none_or(|(name, key)| name.is_empty() || key.is_empty())
|
|
||||||
{
|
|
||||||
bail!("the cache's public key is not <name>:<key>");
|
|
||||||
}
|
|
||||||
Ok(Self {
|
|
||||||
substituter: url.to_owned(),
|
|
||||||
public_key: key.to_owned(),
|
|
||||||
netrc,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The `nix-store` arguments that fetch `store_path` and nothing else.
|
|
||||||
///
|
|
||||||
/// - `--max-jobs 0` forbids a local build, so a path the cache lacks fails.
|
|
||||||
/// - `substituters` and `trusted-public-keys` are replaced, not extended: the
|
|
||||||
/// swarm cache is the only source and its key the only one that counts.
|
|
||||||
/// - `require-sigs true` makes nix refuse a path no trusted key signed.
|
|
||||||
fn realise_args(store_path: &str, cache: &Cache) -> Vec<String> {
|
|
||||||
let mut args: Vec<String> = ["--realise", store_path, "--max-jobs", "0"]
|
|
||||||
.map(str::to_owned)
|
|
||||||
.into();
|
|
||||||
let netrc = cache.netrc.display().to_string();
|
|
||||||
for (name, value) in [
|
|
||||||
("substituters", cache.substituter.as_str()),
|
|
||||||
("trusted-public-keys", cache.public_key.as_str()),
|
|
||||||
("require-sigs", "true"),
|
|
||||||
("netrc-file", netrc.as_str()),
|
|
||||||
] {
|
|
||||||
args.extend(["--option".to_owned(), name.to_owned(), value.to_owned()]);
|
|
||||||
}
|
|
||||||
args
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Why nix did not realise the path, from its last stderr line. A refusal for
|
|
||||||
/// want of a signature is named, so the caller can tell it from a cache that
|
|
||||||
/// is down.
|
|
||||||
fn reason(stderr: &str) -> String {
|
|
||||||
let last = stderr.lines().last().unwrap_or("").trim();
|
|
||||||
if last.to_lowercase().contains("signature") {
|
|
||||||
format!("{SIGNATURE_REFUSED}: {last}")
|
|
||||||
} else {
|
|
||||||
last.to_owned()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `RealiseClosure`.
|
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// Bad arguments, or `not available: <reason>` for anything that left the path
|
/// A `dir` or key file that is missing, not owned by `owner` or open to
|
||||||
/// out of the store: no cache or key on this host, nix failing or timing out.
|
/// others, or a key that is not `<name>:<key>`.
|
||||||
pub(crate) async fn realise_closure(agent: &str, store_path: &str) -> Result<(String, String)> {
|
fn swarm_key(dir: &Path, owner: u32) -> Result<String> {
|
||||||
let url = std::env::var(CACHE_URL_ENV).ok();
|
private_to(dir, owner, true)?;
|
||||||
let done = realise_with(
|
let key_file = dir.join("public-key");
|
||||||
url.as_deref(),
|
private_to(&key_file, owner, false)?;
|
||||||
Path::new(ROOT_FACTS_DIR),
|
let key = std::fs::read_to_string(&key_file)
|
||||||
0,
|
.with_context(|| format!("reading {}", key_file.display()))?;
|
||||||
agent,
|
let key = key.trim();
|
||||||
store_path,
|
if key.contains(char::is_whitespace)
|
||||||
)
|
|| key
|
||||||
.await?;
|
.split_once(':')
|
||||||
if let Ok(mut realised) = REALISED.lock() {
|
.is_none_or(|(name, key)| name.is_empty() || key.is_empty())
|
||||||
remember(&mut realised, agent, store_path);
|
{
|
||||||
|
bail!("the cache's public key is not <name>:<key>");
|
||||||
}
|
}
|
||||||
Ok(done)
|
Ok(key.to_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The store path each agent's last successful `RealiseClosure` put in the
|
/// The `nix` arguments that check `toplevel` is in the store and signed by
|
||||||
/// store. `CreateContainer` / `UpdateContainer` apply only that path for that
|
/// `key`, and by no other key.
|
||||||
/// agent. hive-priv is a long-running process, so the entry outlives the
|
|
||||||
/// calls between the download and the apply; a restart in between empties it
|
|
||||||
/// and the apply is refused.
|
|
||||||
static REALISED: std::sync::Mutex<Vec<(String, String)>> = std::sync::Mutex::new(Vec::new());
|
|
||||||
|
|
||||||
fn remember(realised: &mut Vec<(String, String)>, agent: &str, store_path: &str) {
|
|
||||||
realised.retain(|(a, _)| a != agent);
|
|
||||||
realised.push((agent.to_owned(), store_path.to_owned()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The path recorded for `agent`.
|
|
||||||
fn lookup<'a>(realised: &'a [(String, String)], agent: &str) -> Option<&'a str> {
|
|
||||||
realised
|
|
||||||
.iter()
|
|
||||||
.find(|(a, _)| a == agent)
|
|
||||||
.map(|(_, p)| p.as_str())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// `toplevel` when it is a store path and the one `agent`'s last
|
|
||||||
/// `RealiseClosure` returned (`realised`); else the refusal, which is also
|
|
||||||
/// logged at error level with the agent and both paths.
|
|
||||||
///
|
///
|
||||||
/// The request comes from hive-c0re, which is not root. A signed path is not
|
/// - `--no-contents` checks signatures and validity, not the NAR hash.
|
||||||
/// enough: it could be another agent's closure or the host's own.
|
/// - `--sigs-needed 1` with `trusted-public-keys` replaced by `key` means one
|
||||||
///
|
/// signature by the swarm cache's key; the host's other keys do not count.
|
||||||
/// # Errors
|
fn verify_args(toplevel: &str, key: &str) -> Vec<String> {
|
||||||
/// `toplevel` is not a store path, or is not `realised`.
|
[
|
||||||
pub(crate) fn applicable<'a>(
|
"--extra-experimental-features",
|
||||||
agent: &str,
|
"nix-command",
|
||||||
toplevel: &'a str,
|
"store",
|
||||||
realised: Option<&str>,
|
"verify",
|
||||||
) -> Result<&'a str> {
|
"--no-contents",
|
||||||
|
"--sigs-needed",
|
||||||
|
"1",
|
||||||
|
"--option",
|
||||||
|
"trusted-public-keys",
|
||||||
|
key,
|
||||||
|
toplevel,
|
||||||
|
]
|
||||||
|
.map(str::to_owned)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `nix store verify` reports through its exit status, as a bit set: 2 is
|
||||||
|
/// a path no trusted key signed, 4 a path that is not in the store.
|
||||||
|
fn verdict(code: Option<i32>, toplevel: &str, stderr: &str) -> Result<()> {
|
||||||
|
match code {
|
||||||
|
Some(0) => Ok(()),
|
||||||
|
Some(c) if c & 4 != 0 => bail!("{toplevel} is not in the store"),
|
||||||
|
Some(c) if c & 2 != 0 => bail!("{toplevel} has no signature by the swarm cache's key"),
|
||||||
|
_ => bail!(
|
||||||
|
"nix store verify failed: {}",
|
||||||
|
stderr.lines().last().unwrap_or("").trim()
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<()> {
|
||||||
if !valid_closure_path(toplevel) {
|
if !valid_closure_path(toplevel) {
|
||||||
bail!("{toplevel:?} is not a store path");
|
bail!("{toplevel:?} is not a store path");
|
||||||
}
|
}
|
||||||
if realised == Some(toplevel) {
|
let key = swarm_key(dir, owner)?;
|
||||||
return Ok(toplevel);
|
let mut cmd = spawn.command("nix");
|
||||||
|
cmd.args(verify_args(toplevel, &key));
|
||||||
|
match run_bounded(cmd, VERIFY_TIMEOUT)
|
||||||
|
.await
|
||||||
|
.context("running nix store verify")?
|
||||||
|
{
|
||||||
|
BoundedRun::Exited { status, stderr } => verdict(status.code(), toplevel, &stderr),
|
||||||
|
BoundedRun::TimedOut => bail!(
|
||||||
|
"nix store verify timed out after {}s; killed it",
|
||||||
|
VERIFY_TIMEOUT.as_secs()
|
||||||
|
),
|
||||||
}
|
}
|
||||||
let realised = realised.unwrap_or("none");
|
|
||||||
tracing::error!(
|
|
||||||
%agent, requested = %toplevel, %realised,
|
|
||||||
"refusing to apply a closure that is not the one realised for the agent"
|
|
||||||
);
|
|
||||||
bail!("{agent}: {toplevel} is not the closure realised for this agent ({realised})")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`applicable`] against what `RealiseClosure` recorded for `agent`.
|
/// `toplevel` when it is a store path that is in the store with a signature by
|
||||||
|
/// the swarm cache's key; else the refusal, which is also logged at error
|
||||||
|
/// level with the agent and the path.
|
||||||
|
///
|
||||||
|
/// The request comes from hive-c0re, which is not root and can ask for any
|
||||||
|
/// path of that shape. A path in the store is not enough: a build of the host's
|
||||||
|
/// own, or a path from another cache, carries no signature by this key.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// As [`applicable`].
|
/// `toplevel` is not a store path, is not in the store, or carries no
|
||||||
pub(crate) fn applicable_for<'a>(agent: &str, toplevel: &'a str) -> Result<&'a str> {
|
/// signature by the swarm cache's key, or the key cannot be read.
|
||||||
let realised = REALISED
|
pub(crate) async fn applicable_for<'a>(agent: &str, toplevel: &'a str) -> Result<&'a str> {
|
||||||
.lock()
|
applicable_with(&RealSpawn, Path::new(ROOT_FACTS_DIR), 0, agent, toplevel).await
|
||||||
.ok()
|
|
||||||
.and_then(|r| lookup(&r, agent).map(str::to_owned));
|
|
||||||
applicable(agent, toplevel, realised.as_deref())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`realise_closure`] with the cache's address and the key's directory and
|
async fn applicable_with<'a>(
|
||||||
/// owner named, so a test can stand in for the host.
|
spawn: &impl Spawn,
|
||||||
async fn realise_with(
|
|
||||||
url: Option<&str>,
|
|
||||||
dir: &Path,
|
dir: &Path,
|
||||||
owner: u32,
|
owner: u32,
|
||||||
agent: &str,
|
agent: &str,
|
||||||
store_path: &str,
|
toplevel: &'a str,
|
||||||
) -> Result<(String, String)> {
|
) -> Result<&'a str> {
|
||||||
|
match check_signed(spawn, dir, owner, toplevel).await {
|
||||||
|
Ok(()) => Ok(toplevel),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
%agent, requested = %toplevel, error = %format!("{e:#}"),
|
||||||
|
"refusing to apply a closure the swarm cache did not sign"
|
||||||
|
);
|
||||||
|
bail!("{agent}: refusing {toplevel}: {e:#}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `RealiseClosure`, from an older hive-c0re: nothing is downloaded. `Ok` when
|
||||||
|
/// the path is already what an apply requires, else `not available: <reason>`.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Bad arguments, or `not available: <reason>`.
|
||||||
|
pub(crate) async fn realise_closure(agent: &str, store_path: &str) -> Result<(String, String)> {
|
||||||
validate_agent_name(agent)?;
|
validate_agent_name(agent)?;
|
||||||
if !valid_closure_path(store_path) {
|
if !valid_closure_path(store_path) {
|
||||||
bail!("{store_path:?} is not a store path");
|
bail!("{store_path:?} is not a store path");
|
||||||
}
|
}
|
||||||
let outcome = async {
|
match check_signed(&RealSpawn, Path::new(ROOT_FACTS_DIR), 0, store_path).await {
|
||||||
let cache = Cache::read(url, dir, owner)?;
|
Ok(()) => Ok(("present".to_owned(), String::new())),
|
||||||
let mut cmd = tokio::process::Command::new("nix-store");
|
Err(e) => bail!(
|
||||||
cmd.args(realise_args(store_path, &cache));
|
"not available: {e:#}; hive-priv no longer downloads, hive-c0re pulls through the nix daemon"
|
||||||
match run_bounded(cmd, REALISE_TIMEOUT)
|
),
|
||||||
.await
|
|
||||||
.context("running nix-store")?
|
|
||||||
{
|
|
||||||
BoundedRun::Exited { status, .. } if status.success() => Ok(()),
|
|
||||||
BoundedRun::Exited { stderr, .. } => bail!("{}", reason(&stderr)),
|
|
||||||
BoundedRun::TimedOut => bail!(
|
|
||||||
"nix-store timed out after {}s; killed it",
|
|
||||||
REALISE_TIMEOUT.as_secs()
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.await;
|
}
|
||||||
match outcome {
|
|
||||||
Ok(()) => {
|
/// `RefreshCacheFacts`: start the root unit that reads this hive's cache facts
|
||||||
tracing::info!(%agent, %store_path, "substituted the agent's closure");
|
/// from the store and writes the nix.conf fragment, and wait for it. The unit
|
||||||
Ok(("substituted".to_owned(), String::new()))
|
/// is fixed here, not named by the request.
|
||||||
}
|
///
|
||||||
Err(e) => {
|
/// # Errors
|
||||||
let reason = format!("{e:#}");
|
/// The unit failed to start or did not finish within [`REFRESH_TIMEOUT`].
|
||||||
if reason.contains(SIGNATURE_REFUSED) {
|
pub(crate) async fn refresh_cache_facts() -> Result<(String, String)> {
|
||||||
tracing::error!(%agent, %store_path, %reason, "the cache's closure was refused");
|
refresh_with(&RealSpawn, REFRESH_TIMEOUT).await
|
||||||
}
|
}
|
||||||
bail!("not available: {reason}")
|
|
||||||
|
async fn refresh_with(spawn: &impl Spawn, limit: std::time::Duration) -> Result<(String, String)> {
|
||||||
|
let mut cmd = spawn.command("systemctl");
|
||||||
|
cmd.args(["start", FACTS_UNIT]);
|
||||||
|
match run_bounded(cmd, limit)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("running systemctl start {FACTS_UNIT}"))?
|
||||||
|
{
|
||||||
|
BoundedRun::Exited { status, .. } if status.success() => {
|
||||||
|
tracing::info!("refreshed the cache facts");
|
||||||
|
Ok((String::new(), String::new()))
|
||||||
}
|
}
|
||||||
|
BoundedRun::Exited { status, stderr } => bail!(
|
||||||
|
"systemctl start {FACTS_UNIT} exited {status}: {}",
|
||||||
|
stderr.lines().last().unwrap_or("").trim()
|
||||||
|
),
|
||||||
|
BoundedRun::TimedOut => bail!(
|
||||||
|
"{FACTS_UNIT} did not finish within {}s; killed the wait",
|
||||||
|
limit.as_secs()
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::os::unix::fs::PermissionsExt as _;
|
use std::os::unix::fs::PermissionsExt as _;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::shared::test_support::scratch;
|
use crate::shared::test_support::{ScriptSpawn, scratch};
|
||||||
|
|
||||||
const PATH: &str = "/nix/store/0123456789abcdfghijklmnpqrsvwxyz-nixos-system-atlas";
|
const PATH: &str = "/nix/store/0123456789abcdfghijklmnpqrsvwxyz-nixos-system-atlas";
|
||||||
const URL: &str = "https://cache.example/swarm";
|
const KEY: &str = "swarm:AAAA";
|
||||||
|
|
||||||
fn cache() -> Cache {
|
|
||||||
Cache {
|
|
||||||
substituter: URL.to_owned(),
|
|
||||||
public_key: "swarm:AAAA".to_owned(),
|
|
||||||
netrc: PathBuf::from("/run/hyperhive-priv/attic/netrc"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const OTHER: &str = "/nix/store/zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz-nixos-system-iris";
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_path_realised_for_the_agent_is_applied() {
|
|
||||||
assert_eq!(applicable("atlas", PATH, Some(PATH)).unwrap(), PATH);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Another agent's realised path, a valid-shaped path never realised, and
|
|
||||||
/// a restart that emptied the record are all refused, naming the agent
|
|
||||||
/// and both paths.
|
|
||||||
#[test]
|
|
||||||
fn any_other_path_is_refused() {
|
|
||||||
for (case, realised) in [
|
|
||||||
("another path was realised for the agent", Some(OTHER)),
|
|
||||||
("nothing was realised for the agent", None),
|
|
||||||
] {
|
|
||||||
let err = applicable("atlas", PATH, realised).unwrap_err();
|
|
||||||
let msg = format!("{err:#}");
|
|
||||||
assert!(
|
|
||||||
msg.contains("atlas")
|
|
||||||
&& msg.contains(PATH)
|
|
||||||
&& msg.contains(realised.unwrap_or("none")),
|
|
||||||
"{case}: {msg}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn only_a_store_path_is_applied_even_when_it_was_recorded() {
|
|
||||||
for bad in [
|
|
||||||
"",
|
|
||||||
"main",
|
|
||||||
"/etc/passwd",
|
|
||||||
"/nix/store/x",
|
|
||||||
&format!("{PATH}/bin"),
|
|
||||||
] {
|
|
||||||
let err = applicable("atlas", bad, Some(bad)).unwrap_err();
|
|
||||||
assert!(format!("{err:#}").contains("not a store path"), "{bad}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Each agent keeps its own entry, and a newer download replaces the
|
|
||||||
/// older one.
|
|
||||||
#[test]
|
|
||||||
fn a_record_is_per_agent_and_the_latest_wins() {
|
|
||||||
let mut realised = Vec::new();
|
|
||||||
remember(&mut realised, "atlas", PATH);
|
|
||||||
remember(&mut realised, "iris", OTHER);
|
|
||||||
assert_eq!(lookup(&realised, "atlas"), Some(PATH));
|
|
||||||
assert_eq!(lookup(&realised, "iris"), Some(OTHER));
|
|
||||||
remember(&mut realised, "atlas", OTHER);
|
|
||||||
assert_eq!(lookup(&realised, "atlas"), Some(OTHER));
|
|
||||||
assert_eq!(lookup(&realised, "nobody"), None);
|
|
||||||
assert_eq!(realised.len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn option<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
|
|
||||||
args.windows(3)
|
|
||||||
.find(|w| w[0] == "--option" && w[1] == name)
|
|
||||||
.map(|w| w[2].as_str())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_command_never_builds() {
|
|
||||||
let args = realise_args(PATH, &cache());
|
|
||||||
assert_eq!(args[..2], ["--realise", PATH]);
|
|
||||||
let jobs = args.iter().position(|a| a == "--max-jobs").expect("flag");
|
|
||||||
assert_eq!(args[jobs + 1], "0");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn only_the_swarm_cache_and_its_key_count_and_signatures_are_required() {
|
|
||||||
let args = realise_args(PATH, &cache());
|
|
||||||
assert_eq!(option(&args, "substituters"), Some(URL));
|
|
||||||
assert_eq!(option(&args, "trusted-public-keys"), Some("swarm:AAAA"));
|
|
||||||
assert_eq!(option(&args, "require-sigs"), Some("true"));
|
|
||||||
assert_eq!(
|
|
||||||
option(&args, "netrc-file"),
|
|
||||||
Some("/run/hyperhive-priv/attic/netrc")
|
|
||||||
);
|
|
||||||
// The settings that would add to the defaults are not used.
|
|
||||||
assert!(!args.iter().any(|a| a.starts_with("extra-")), "{args:?}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The uid the test runs as, which stands in for root.
|
/// The uid the test runs as, which stands in for root.
|
||||||
fn me() -> u32 {
|
fn me() -> u32 {
|
||||||
|
|
@ -387,126 +254,169 @@ mod tests {
|
||||||
dir
|
dir
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
/// A `nix` that behaves like `nix store verify --sigs-needed 1` over a store
|
||||||
fn the_cache_is_the_hosts_address_and_the_root_files_key() {
|
/// holding `PATH`, signed by `KEY` only: exit 0 for that path under that key,
|
||||||
let dir = facts("swarm:AAAA");
|
/// 2 for that path under any other key, 4 for any other path. It exits 99
|
||||||
let cache = Cache::read(Some(URL), &dir, me()).expect("read");
|
/// for anything but the verify invocation, and logs its arguments.
|
||||||
assert_eq!(cache.substituter, URL);
|
fn nix(log: &Path) -> ScriptSpawn {
|
||||||
assert_eq!(cache.public_key, "swarm:AAAA");
|
ScriptSpawn(format!(
|
||||||
assert_eq!(cache.netrc, dir.join("netrc"));
|
r#"echo "$@" >>{log}
|
||||||
|
[ "$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
|
||||||
|
exit 0"#,
|
||||||
|
log = log.display()
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A `substituter` or `public-key` file beside the facts, as hive-c0re
|
#[tokio::test]
|
||||||
/// writes them, decides nothing: the address is the argument and the key is
|
async fn a_toplevel_signed_by_the_swarm_key_is_applied() {
|
||||||
/// the root file's.
|
let log = scratch().join("calls");
|
||||||
#[test]
|
let got = applicable_with(&nix(&log), &facts(KEY), me(), "atlas", PATH)
|
||||||
fn the_address_is_never_read_from_a_file_and_the_key_only_from_the_root_files() {
|
.await
|
||||||
let dir = facts("swarm:ROOT");
|
.expect("signed");
|
||||||
put(
|
assert_eq!(got, PATH);
|
||||||
&dir.join("substituter"),
|
// The key checked against is the one in the root file.
|
||||||
"https://evil.example/cache\n",
|
let calls = std::fs::read_to_string(&log).expect("log");
|
||||||
0o600,
|
assert!(
|
||||||
|
calls.contains(&format!("trusted-public-keys {KEY} {PATH}")),
|
||||||
|
"{calls}"
|
||||||
);
|
);
|
||||||
let cache = Cache::read(Some(URL), &dir, me()).expect("read");
|
|
||||||
assert_eq!(cache.substituter, URL);
|
|
||||||
assert_eq!(cache.public_key, "swarm:ROOT");
|
|
||||||
// The directory hive-c0re writes is not the one this reads.
|
|
||||||
assert!(!ROOT_FACTS_DIR.starts_with("/run/hyperhive/"));
|
|
||||||
assert_eq!(ROOT_FACTS_DIR, "/run/hyperhive-priv/attic");
|
|
||||||
// And without an address from the host there is no cache, whatever the
|
|
||||||
// directory holds.
|
|
||||||
for none in [None, Some(""), Some(" ")] {
|
|
||||||
assert!(Cache::read(none, &dir, me()).is_err(), "{none:?}");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
/// A path no swarm key signed, one the store lacks, and a key file that
|
||||||
fn a_file_that_is_not_the_roots_or_is_open_to_others_is_refused() {
|
/// names another key are each refused, naming the agent and the path.
|
||||||
let me = me();
|
#[tokio::test]
|
||||||
// Control: the facts as the unit leaves them are accepted.
|
async fn an_unsigned_or_absent_toplevel_is_refused() {
|
||||||
assert!(Cache::read(Some(URL), &facts("swarm:AAAA"), me).is_ok());
|
let log = scratch().join("calls");
|
||||||
// Another owner than the one trusted.
|
let other = "/nix/store/zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz-nixos-system-iris";
|
||||||
assert!(Cache::read(Some(URL), &facts("swarm:AAAA"), me + 1).is_err());
|
for (case, dir, path, want) in [
|
||||||
// The key or the netrc readable by others, or the directory open.
|
("absent", facts(KEY), other, "is not in the store"),
|
||||||
for (name, mode) in [("public-key", 0o644), ("netrc", 0o644), ("netrc", 0o660)] {
|
(
|
||||||
let dir = facts("swarm:AAAA");
|
"unsigned by the key on file",
|
||||||
std::fs::set_permissions(dir.join(name), std::fs::Permissions::from_mode(mode))
|
facts("other:BBBB"),
|
||||||
.expect("chmod");
|
PATH,
|
||||||
assert!(Cache::read(Some(URL), &dir, me).is_err(), "{name} {mode:o}");
|
"has no signature by the swarm cache's key",
|
||||||
}
|
),
|
||||||
let dir = facts("swarm:AAAA");
|
] {
|
||||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).expect("chmod");
|
let err = applicable_with(&nix(&log), &dir, me(), "atlas", path)
|
||||||
assert!(Cache::read(Some(URL), &dir, me).is_err());
|
.await
|
||||||
// A symlink in place of the key.
|
.expect_err(case);
|
||||||
let dir = facts("swarm:AAAA");
|
let msg = format!("{err:#}");
|
||||||
std::fs::remove_file(dir.join("public-key")).expect("rm");
|
|
||||||
std::os::unix::fs::symlink(dir.join("netrc"), dir.join("public-key")).expect("link");
|
|
||||||
assert!(Cache::read(Some(URL), &dir, me).is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn missing_files_and_unusable_values_are_refused() {
|
|
||||||
let me = me();
|
|
||||||
for url in ["http://cache.example/swarm", "https://cache.example/a b"] {
|
|
||||||
assert!(
|
assert!(
|
||||||
Cache::read(Some(url), &facts("swarm:AAAA"), me).is_err(),
|
msg.contains("atlas") && msg.contains(path) && msg.contains(want),
|
||||||
"{url}"
|
"{case}: {msg}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for key in ["nokeyname", "swarm: AAAA", ":AAAA", "swarm:"] {
|
|
||||||
assert!(Cache::read(Some(URL), &facts(key), me).is_err(), "{key:?}");
|
|
||||||
}
|
|
||||||
let dir = facts("swarm:AAAA");
|
|
||||||
std::fs::remove_file(dir.join("netrc")).expect("rm");
|
|
||||||
assert!(Cache::read(Some(URL), &dir, me).is_err());
|
|
||||||
assert!(Cache::read(Some(URL), &dir.join("nowhere"), me).is_err());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Missing facts reach the caller as `not available`, which a rebuild
|
/// A tool failure other than the two verdicts is a refusal too, with the
|
||||||
/// reports as a closure that cannot be pulled.
|
/// tool's last line.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn without_the_root_files_the_answer_is_not_available() {
|
async fn a_failing_verify_refuses() {
|
||||||
let nowhere = scratch().join("nowhere");
|
let spawn = ScriptSpawn("echo 'error: cannot connect' >&2; exit 1".to_owned());
|
||||||
let err = realise_with(Some(URL), &nowhere, 0, "atlas", PATH)
|
let err = applicable_with(&spawn, &facts(KEY), me(), "atlas", PATH)
|
||||||
.await
|
.await
|
||||||
.expect_err("no facts");
|
.expect_err("refused");
|
||||||
assert!(format!("{err:#}").starts_with("not available: "), "{err:#}");
|
assert!(format!("{err:#}").contains("cannot connect"), "{err:#}");
|
||||||
let err = realise_with(None, &facts("swarm:AAAA"), me(), "atlas", PATH)
|
|
||||||
.await
|
|
||||||
.expect_err("no address");
|
|
||||||
assert!(format!("{err:#}").starts_with("not available: "), "{err:#}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The request carries an agent and a path and nothing that chooses a
|
#[tokio::test]
|
||||||
/// cache: fields beyond those are dropped when it is read.
|
async fn only_a_store_path_is_verified() {
|
||||||
#[test]
|
// A stand-in that would accept anything, so only the shape check refuses.
|
||||||
fn the_request_cannot_carry_an_address_or_a_key() {
|
let yes = ScriptSpawn("exit 0".to_owned());
|
||||||
let line = r#"{"op":"realise_closure","agent":"atlas","store_path":"/nix/store/x",
|
for bad in [
|
||||||
"substituter":"https://evil.example/c","public_key":"evil:AAAA","netrc":"/tmp/x"}"#;
|
"",
|
||||||
let hive_priv_sock::PrivRequest::RealiseClosure { agent, store_path } =
|
"main",
|
||||||
serde_json::from_str(line).expect("reads")
|
"/etc/passwd",
|
||||||
else {
|
"/nix/store/x",
|
||||||
panic!("not a RealiseClosure");
|
&format!("{PATH}/bin"),
|
||||||
};
|
] {
|
||||||
assert_eq!(
|
let err = applicable_with(&yes, &facts(KEY), me(), "atlas", bad)
|
||||||
(agent.as_str(), store_path.as_str()),
|
.await
|
||||||
("atlas", "/nix/store/x")
|
.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)
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
/// Without the root key there is nothing to verify against: refused, and
|
||||||
fn a_signature_refusal_is_named_and_another_failure_is_not() {
|
/// nix is not asked.
|
||||||
let refused =
|
#[tokio::test]
|
||||||
reason("error: cannot add path 'x' because it lacks a signature by a trusted key");
|
async fn without_the_root_key_nothing_is_applied() {
|
||||||
assert!(refused.starts_with(SIGNATURE_REFUSED), "{refused}");
|
let log = scratch().join("calls");
|
||||||
let down =
|
let nowhere = scratch().join("nowhere");
|
||||||
reason("warning: x\nerror: unable to download 'https://c/x.narinfo': Couldn't connect");
|
let err = applicable_with(&nix(&log), &nowhere, 0, "atlas", PATH)
|
||||||
assert!(!down.contains(SIGNATURE_REFUSED), "{down}");
|
.await
|
||||||
assert!(down.contains("Couldn't connect"), "{down}");
|
.expect_err("no key");
|
||||||
|
assert!(format!("{err:#}").contains("missing"), "{err:#}");
|
||||||
|
assert!(!log.exists(), "nix ran without a key");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_key_is_the_root_files_and_only_a_private_one() {
|
||||||
|
let me = me();
|
||||||
|
assert_eq!(swarm_key(&facts(KEY), me).unwrap(), KEY);
|
||||||
|
// Another owner than the one trusted.
|
||||||
|
assert!(swarm_key(&facts(KEY), me + 1).is_err());
|
||||||
|
// The key open to others, or the directory open.
|
||||||
|
let dir = facts(KEY);
|
||||||
|
std::fs::set_permissions(
|
||||||
|
dir.join("public-key"),
|
||||||
|
std::fs::Permissions::from_mode(0o644),
|
||||||
|
)
|
||||||
|
.expect("chmod");
|
||||||
|
assert!(swarm_key(&dir, me).is_err());
|
||||||
|
let dir = facts(KEY);
|
||||||
|
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).expect("chmod");
|
||||||
|
assert!(swarm_key(&dir, me).is_err());
|
||||||
|
// A symlink in place of the key.
|
||||||
|
let dir = facts(KEY);
|
||||||
|
std::fs::remove_file(dir.join("public-key")).expect("rm");
|
||||||
|
std::os::unix::fs::symlink(dir.join("netrc"), dir.join("public-key")).expect("link");
|
||||||
|
assert!(swarm_key(&dir, me).is_err());
|
||||||
|
for key in ["nokeyname", "swarm: AAAA", ":AAAA", "swarm:"] {
|
||||||
|
assert!(swarm_key(&facts(key), me).is_err(), "{key:?}");
|
||||||
|
}
|
||||||
|
// The directory hive-c0re writes is not the one this reads.
|
||||||
|
assert_eq!(ROOT_FACTS_DIR, "/run/hyperhive-priv/attic");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_command_counts_only_the_swarm_key() {
|
||||||
|
let args = verify_args(PATH, KEY);
|
||||||
|
assert_eq!(args.last().map(String::as_str), Some(PATH));
|
||||||
|
let at = args
|
||||||
|
.iter()
|
||||||
|
.position(|a| a == "--sigs-needed")
|
||||||
|
.expect("flag");
|
||||||
|
assert_eq!(args[at + 1], "1");
|
||||||
|
let at = args
|
||||||
|
.iter()
|
||||||
|
.position(|a| a == "trusted-public-keys")
|
||||||
|
.expect("option");
|
||||||
|
assert_eq!(args[at - 1], "--option");
|
||||||
|
assert_eq!(args[at + 1], KEY);
|
||||||
|
// The setting that would add to the host's keys is not used.
|
||||||
|
assert!(
|
||||||
|
!args.iter().any(|a| a.starts_with("extra-trusted")),
|
||||||
|
"{args:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An older hive-c0re's download request downloads nothing: it succeeds
|
||||||
|
/// only for a path an apply would accept.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn bad_arguments_are_refused_before_anything_runs() {
|
async fn the_older_download_request_answers_not_available_for_a_path_not_in_the_store() {
|
||||||
|
// The real `nix` is not on the path of the test, so this is refused for
|
||||||
|
// want of the root key before anything runs; the shape and name checks
|
||||||
|
// come first.
|
||||||
for (agent, path) in [
|
for (agent, path) in [
|
||||||
("Bad Agent", PATH),
|
("Bad Agent", PATH),
|
||||||
("", PATH),
|
("", PATH),
|
||||||
|
|
@ -517,5 +427,34 @@ mod tests {
|
||||||
let err = realise_closure(agent, path).await.expect_err("refused");
|
let err = realise_closure(agent, path).await.expect_err("refused");
|
||||||
assert!(!format!("{err:#}").contains("not available"), "{err:#}");
|
assert!(!format!("{err:#}").contains("not available"), "{err:#}");
|
||||||
}
|
}
|
||||||
|
let err = realise_closure("atlas", PATH).await.expect_err("no key");
|
||||||
|
let msg = format!("{err:#}");
|
||||||
|
assert!(
|
||||||
|
msg.starts_with("not available: ") && msg.contains("no longer downloads"),
|
||||||
|
"{msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The refresh starts the fixed unit and nothing else, and says why it
|
||||||
|
/// failed or timed out.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_refresh_starts_the_facts_unit_and_reports_a_failure() {
|
||||||
|
let log = scratch().join("calls");
|
||||||
|
let ok = ScriptSpawn(format!("echo \"$0 $@\" >>{}", log.display()));
|
||||||
|
refresh_with(&ok, REFRESH_TIMEOUT).await.expect("started");
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(&log).expect("log").trim(),
|
||||||
|
"systemctl start hive-priv-cache-facts.service"
|
||||||
|
);
|
||||||
|
let failing = ScriptSpawn("echo 'Unit not found.' >&2; exit 5".to_owned());
|
||||||
|
let err = refresh_with(&failing, REFRESH_TIMEOUT)
|
||||||
|
.await
|
||||||
|
.expect_err("failed");
|
||||||
|
assert!(format!("{err:#}").contains("Unit not found."), "{err:#}");
|
||||||
|
let slow = ScriptSpawn("sleep 30".to_owned());
|
||||||
|
let err = refresh_with(&slow, std::time::Duration::from_millis(100))
|
||||||
|
.await
|
||||||
|
.expect_err("timed out");
|
||||||
|
assert!(format!("{err:#}").contains("did not finish"), "{err:#}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -207,9 +207,10 @@ async fn destroy_container_with(host: &Host<impl Spawn>, name: &str) -> Result<(
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared body for `CreateContainer` / `UpdateContainer`: validate the name
|
/// Shared body for `CreateContainer` / `UpdateContainer`: validate the name
|
||||||
/// and that `toplevel` is the closure realised for the agent, and apply it (streaming line events to `writer` when
|
/// and that `toplevel` is in the store with a signature by the swarm cache's
|
||||||
/// `stream` is set). Nothing is built here: `toplevel` is a closure already in
|
/// key, and apply it (streaming line events to `writer` when `stream` is set).
|
||||||
/// the store, which `RealiseClosure` put there.
|
/// Nothing is built here: `toplevel` is a closure already in the store, which
|
||||||
|
/// hive-c0re pulled through the nix daemon.
|
||||||
///
|
///
|
||||||
/// `create` passes `--system-path` so `nixos-container`'s own `buildFlake()`
|
/// `create` passes `--system-path` so `nixos-container`'s own `buildFlake()`
|
||||||
/// never runs; it builds to a relative `.tmp` out-link that concurrent calls
|
/// never runs; it builds to a relative `.tmp` out-link that concurrent calls
|
||||||
|
|
@ -228,12 +229,12 @@ pub(crate) async fn container_toplevel_action(
|
||||||
writer: &mut OwnedWriteHalf,
|
writer: &mut OwnedWriteHalf,
|
||||||
) -> Result<(String, String)> {
|
) -> Result<(String, String)> {
|
||||||
validate_agent_name(name)?;
|
validate_agent_name(name)?;
|
||||||
let toplevel = crate::closure::applicable_for(name, toplevel)?;
|
let toplevel = crate::closure::applicable_for(name, toplevel).await?;
|
||||||
apply_toplevel(&Host::REAL, verb, name, toplevel, stream, writer).await
|
apply_toplevel(&Host::REAL, verb, name, toplevel, stream, writer).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`container_toplevel_action`] past the check that `toplevel` is the
|
/// [`container_toplevel_action`] past the check that `toplevel` is signed by
|
||||||
/// closure realised for `name`.
|
/// the swarm cache's key.
|
||||||
async fn apply_toplevel(
|
async fn apply_toplevel(
|
||||||
host: &Host<impl Spawn>,
|
host: &Host<impl Spawn>,
|
||||||
verb: &str,
|
verb: &str,
|
||||||
|
|
@ -987,11 +988,12 @@ mod host_tests {
|
||||||
|
|
||||||
// ---- the apply gate ----
|
// ---- the apply gate ----
|
||||||
|
|
||||||
/// Only the closure realised for the agent is applied. Nothing is
|
/// Only a closure the swarm key signed is applied. A test has no swarm key
|
||||||
/// realised for these names, so each request is refused at the gate: the
|
/// at the root path, so each request is refused at the gate: the message
|
||||||
/// message is the gate's, not a `nixos-container` failure.
|
/// is the gate's, not a `nixos-container` failure. The signed and unsigned
|
||||||
|
/// verdicts are tested in `closure`.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn an_unrealised_or_foreign_closure_is_refused_at_the_gate() {
|
async fn a_closure_without_the_swarm_key_to_check_it_against_is_refused_at_the_gate() {
|
||||||
for verb in ["create", "update"] {
|
for verb in ["create", "update"] {
|
||||||
let (writer, _peer) = &mut pipe();
|
let (writer, _peer) = &mut pipe();
|
||||||
let err = container_toplevel_action(verb, "never-realised", TOPLEVEL, false, writer)
|
let err = container_toplevel_action(verb, "never-realised", TOPLEVEL, false, writer)
|
||||||
|
|
@ -999,7 +1001,7 @@ mod host_tests {
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
let msg = format!("{err:#}");
|
let msg = format!("{err:#}");
|
||||||
assert!(
|
assert!(
|
||||||
msg.contains("is not the closure realised for this agent (none)"),
|
msg.contains(&format!("never-realised: refusing {TOPLEVEL}")),
|
||||||
"{verb}: {msg}"
|
"{verb}: {msg}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,8 @@
|
||||||
//! [`prestart`] (the check of a container's root that runs before each start,
|
//! [`prestart`] (the check of a container's root that runs before each start,
|
||||||
//! as a sub-command), [`storage`] (btrfs subvolumes, quotas, snapshots, disk
|
//! as a sub-command), [`storage`] (btrfs subvolumes, quotas, snapshots, disk
|
||||||
//! measurement), [`files`] (pause marker, socket dir, legacy tmpfiles),
|
//! measurement), [`files`] (pause marker, socket dir, legacy tmpfiles),
|
||||||
//! [`infra`] (gateway nginx, infra containers) and [`closure`] (downloads from
|
//! [`infra`] (gateway nginx, infra containers) and [`closure`] (the swarm-signature
|
||||||
//! the swarm cache). Helpers more than one family uses are in [`shared`].
|
//! check before an apply, and the cache-facts refresh). Helpers more than one family uses are in [`shared`].
|
||||||
|
|
||||||
mod closure;
|
mod closure;
|
||||||
mod container;
|
mod container;
|
||||||
|
|
@ -403,6 +403,8 @@ async fn exec(
|
||||||
ref store_path,
|
ref store_path,
|
||||||
} => closure::realise_closure(agent, store_path).await,
|
} => closure::realise_closure(agent, store_path).await,
|
||||||
|
|
||||||
|
PrivRequest::RefreshCacheFacts => closure::refresh_cache_facts().await,
|
||||||
|
|
||||||
PrivRequest::DestroyContainer { ref name } => container::destroy_container(name).await,
|
PrivRequest::DestroyContainer { ref name } => container::destroy_container(name).await,
|
||||||
|
|
||||||
PrivRequest::ListContainers => container::container_run(&["list"]).await,
|
PrivRequest::ListContainers => container::container_run(&["list"]).await,
|
||||||
|
|
|
||||||
|
|
@ -315,9 +315,11 @@ in
|
||||||
# StateDirectory (/var/lib/hyperhive), RuntimeDirectory
|
# StateDirectory (/var/lib/hyperhive), RuntimeDirectory
|
||||||
# (/run/hyperhive), and ReadWritePaths below, which systemd keeps
|
# (/run/hyperhive), and ReadWritePaths below, which systemd keeps
|
||||||
# writable. Beyond the managed directories:
|
# writable. Beyond the managed directories:
|
||||||
# - nix is invoked directly (lifecycle, meta, flake_check), but
|
# - the one nix call is the closure pull (src/cache_pull.rs):
|
||||||
# NIX_REMOTE=daemon routes all store writes through the host
|
# `nix-store --realise` with NIX_REMOTE=daemon, so the host daemon
|
||||||
# daemon — hive-c0re never writes to /nix itself.
|
# writes the store — hive-c0re never writes to /nix itself. It
|
||||||
|
# connects to the daemon's socket, which RestrictAddressFamilies
|
||||||
|
# (deferred above) must keep allowing.
|
||||||
|
|
||||||
# - nix build worktrees live in PrivateTmp, not /tmp.
|
# - nix build worktrees live in PrivateTmp, not /tmp.
|
||||||
# - /etc writes (bind-mount edits) go through hive-priv via the
|
# - /etc writes (bind-mount edits) go through hive-priv via the
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,21 @@
|
||||||
# The binary-cache facts hive-priv (root) trusts, kept where only root can
|
# The binary-cache facts the host's nix daemon and hive-priv (root) trust, kept
|
||||||
# change them.
|
# where only root can change them.
|
||||||
#
|
#
|
||||||
# hive-priv downloads an agent's closure from the swarm cache when a rebuild
|
# hive-c0re downloads an agent's closure from the swarm cache through the
|
||||||
# asks it to. What decides whether a download is accepted — which cache, and
|
# host's nix daemon, and hive-priv applies it only if the swarm's key signed
|
||||||
# which key must have signed it — must not come from a file the unprivileged
|
# it. What decides whether a download is accepted — which cache the daemon
|
||||||
# hive-c0re writes, or a compromised hive-c0re could have root accept a
|
# lets an unprivileged client name, and which key must have signed it — must
|
||||||
# closure from a cache of its own. So:
|
# not come from a file the unprivileged hive-c0re writes, or a compromised
|
||||||
|
# hive-c0re could have root accept a closure from a cache of its own. So:
|
||||||
#
|
#
|
||||||
# - the cache's address is this host's configuration
|
# - the cache's address is this host's configuration
|
||||||
# ({option}`services.hyperhive.swarm.attic.domain` and `cache`), handed to
|
# ({option}`services.hyperhive.swarm.attic.domain` and `cache`);
|
||||||
# hive-priv in its unit's environment;
|
|
||||||
# - the cache's public key and the pull token are read from the store by a root
|
# - the cache's public key and the pull token are read from the store by a root
|
||||||
# oneshot, `hive-priv-cache-facts`, under this hive's own store identity, and
|
# oneshot, `hive-priv-cache-facts`, under this hive's own store identity. The
|
||||||
# land in `/run/hyperhive-priv/attic/` (root, `0700`; files `0600`).
|
# key and the token land in `/run/hyperhive-priv/attic/` (root, `0700`; files
|
||||||
|
# `0600`), and the address, the key and the netrc's path in a nix.conf
|
||||||
|
# fragment, `/run/hyperhive-nix/cache.conf`, which the host's nix.conf
|
||||||
|
# includes (`!include`, so a host whose unit has not run yet still starts).
|
||||||
#
|
#
|
||||||
# hive-c0re keeps its own copies under `/run/hyperhive/attic/` for the probe,
|
# hive-c0re keeps its own copies under `/run/hyperhive/attic/` for the probe,
|
||||||
# which only reports.
|
# which only reports.
|
||||||
|
|
@ -42,6 +45,9 @@ let
|
||||||
&& tls.baoHiveIdentityKeyFile != null;
|
&& tls.baoHiveIdentityKeyFile != null;
|
||||||
|
|
||||||
dir = "/run/hyperhive-priv/attic";
|
dir = "/run/hyperhive-priv/attic";
|
||||||
|
confDir = "/run/hyperhive-nix";
|
||||||
|
confFile = "${confDir}/cache.conf";
|
||||||
|
url = "https://${atticCfg.domain}/${atticCfg.cache}";
|
||||||
|
|
||||||
# Where the store runs on this host, the first login waits for its container.
|
# Where the store runs on this host, the first login waits for its container.
|
||||||
# No `wants`: a pull would start the container mid-switch on the old closure.
|
# No `wants`: a pull would start the container mid-switch on the old closure.
|
||||||
|
|
@ -52,15 +58,16 @@ let
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
config = lib.mkIf enabled {
|
config = lib.mkIf enabled {
|
||||||
systemd.tmpfiles.rules = [ "d /run/hyperhive-priv 0700 root root - -" ];
|
systemd.tmpfiles.rules = [
|
||||||
|
"d /run/hyperhive-priv 0700 root root - -"
|
||||||
|
"d ${confDir} 0755 root root - -"
|
||||||
|
];
|
||||||
|
|
||||||
# The URL nix is told is the only substituter for the download; no request
|
# `!include` tolerates a fragment that does not exist yet.
|
||||||
# chooses it.
|
nix.extraOptions = "!include ${confFile}";
|
||||||
systemd.services.hive-priv.environment.HIVE_PRIV_CACHE_URL =
|
|
||||||
"https://${atticCfg.domain}/${atticCfg.cache}";
|
|
||||||
|
|
||||||
systemd.services.hive-priv-cache-facts = {
|
systemd.services.hive-priv-cache-facts = {
|
||||||
description = "Read the swarm cache's public key and pull token for hive-priv";
|
description = "Read the swarm cache's public key and pull token for the host's nix daemon and hive-priv";
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
wants = [ "network-online.target" ];
|
wants = [ "network-online.target" ];
|
||||||
after = [
|
after = [
|
||||||
|
|
@ -94,7 +101,7 @@ in
|
||||||
script = import ./lib/attic-root-facts.nix { inherit lib; } {
|
script = import ./lib/attic-root-facts.nix { inherit lib; } {
|
||||||
hive = hiveController.hiveName;
|
hive = hiveController.hiveName;
|
||||||
host = atticCfg.domain;
|
host = atticCfg.domain;
|
||||||
inherit dir;
|
inherit dir url confFile;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -124,8 +124,8 @@ in
|
||||||
# hive-priv runs as root. Root nix defaults to store=auto which
|
# hive-priv runs as root. Root nix defaults to store=auto which
|
||||||
# resolves to the LOCAL store — bypassing the host daemon, its
|
# resolves to the LOCAL store — bypassing the host daemon, its
|
||||||
# remote builders, and prebuilt derivation outputs. Force daemon
|
# remote builders, and prebuilt derivation outputs. Force daemon
|
||||||
# routing so the closure `RealiseClosure` downloads is in the same
|
# routing so the closure hive-c0re pulls through the daemon is in
|
||||||
# store the container is then pointed at.
|
# the same store the container is then pointed at.
|
||||||
NIX_REMOTE = "daemon";
|
NIX_REMOTE = "daemon";
|
||||||
# The owner a namespaced agent's data moves to. Which agents are
|
# The owner a namespaced agent's data moves to. Which agents are
|
||||||
# namespaced is not set here: hive-c0re sends it with each start.
|
# namespaced is not set here: hive-c0re sends it with each start.
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,26 @@
|
||||||
# The root-side copy of the two cache facts hive-priv trusts: the key a
|
# The root-side copy of the cache facts the host's nix daemon and hive-priv
|
||||||
# downloaded closure must be signed by and the netrc the pull token goes out
|
# trust: the key a downloaded closure must be signed by and the netrc the pull
|
||||||
# in. The script runs as root on the hive host under the hive's own store
|
# token goes out in. The script runs as root on the hive host under the hive's
|
||||||
# identity and writes both into `dir`, which only root can read.
|
# own store identity and writes both into `dir`, which only root can read, and
|
||||||
|
# a nix.conf fragment at `confFile` that the host's nix.conf includes.
|
||||||
#
|
#
|
||||||
# Everything hive-priv decides on is here or in the host's configuration, and
|
# What the daemon and hive-priv decide on is here or in the host's
|
||||||
# nothing is read from a file hive-c0re (unprivileged) writes: the key and the
|
# configuration, and nothing is read from a file hive-c0re (unprivileged)
|
||||||
# token come from the store, the cache's address from
|
# writes: the key and the token come from the store, the cache's address from
|
||||||
# `services.hyperhive.swarm.attic.{domain,cache}`.
|
# `services.hyperhive.swarm.attic.{domain,cache}`. The fragment holds no secret
|
||||||
|
# (the key is public, the netrc stays in `dir`) and hive-c0re can read it: it
|
||||||
|
# takes the cache's address from there.
|
||||||
#
|
#
|
||||||
# A store that holds no facts yet, or cannot be reached, leaves what is in `dir`
|
# A store that holds no facts yet, or cannot be reached, leaves what is in `dir`
|
||||||
# as it is. Nothing here removes a file.
|
# and `confFile` as they are. Nothing here removes a file.
|
||||||
#
|
#
|
||||||
# Pure function — NOT a NixOS module. Call it from a unit's `script`, with
|
# Pure function — NOT a NixOS module. Call it from a unit's `script`, with
|
||||||
# `bao`, `coreutils` and `gnugrep` on its `path` and the `BAO_*` client
|
# `bao`, `coreutils`, `gnugrep` and `systemd` on its `path` and the `BAO_*`
|
||||||
# environment set:
|
# client environment set:
|
||||||
#
|
#
|
||||||
# script = import ./lib/attic-root-facts.nix { inherit lib; } {
|
# script = import ./lib/attic-root-facts.nix { inherit lib; } {
|
||||||
# hive = "alpha"; host = "attic.example.org"; dir = "/run/hyperhive-priv/attic";
|
# hive = "alpha"; host = "attic.example.org"; dir = "/run/hyperhive-priv/attic";
|
||||||
|
# url = "https://attic.example.org/swarm"; confFile = "/run/hyperhive-nix/cache.conf";
|
||||||
# };
|
# };
|
||||||
{ lib }:
|
{ lib }:
|
||||||
{
|
{
|
||||||
|
|
@ -25,8 +29,12 @@
|
||||||
hive,
|
hive,
|
||||||
# The cache server's host name, which the netrc line names.
|
# The cache server's host name, which the netrc line names.
|
||||||
host,
|
host,
|
||||||
# Where the two files go.
|
# Where the key and the netrc go.
|
||||||
dir,
|
dir,
|
||||||
|
# The cache's address as the daemon lists it in `trusted-substituters`.
|
||||||
|
url,
|
||||||
|
# Where the nix.conf fragment goes; its directory is root's and readable.
|
||||||
|
confFile,
|
||||||
}:
|
}:
|
||||||
''
|
''
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
@ -35,7 +43,9 @@
|
||||||
${import ./refresh-consumer.nix { }}
|
${import ./refresh-consumer.nix { }}
|
||||||
|
|
||||||
dir=${lib.escapeShellArg dir}
|
dir=${lib.escapeShellArg dir}
|
||||||
|
conf=${lib.escapeShellArg confFile}
|
||||||
install -d -m 0700 "$dir"
|
install -d -m 0700 "$dir"
|
||||||
|
install -d -m 0755 "$(dirname -- "$conf")"
|
||||||
|
|
||||||
store_init
|
store_init
|
||||||
store_login
|
store_login
|
||||||
|
|
@ -84,4 +94,17 @@
|
||||||
if secret_differs "$dir/netrc" "$netrc"; then
|
if secret_differs "$dir/netrc" "$netrc"; then
|
||||||
atomic_write_secret 0600 "" "$dir/netrc" "$netrc"
|
atomic_write_secret 0600 "" "$dir/netrc" "$netrc"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Last, so a daemon that reads the fragment finds the netrc in place. The
|
||||||
|
# address is the one hive-c0re may name as the substituter. The mtime moves
|
||||||
|
# only with the content. Lix re-reads nix.conf for every connection, CppNix
|
||||||
|
# only at start, so a daemon that started before this write is restarted.
|
||||||
|
printf -v fragment '%s\n%s\n%s' \
|
||||||
|
${lib.escapeShellArg "extra-trusted-substituters = ${url}"} \
|
||||||
|
"extra-trusted-public-keys = $key" \
|
||||||
|
"netrc-file = $dir/netrc"
|
||||||
|
if secret_differs "$conf" "$fragment"; then
|
||||||
|
atomic_write_secret 0644 "" "$conf" "$fragment"
|
||||||
|
fi
|
||||||
|
refresh_unit nix-daemon.service "$conf"
|
||||||
''
|
''
|
||||||
|
|
|
||||||
|
|
@ -878,10 +878,23 @@ let
|
||||||
inner.ProcSubset == "all" && inner.ProtectProc == "invisible";
|
inner.ProcSubset == "all" && inner.ProtectProc == "invisible";
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
name = "hive-priv is told the cache's address by the host's configuration";
|
# The daemon only learns the cache from the fragment the root unit
|
||||||
|
# writes, and lists as trusted the one address from the host's
|
||||||
|
# configuration. A host with no unit has no include.
|
||||||
|
name = "the host's nix.conf includes the cache fragment the root unit writes, with the cache's address";
|
||||||
ok =
|
ok =
|
||||||
allLocal.systemd.services.hive-priv.environment.HIVE_PRIV_CACHE_URL == "https://attic.t.local/swarm"
|
let
|
||||||
&& !(noIdentity.systemd.services.hive-priv.environment ? HIVE_PRIV_CACHE_URL);
|
u = units.hive-priv-cache-facts;
|
||||||
|
in
|
||||||
|
lib.hasInfix "!include /run/hyperhive-nix/cache.conf" allLocal.nix.extraOptions
|
||||||
|
&& !(lib.hasInfix "hyperhive-nix" noIdentity.nix.extraOptions)
|
||||||
|
&& lib.hasInfix "conf=/run/hyperhive-nix/cache.conf" u.script
|
||||||
|
&& lib.hasInfix "extra-trusted-substituters = https://attic.t.local/swarm" u.script
|
||||||
|
&& lib.hasInfix ''atomic_write_secret 0644 "" "$conf"'' u.script
|
||||||
|
&& lib.elem "d /run/hyperhive-nix 0755 root root - -" allLocal.systemd.tmpfiles.rules
|
||||||
|
# Control for the absence: the lookup finds the unit's other variable.
|
||||||
|
&& allLocal.systemd.services.hive-priv.environment ? NIX_REMOTE
|
||||||
|
&& !(allLocal.systemd.services.hive-priv.environment ? HIVE_PRIV_CACHE_URL);
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
in
|
in
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ let
|
||||||
hive = "alpha";
|
hive = "alpha";
|
||||||
host = "cache.example";
|
host = "cache.example";
|
||||||
dir = "@DIR@/attic";
|
dir = "@DIR@/attic";
|
||||||
|
url = "https://cache.example/swarm";
|
||||||
|
confFile = "@DIR@/nix/cache.conf";
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
in
|
in
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue