keep the cache's key and pull token for hive-priv in a root-only directory
This commit is contained in:
parent
d241e64ab1
commit
b845630023
13 changed files with 519 additions and 22 deletions
|
|
@ -388,7 +388,16 @@ store doesn't hold, and leaves a copy whose source disappeared.
|
|||
identity and writes `/run/hyperhive/attic/netrc`, `substituter` and
|
||||
`public-key` (tmpfs, each `0600`). It accepts only an `https` URL, since the
|
||||
token goes out in a header and in the netrc; a hive that finds another scheme
|
||||
writes nothing. No deploy reads the files yet: deploys still build locally.
|
||||
writes nothing.
|
||||
|
||||
`hive-priv` doesn't read those files. The key that must have signed a download and
|
||||
the pull token come from `hive-priv-cache-facts`, a root unit that reads the
|
||||
hive's own `attic/public-key` and `attic/pull-token` from the store under the
|
||||
hive's own store identity every ten minutes and writes them to
|
||||
`/run/hyperhive-priv/attic/` (`0700`, root; each file `0600`). The cache's
|
||||
address is the host's `services.hyperhive.swarm.attic.domain` and `cache`,
|
||||
handed to `hive-priv` in its unit's environment. A file `hive-c0re` writes
|
||||
decides nothing `hive-priv` trusts.
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -22,7 +22,9 @@ hive's domain and `*.<hive domain>`, read from
|
|||
`services.hyperhive.swarm.hives.<name>.domain` on the store's host, with no
|
||||
IP SANs and no `localhost`. A request for any other name is a refusal from
|
||||
the store naming that name. The hive's policy grants `update` on that one
|
||||
issue path and nothing else. `swarm-bao-hive-leaf-policy.service`, on the
|
||||
issue path and `read` on its own `attic/public-key` and `attic/pull-token`
|
||||
(see [the cache facts for hive-priv](credentials-reference.md)), and nothing
|
||||
else. `swarm-bao-hive-leaf-policy.service`, on the
|
||||
store's host, writes the role, the policy and the cert-auth role for every
|
||||
hive in `swarm.hives`.
|
||||
|
||||
|
|
|
|||
|
|
@ -146,13 +146,14 @@ pieces fit, and the reasoning behind them, is in [`credentials.md`](credentials.
|
|||
|
||||
### Hive-level — one of each per hive
|
||||
|
||||
| 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` |
|
||||
| the hive's own store identity | `glue-bao-tls.nix` on the store's host; placed by hand on every other host ([`ca.md`](ca.md#the-gateway-leaf-comes-from-the-secret-store)) | `deploy.hive-controller.tls.baoHiveIdentityCertFile` / `baoHiveIdentityKeyFile` |
|
||||
| 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` |
|
||||
| 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, and copies it (account on the first line, token on the second) to `/run/hyperhive/forge-hive-token` (tmpfs, `0600` `hive-core`) for nix's `git`, which `hive-priv` also runs. Each `internal/meta` read and each boot rewrites the copy when it differs; where they differ after a rotation, a build that starts before the next read fails to authenticate, and a retry reads the new token |
|
||||
| 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` |
|
||||
| the hive's own store identity | `glue-bao-tls.nix` on the store's host; placed by hand on every other host ([`ca.md`](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 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` |
|
||||
| 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, and copies it (account on the first line, token on the second) to `/run/hyperhive/forge-hive-token` (tmpfs, `0600` `hive-core`) for nix's `git`, which `hive-priv` also runs. Each `internal/meta` read and each boot rewrites the copy when it differs; where they differ after a rotation, a build that starts before the next read fails to authenticate, and a retry reads the new token |
|
||||
|
||||
### Store identities, one per principal
|
||||
|
||||
|
|
|
|||
|
|
@ -72,6 +72,10 @@ in
|
|||
# cache's key, cache and tokens, twice over against stub tools: the second
|
||||
# run must mint nothing.
|
||||
script-test-attic-init = import ./script-tests/attic-init.nix { inherit pkgs; };
|
||||
# Runs ./host-modules/lib/attic-root-facts.nix, the root unit that keeps the
|
||||
# cache's key and pull token for hive-priv, against a stub `bao`: file modes,
|
||||
# no rewrite when nothing changed, and no file removed when the store fails.
|
||||
script-test-attic-root-facts = import ./script-tests/attic-root-facts.nix { inherit pkgs; };
|
||||
# Runs ./host-modules/lib/store-seeded-secret.nix's seed, store-wins and mint
|
||||
# decisions against a stub `bao` that keeps its leaves in files.
|
||||
script-test-store-seeded-secret = import ./script-tests/store-seeded-secret.nix { inherit pkgs; };
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
./hive-matrix.nix
|
||||
./hive-network.nix
|
||||
./hive-priv.nix
|
||||
./hive-priv-cache.nix
|
||||
./hive-squid.nix
|
||||
./hive-tls.nix
|
||||
./otel.nix
|
||||
|
|
|
|||
98
nix/host-modules/hive-priv-cache.nix
Normal file
98
nix/host-modules/hive-priv-cache.nix
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# The binary-cache facts hive-priv (root) trusts, kept where only root can
|
||||
# change them.
|
||||
#
|
||||
# hive-priv downloads an agent's closure from the swarm cache when a rebuild
|
||||
# asks it to. What decides whether a download is accepted — which cache, and
|
||||
# which key must have signed it — must 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
|
||||
# ({option}`services.hyperhive.swarm.attic.domain` and `cache`), handed to
|
||||
# hive-priv in its unit's environment;
|
||||
# - 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
|
||||
# land in `/run/hyperhive-priv/attic/` (root, `0700`; files `0600`).
|
||||
#
|
||||
# hive-c0re keeps its own copies under `/run/hyperhive/attic/` for the probe,
|
||||
# which only reports.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
hyperhiveCfg = config.services.hyperhive;
|
||||
hiveController = hyperhiveCfg.deploy.hive-controller;
|
||||
tls = hiveController.tls;
|
||||
baoCfg = hyperhiveCfg.swarm.bao;
|
||||
baoDeploy = hyperhiveCfg.deploy.bao;
|
||||
atticCfg = hyperhiveCfg.swarm.attic;
|
||||
|
||||
# Without a cache to name, a hive name to read under or an identity to log
|
||||
# in with there is nothing to fetch, and hive-priv has no cache to download
|
||||
# from.
|
||||
enabled =
|
||||
hiveController.enable
|
||||
&& atticCfg.domain != null
|
||||
&& atticCfg.cache != null
|
||||
&& hiveController.hiveName != null
|
||||
&& tls.baoHiveIdentityCertFile != null
|
||||
&& tls.baoHiveIdentityKeyFile != null;
|
||||
|
||||
dir = "/run/hyperhive-priv/attic";
|
||||
|
||||
storeRetry = import ./lib/store-retry.nix { };
|
||||
storeRefreshTimer = import ./lib/store-refresh-timer.nix { };
|
||||
in
|
||||
{
|
||||
config = lib.mkIf enabled {
|
||||
systemd.tmpfiles.rules = [ "d /run/hyperhive-priv 0700 root root - -" ];
|
||||
|
||||
# The URL nix is told is the only substituter for the download; no request
|
||||
# chooses it.
|
||||
systemd.services.hive-priv.environment.HIVE_PRIV_CACHE_URL =
|
||||
"https://${atticCfg.domain}/${atticCfg.cache}";
|
||||
|
||||
systemd.services.hive-priv-cache-facts = {
|
||||
description = "Read the swarm cache's public key and pull token for hive-priv";
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
wants = [ "network-online.target" ];
|
||||
after = [
|
||||
"network-online.target"
|
||||
"systemd-tmpfiles-setup.service"
|
||||
];
|
||||
path = [
|
||||
baoDeploy.package
|
||||
pkgs.coreutils
|
||||
pkgs.gnugrep
|
||||
pkgs.systemd
|
||||
];
|
||||
environment = {
|
||||
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
|
||||
BAO_CLIENT_CERT = tls.baoHiveIdentityCertFile;
|
||||
BAO_CLIENT_KEY = tls.baoHiveIdentityKeyFile;
|
||||
}
|
||||
// lib.optionalAttrs (baoDeploy.serverCaFile != null) {
|
||||
BAO_CACERT = baoDeploy.serverCaFile;
|
||||
};
|
||||
# ./lib/store-retry.nix: the login fails for as long as the store is
|
||||
# sealed or down.
|
||||
inherit (storeRetry) startLimitBurst startLimitIntervalSec;
|
||||
serviceConfig = storeRetry.serviceConfig // {
|
||||
Type = "oneshot";
|
||||
UMask = "0077";
|
||||
SyslogIdentifier = "hive-priv-cache-facts";
|
||||
TimeoutStartSec = "5min";
|
||||
};
|
||||
script = import ./lib/attic-root-facts.nix { inherit lib; } {
|
||||
hive = hiveController.hiveName;
|
||||
host = atticCfg.domain;
|
||||
inherit dir;
|
||||
};
|
||||
};
|
||||
|
||||
systemd.timers.hive-priv-cache-facts = storeRefreshTimer "re-read the swarm cache's facts for hive-priv";
|
||||
};
|
||||
}
|
||||
|
|
@ -127,8 +127,9 @@ let
|
|||
|
||||
# The hive's own store identity: its subject is
|
||||
# `<hiveLeafCommonNamePrefix>-<hiveName>`, and its policy may issue from
|
||||
# `hiveLeafRole` alone. It also carries the root fetch's handshake. Every
|
||||
# hive must hold one.
|
||||
# `hiveLeafRole` and read this hive's own cache public key and pull token
|
||||
# (`hive-priv-cache-facts`, ./hive-priv-cache.nix), and nothing else. It also
|
||||
# carries the root fetch's handshake. Every hive must hold one.
|
||||
haveHiveIdentity = cfg.baoHiveIdentityCertFile != null && cfg.baoHiveIdentityKeyFile != null;
|
||||
requireHiveIdentity = requireIdentity {
|
||||
holder = "this hive";
|
||||
|
|
@ -327,7 +328,8 @@ in
|
|||
Its subject has to be
|
||||
`<services.hyperhive.deploy.bao.hiveLeafCommonNamePrefix>-<hiveName>`;
|
||||
that cert-auth role's policy may issue from this hive's gateway-leaf
|
||||
role and nothing else.
|
||||
role and read this hive's own cache public key and pull token, and
|
||||
nothing else. `hive-priv-cache-facts` reads those two under this identity.
|
||||
|
||||
Every hive with a self-signed gateway needs it: without it both units
|
||||
fail, naming this option and
|
||||
|
|
|
|||
87
nix/host-modules/lib/attic-root-facts.nix
Normal file
87
nix/host-modules/lib/attic-root-facts.nix
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# The root-side copy of the two cache facts hive-priv trusts: the key a
|
||||
# downloaded closure must be signed by and the netrc the pull token goes out
|
||||
# in. The script runs as root on the hive host under the hive's own store
|
||||
# identity and writes both into `dir`, which only root can read.
|
||||
#
|
||||
# Everything hive-priv decides on is here or in the host's configuration, and
|
||||
# nothing is read from a file hive-c0re (unprivileged) writes: the key and the
|
||||
# token come from the store, the cache's address from
|
||||
# `services.hyperhive.swarm.attic.{domain,cache}`.
|
||||
#
|
||||
# A store that holds no facts yet, or cannot be reached, leaves what is in `dir`
|
||||
# as it is. Nothing here removes a file.
|
||||
#
|
||||
# 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
|
||||
# environment set:
|
||||
#
|
||||
# script = import ./lib/attic-root-facts.nix { inherit lib; } {
|
||||
# hive = "alpha"; host = "attic.example.org"; dir = "/run/hyperhive-priv/attic";
|
||||
# };
|
||||
{ lib }:
|
||||
{
|
||||
# The hive's name in the store: facts are read from
|
||||
# `secret/swarm/hives/<hive>/attic/`.
|
||||
hive,
|
||||
# The cache server's host name, which the netrc line names.
|
||||
host,
|
||||
# Where the two files go.
|
||||
dir,
|
||||
}:
|
||||
''
|
||||
set -euo pipefail
|
||||
${import ./store-seeded-secret.nix { }}
|
||||
${import ./atomic-write-secret.nix { }}
|
||||
${import ./refresh-consumer.nix { }}
|
||||
|
||||
dir=${lib.escapeShellArg dir}
|
||||
install -d -m 0700 "$dir"
|
||||
|
||||
store_init
|
||||
store_login
|
||||
if [ "$STORE_UP" != 1 ]; then
|
||||
echo "could not read the cache facts; $dir stays as it is." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# <leaf> <variable>. Sets the variable to the leaf's value. A leaf the store
|
||||
# does not hold sets `missing`; any other failure ends the run.
|
||||
missing=0
|
||||
fetch() {
|
||||
local value
|
||||
if value="$(store_bao kv get -field=value ${lib.escapeShellArg "secret/swarm/hives/${hive}/attic"}/"$1" 2>"$STORE_ERR")"; then
|
||||
printf -v "$2" '%s' "$value"
|
||||
elif grep -qi 'no value found' "$STORE_ERR"; then
|
||||
missing=1
|
||||
else
|
||||
cat "$STORE_ERR" >&2
|
||||
echo "could not read the cache fact $1; $dir stays as it is." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
key=""
|
||||
token=""
|
||||
fetch public-key key
|
||||
fetch pull-token token
|
||||
if [ "$missing" = 1 ]; then
|
||||
echo "the swarm has not given this hive its cache facts yet; $dir stays as it is."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# One token each: both land in a file nix and hive-priv parse by whitespace.
|
||||
if [[ ! "$key" =~ ^[^[:space:]:]+:[^[:space:]]+$ ]] || [[ ! "$token" =~ ^[^[:space:]]+$ ]]; then
|
||||
echo "the stored cache facts are not a <name>:<key> and a single token; $dir stays as it is." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Written only when the value changed, so the files keep their mtime between
|
||||
# runs. The owner is the running user, root, and the mode is 0600.
|
||||
if secret_differs "$dir/public-key" "$key"; then
|
||||
atomic_write_secret 0600 "" "$dir/public-key" "$key"
|
||||
fi
|
||||
netrc="machine ${host} password $token"
|
||||
if secret_differs "$dir/netrc" "$netrc"; then
|
||||
atomic_write_secret 0600 "" "$dir/netrc" "$netrc"
|
||||
fi
|
||||
''
|
||||
|
|
@ -935,8 +935,30 @@ let
|
|||
cn = "${baoDeploy.hiveLeafCommonNamePrefix}-${hiveName}";
|
||||
inherit (hive) domain;
|
||||
dashboard = hyperhiveCfg.swarm.dashboardDomains.${hiveName};
|
||||
inherit hiveName;
|
||||
}) hyperhiveCfg.swarm.hives;
|
||||
|
||||
# What a hive's gateway-leaf identity may do: issue from its own role, and
|
||||
# read two of its own cache facts. The root unit on the hive host
|
||||
# (`hive-priv-cache-facts`, ./hive-priv-cache.nix) reads them under this
|
||||
# identity: the key hive-priv's downloads must be signed by, and the token it
|
||||
# pulls with. The cache's URL and name come from the host's configuration,
|
||||
# not from the store.
|
||||
hiveLeafPolicyText =
|
||||
obj:
|
||||
''
|
||||
path "${servicesPkiMountPath}/issue/${obj.name}" {
|
||||
capabilities = ["update"]
|
||||
}
|
||||
''
|
||||
+
|
||||
lib.concatMapStrings
|
||||
(fact: readStanza "${credentialMountPath}/data/swarm/hives/${obj.hiveName}/attic/${fact}")
|
||||
[
|
||||
"public-key"
|
||||
"pull-token"
|
||||
];
|
||||
|
||||
# The PKI mount agent client certificates are issued from. Its root is
|
||||
# generated inside the store, so the agent CA's key never exists outside it.
|
||||
# A mount of its own because the services mount holds exactly one issuer; a
|
||||
|
|
@ -2241,9 +2263,11 @@ in
|
|||
example = "swarm-bao-hive-leaf.svc";
|
||||
description = ''
|
||||
Prefix of the subject the store's per-hive gateway-leaf roles accept.
|
||||
The role for hive `h` accepts `<prefix>-h` and may only issue from
|
||||
The role for hive `h` accepts `<prefix>-h`. Its policy may issue from
|
||||
`<servicesPkiMountPath>/issue/<hiveLeafPkiRolePrefix>-h`, which issues
|
||||
for `h`'s domain and `*.` that domain and nothing else.
|
||||
for `h`'s domain and `*.` that domain and nothing else, and read
|
||||
`secret/swarm/hives/h/attic/public-key` and `…/pull-token`, which the
|
||||
root unit `hive-priv-cache-facts` fetches for hive-priv.
|
||||
|
||||
Per hive because one subject for every hive could ask for any hive's
|
||||
certificate.
|
||||
|
|
@ -3792,11 +3816,7 @@ in
|
|||
ttl=${servicesPkiLeafTtl} \
|
||||
max_ttl=${servicesPkiLeafTtl}
|
||||
|
||||
printf '%s' ${lib.escapeShellArg ''
|
||||
path "${servicesPkiMountPath}/issue/${obj.name}" {
|
||||
capabilities = ["update"]
|
||||
}
|
||||
''} |
|
||||
printf '%s' ${lib.escapeShellArg (hiveLeafPolicyText obj)} |
|
||||
bao policy write ${lib.escapeShellArg obj.name} -
|
||||
|
||||
bao write auth/cert/certs/${lib.escapeShellArg obj.name} \
|
||||
|
|
|
|||
|
|
@ -1496,9 +1496,9 @@ let
|
|||
{
|
||||
# One role, policy and login role per hive in the directory, each
|
||||
# naming only its own hive: the policy text in the unit is exactly the
|
||||
# two `update` grants, and each hive's block names neither the other's
|
||||
# role nor its subject.
|
||||
name = "each hive's gateway-leaf identity may issue from its own role alone, for its own domain and dashboard";
|
||||
# `update` on its own role and `read` on its own two cache facts, and each
|
||||
# hive's block names neither the other's role nor its subject.
|
||||
name = "each hive's gateway-leaf identity may issue from its own role and read its own two cache facts, and nothing else";
|
||||
ok =
|
||||
let
|
||||
s = baoGrantTwoHives.systemd.services.swarm-bao-hive-leaf-policy.script;
|
||||
|
|
@ -1528,10 +1528,26 @@ let
|
|||
path = "pki/issue/swarm-hive-leaf-h1";
|
||||
caps = [ "update" ];
|
||||
}
|
||||
{
|
||||
path = "secret/data/swarm/hives/h1/attic/public-key";
|
||||
caps = [ "read" ];
|
||||
}
|
||||
{
|
||||
path = "secret/data/swarm/hives/h1/attic/pull-token";
|
||||
caps = [ "read" ];
|
||||
}
|
||||
{
|
||||
path = "pki/issue/swarm-hive-leaf-h2";
|
||||
caps = [ "update" ];
|
||||
}
|
||||
{
|
||||
path = "secret/data/swarm/hives/h2/attic/public-key";
|
||||
caps = [ "read" ];
|
||||
}
|
||||
{
|
||||
path = "secret/data/swarm/hives/h2/attic/pull-token";
|
||||
caps = [ "read" ];
|
||||
}
|
||||
]
|
||||
&& issues "h1" "h1.t.local" h1
|
||||
&& issues "h2" "h2.example.org" h2
|
||||
|
|
|
|||
|
|
@ -758,6 +758,54 @@ let
|
|||
]
|
||||
&& lib.hasInfix "--connect-timeout 10 --max-time 30" (noSvcBranch noSvcFetch);
|
||||
}
|
||||
{
|
||||
# What hive-priv trusts comes from root-owned places: the store, under
|
||||
# this hive's own identity, and nothing under `swarm/services/`, which
|
||||
# no hive may read.
|
||||
name = "the root cache-facts unit reads this hive's own facts under its store identity and writes a root-only directory";
|
||||
ok =
|
||||
let
|
||||
u = units.hive-priv-cache-facts;
|
||||
in
|
||||
lib.hasInfix "secret/swarm/hives/h1/attic" u.script
|
||||
&& !(lib.hasInfix "swarm/services" u.script)
|
||||
&& !(lib.hasInfix "swarm/hives/h2" u.script)
|
||||
&& lib.hasInfix "dir=/run/hyperhive-priv/attic" u.script
|
||||
&& lib.hasInfix "install -d -m 0700 \"$dir\"" u.script
|
||||
&& lib.hasInfix ''atomic_write_secret 0600 "" "$dir/public-key"'' u.script
|
||||
&& lib.hasInfix ''atomic_write_secret 0600 "" "$dir/netrc"'' u.script
|
||||
&& lib.hasInfix "machine attic.t.local password" u.script
|
||||
&& u.environment.BAO_CLIENT_CERT == leafUnit.environment.BAO_CLIENT_CERT
|
||||
&& u.environment.BAO_CLIENT_KEY == leafUnit.environment.BAO_CLIENT_KEY
|
||||
&& u.environment.BAO_CLIENT_CERT != allLocal.services.hyperhive.deploy.bao.clientCertFile
|
||||
&& u.serviceConfig.Type == "oneshot"
|
||||
&& !(remainsAfterExit u)
|
||||
&& lib.elem "d /run/hyperhive-priv 0700 root root - -" allLocal.systemd.tmpfiles.rules;
|
||||
}
|
||||
{
|
||||
# The control for the case above, and the one below: a hive with no
|
||||
# store identity has neither unit and hive-priv gets no cache URL, so a
|
||||
# lookup that always came back empty would fail the case before it.
|
||||
name = "the cache-facts unit has a timer, and a hive with no store identity has neither unit";
|
||||
ok =
|
||||
let
|
||||
t = allLocal.systemd.timers.hive-priv-cache-facts;
|
||||
in
|
||||
lib.elem "timers.target" t.wantedBy
|
||||
&& t.timerConfig.OnUnitInactiveSec == "10min"
|
||||
&& !(noIdentity.systemd.services ? hive-priv-cache-facts)
|
||||
&& !(noIdentity.systemd.timers ? hive-priv-cache-facts)
|
||||
&& noService.systemd.services ? hive-priv-cache-facts
|
||||
&&
|
||||
noService.systemd.services.hive-priv-cache-facts.environment.BAO_CLIENT_CERT
|
||||
== "/etc/pki/bao-hive.pem";
|
||||
}
|
||||
{
|
||||
name = "hive-priv is told the cache's address by the host's configuration";
|
||||
ok =
|
||||
allLocal.systemd.services.hive-priv.environment.HIVE_PRIV_CACHE_URL == "https://attic.t.local/swarm"
|
||||
&& !(noIdentity.systemd.services.hive-priv.environment ? HIVE_PRIV_CACHE_URL);
|
||||
}
|
||||
];
|
||||
in
|
||||
runGroup "hive-tls" cases
|
||||
|
|
|
|||
32
nix/script-tests/attic-root-facts.nix
Normal file
32
nix/script-tests/attic-root-facts.nix
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# `checks.script-test-attic-root-facts` — runs
|
||||
# ../host-modules/lib/attic-root-facts.nix's script, as `hive-priv-cache-facts`
|
||||
# runs it, against a stub `bao` that keeps its leaves in files. The cases are in
|
||||
# ./attic-root-facts.sh.
|
||||
#
|
||||
# What runs is the rendered script, with the directory it writes moved into the
|
||||
# test's scratch space.
|
||||
{
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
script = pkgs.writeText "attic-root-facts.sh" (
|
||||
import ../host-modules/lib/attic-root-facts.nix { inherit (pkgs) lib; } {
|
||||
hive = "alpha";
|
||||
host = "cache.example";
|
||||
dir = "@DIR@/attic";
|
||||
}
|
||||
);
|
||||
in
|
||||
pkgs.runCommand "hyperhive-script-test-attic-root-facts"
|
||||
{
|
||||
SCRIPT = script;
|
||||
nativeBuildInputs = [
|
||||
pkgs.coreutils
|
||||
pkgs.gnugrep
|
||||
];
|
||||
}
|
||||
''
|
||||
${pkgs.bash}/bin/bash ${./attic-root-facts.sh}
|
||||
touch "$out"
|
||||
''
|
||||
177
nix/script-tests/attic-root-facts.sh
Normal file
177
nix/script-tests/attic-root-facts.sh
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
# Cases for ../host-modules/lib/attic-root-facts.nix. Each case runs the script
|
||||
# against a stub `bao` over a store kept in files, and compares what the script
|
||||
# left in its directory and which leaves it read.
|
||||
set -uo pipefail
|
||||
|
||||
failures=0
|
||||
count=0
|
||||
case="setup"
|
||||
|
||||
fail() {
|
||||
echo "FAILED: $case: $*" >&2
|
||||
failures=$((failures + 1))
|
||||
}
|
||||
|
||||
expect() { # <description> <got> <want>
|
||||
count=$((count + 1))
|
||||
if [ "$2" != "$3" ]; then fail "$1: got '$2', wanted '$3'"; fi
|
||||
}
|
||||
|
||||
dir=$(mktemp -d)
|
||||
mkdir -p "$dir/bin"
|
||||
sed "s|@DIR@|$dir|g" "$SCRIPT" >"$dir/script.sh"
|
||||
calls=$dir/calls
|
||||
export DIR=$dir
|
||||
|
||||
# `login` answers from LOGIN_FAIL. `kv get -field=value <path>` prints the file
|
||||
# kept for the path; a missing one is the store's "no value found" and DENY_READ
|
||||
# is a policy that grants nothing. Every call is logged.
|
||||
cat >"$dir/bin/bao" <<STUB
|
||||
#!$BASH
|
||||
echo "bao \$*" >>"$calls"
|
||||
case "\$1 \$2" in
|
||||
"login -method=cert")
|
||||
if [ "\${LOGIN_FAIL:-0}" = 1 ]; then echo "certificate refused" >&2; exit 2; fi
|
||||
echo store-token
|
||||
;;
|
||||
"kv get")
|
||||
[ "\${BAO_TOKEN-}" = store-token ] || { echo "kv without the login's token" >&2; exit 97; }
|
||||
f="\$DIR/store/\$(printf '%s' "\$4" | tr / _)"
|
||||
if [ "\${DENY_READ:-0}" = 1 ]; then echo "permission denied" >&2; exit 2; fi
|
||||
[ -f "\$f" ] || { echo "No value found at \$4" >&2; exit 2; }
|
||||
cat "\$f"
|
||||
;;
|
||||
*) echo "unexpected call: \$*" >&2; exit 98 ;;
|
||||
esac
|
||||
STUB
|
||||
chmod +x "$dir/bin/bao"
|
||||
export PATH="$dir/bin:$PATH"
|
||||
|
||||
KEYLEAF=secret_swarm_hives_alpha_attic_public-key
|
||||
TOKENLEAF=secret_swarm_hives_alpha_attic_pull-token
|
||||
|
||||
reset() {
|
||||
rm -rf "$dir/store" "$dir/attic" "$calls"
|
||||
mkdir -p "$dir/store"
|
||||
: >"$calls"
|
||||
}
|
||||
|
||||
seed() { # <key> <token>
|
||||
printf '%s' "$1" >"$dir/store/$KEYLEAF"
|
||||
printf '%s' "$2" >"$dir/store/$TOKENLEAF"
|
||||
}
|
||||
|
||||
# run [VAR=value…]: the script's exit status.
|
||||
run() {
|
||||
env "$@" bash "$dir/script.sh" >"$dir/out" 2>"$dir/err"
|
||||
}
|
||||
|
||||
mode() { stat -c %a "$1" 2>/dev/null || echo missing; }
|
||||
ino() { stat -c %i "$1" 2>/dev/null || echo missing; }
|
||||
body() { cat "$1" 2>/dev/null || echo missing; }
|
||||
|
||||
case="first run writes the key and the netrc, each 0600, in a 0700 directory"
|
||||
reset
|
||||
seed "swarm:S0VZ" "tok-1"
|
||||
run
|
||||
expect "exit" $? 0
|
||||
expect "key" "$(body "$dir/attic/public-key")" "swarm:S0VZ"
|
||||
expect "netrc" "$(body "$dir/attic/netrc")" "machine cache.example password tok-1"
|
||||
expect "key mode" "$(mode "$dir/attic/public-key")" 600
|
||||
expect "netrc mode" "$(mode "$dir/attic/netrc")" 600
|
||||
expect "dir mode" "$(mode "$dir/attic")" 700
|
||||
|
||||
case="a second run with the same facts rewrites nothing"
|
||||
k=$(ino "$dir/attic/public-key")
|
||||
n=$(ino "$dir/attic/netrc")
|
||||
run
|
||||
expect "exit" $? 0
|
||||
expect "key untouched" "$(ino "$dir/attic/public-key")" "$k"
|
||||
expect "netrc untouched" "$(ino "$dir/attic/netrc")" "$n"
|
||||
|
||||
case="a rotated token replaces the netrc and leaves the key"
|
||||
seed "swarm:S0VZ" "tok-2"
|
||||
run
|
||||
expect "exit" $? 0
|
||||
expect "netrc" "$(body "$dir/attic/netrc")" "machine cache.example password tok-2"
|
||||
expect "netrc mode" "$(mode "$dir/attic/netrc")" 600
|
||||
expect "key untouched" "$(ino "$dir/attic/public-key")" "$k"
|
||||
|
||||
case="a rotated key replaces the key file"
|
||||
seed "swarm:Tk9XCg" "tok-2"
|
||||
run
|
||||
expect "exit" $? 0
|
||||
expect "key" "$(body "$dir/attic/public-key")" "swarm:Tk9XCg"
|
||||
|
||||
case="only the hive's own leaves are read, after a login"
|
||||
expect "reads" "$(grep -c '^bao kv get' "$calls")" "$(grep -c '^bao kv get -field=value secret/swarm/hives/alpha/attic/' "$calls")"
|
||||
expect "login first" "$(head -1 "$calls")" "bao login -method=cert -token-only"
|
||||
expect "something was read" "$([ "$(grep -c '^bao kv get' "$calls")" -ge 2 ] && echo yes || echo no)" yes
|
||||
|
||||
case="a store with no facts exits cleanly and creates nothing"
|
||||
reset
|
||||
run
|
||||
expect "exit" $? 0
|
||||
expect "no key" "$(body "$dir/attic/public-key")" missing
|
||||
expect "no netrc" "$(body "$dir/attic/netrc")" missing
|
||||
|
||||
case="one fact without the other creates nothing"
|
||||
reset
|
||||
printf '%s' "swarm:S0VZ" >"$dir/store/$KEYLEAF"
|
||||
run
|
||||
expect "exit" $? 0
|
||||
expect "no key" "$(body "$dir/attic/public-key")" missing
|
||||
expect "no netrc" "$(body "$dir/attic/netrc")" missing
|
||||
|
||||
# The failure cases start from a good state and an extra file in the directory.
|
||||
good() {
|
||||
reset
|
||||
seed "swarm:S0VZ" "tok-1"
|
||||
run
|
||||
touch "$dir/attic/other"
|
||||
}
|
||||
intact() {
|
||||
expect "key kept" "$(body "$dir/attic/public-key")" "swarm:S0VZ"
|
||||
expect "netrc kept" "$(body "$dir/attic/netrc")" "machine cache.example password tok-1"
|
||||
expect "other file kept" "$(body "$dir/attic/other")" ""
|
||||
}
|
||||
|
||||
case="a store that refuses the login fails the run and removes nothing"
|
||||
good
|
||||
seed "swarm:NEW" "tok-new"
|
||||
run LOGIN_FAIL=1
|
||||
expect "exit" $? 1
|
||||
intact
|
||||
|
||||
case="a store that refuses the read fails the run and removes nothing"
|
||||
good
|
||||
run DENY_READ=1
|
||||
expect "exit" $? 1
|
||||
intact
|
||||
|
||||
case="a key with whitespace is refused and nothing changes"
|
||||
good
|
||||
seed "swarm:S0 VZ" "tok-new"
|
||||
run
|
||||
expect "exit" $? 1
|
||||
intact
|
||||
|
||||
case="a token with whitespace is refused and nothing changes"
|
||||
good
|
||||
seed "swarm:S0VZ" "tok new"
|
||||
run
|
||||
expect "exit" $? 1
|
||||
intact
|
||||
|
||||
case="a key without a name is refused and nothing changes"
|
||||
good
|
||||
seed "S0VZ" "tok-new"
|
||||
run
|
||||
expect "exit" $? 1
|
||||
intact
|
||||
|
||||
if [ "$failures" -ne 0 ]; then
|
||||
echo "$failures of $count checks failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "$count checks passed"
|
||||
Loading…
Reference in a new issue