matrix: mint the appservice sender token in the matrix container
A swarm runs one homeserver and a homeserver has one appservice sender account, so "mint it once" is a property of the thing being minted rather than something a lock has to enforce. That is what makes this account the one to move first: no trigger route, no controller change and no agent list — a boot-time oneshot beside tuwunel is the whole mechanism. `swarm-matrix-minter` runs inside `containers.hive-matrix`, which already holds the appservice token: the rendered registration is bound in read-only because that is how tuwunel is handed it. What the container lacked was an identity of its own, so this adds one — a leaf from the store's CA with a grant of exactly one path, not the hive's leaf, which reads every secret in the store. Both ends of the credential ship here. The minter reads the path it publishes to before it touches the homeserver, and returning on a non-empty read IS the "only once"; `hive-c0re`'s `ensure_hive_user` reads the same path, authenticating with the hive name already in `HYPERHIVE_HIVE_NAME`. The existing mint-then-`M_USER_IN_USE`-login ladder stays as the fallback for a store that is empty, unconfigured or unreachable, which is every swarm deployed before this — so nothing needs backfilling and nothing breaks if the rest of the sequence never lands. The credential is not an admin credential, and is not named like one. It is the access token of the appservice registration's own `sender_localpart` — `@hive:<server_name>`, an account the homeserver creates for itself when it loads the registration. The store path is `swarm/services/matrix/sender-token`, the host path is `matrix/access-token`, and the homeserver no longer runs an `admin_execute` promotion for that account at boot. Everything the hive provisions with it — the Space, the chat room, their hierarchy and join rules, the invites — rides on being the creator of those rooms at power level 100, not on homeserver admin; there is no Synapse admin API here to need, tuwunel has none. Two operations do need an admin *sender* and therefore stop working: `hivectl matrix promote-user` and `hivectl matrix reset-password`, both `!admin …` messages into `#admins:<server>`, plus the password-reset recovery path that an agent with a lost password file falls back to. They are swarm-level operations and are left failing loudly rather than served by an over-privileged token every other call site would also carry. The sweep's own admin-rights check and self-repair go with them: an account that is deliberately not an admin has nothing to check. `ephemeral = false` stays, and hive root can still read the container's filesystem. Accepted: what this buys is identity separation — no hive *process* holds or reads the appservice token — not physical isolation. Refs #4345
This commit is contained in:
parent
ff0da0b617
commit
f778122f5a
28 changed files with 1566 additions and 320 deletions
|
|
@ -27,6 +27,7 @@
|
|||
./glue-controller-bao-identity.nix
|
||||
./glue-grafana-oidc-client.nix
|
||||
./glue-matrix-bao-token.nix
|
||||
./glue-matrix-minter-bao-identity.nix
|
||||
./glue-queue-agent-credential.nix
|
||||
./glue-secret-publisher-bao-identity.nix
|
||||
./glue-swarm-otel-oidc-client.nix
|
||||
|
|
|
|||
|
|
@ -147,6 +147,19 @@ in
|
|||
# to be this one whenever the store has a host of its own.
|
||||
[ -s ${pkiDir}/secret-publisher.pem ] || ${signLeaf} ${pkiDir} secret-publisher \
|
||||
${lib.escapeShellArg deployCfg.bao.secretPublisherCommonName} "" clientAuth
|
||||
|
||||
# The matrix container's minter. Minted unconditionally like the two
|
||||
# above, and for the third variant of the same reason: the homeserver
|
||||
# is a swarm singleton, so on every hive but the one running it this
|
||||
# leaf is the file an operator copies rather than a file anything
|
||||
# local reads.
|
||||
#
|
||||
# Its own CN, not the reader's: the reader's leaf carries this hive's
|
||||
# name and its policy reads the whole store, while this principal may
|
||||
# only write one path — which is the entire point of giving the
|
||||
# container an identity instead of lending it the hive's.
|
||||
[ -s ${pkiDir}/matrix-minter.pem ] || ${signLeaf} ${pkiDir} matrix-minter \
|
||||
${lib.escapeShellArg deployCfg.bao.matrixMinterCommonName} "" clientAuth
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
|
|
|||
40
nix/host-modules/glue-matrix-minter-bao-identity.nix
Normal file
40
nix/host-modules/glue-matrix-minter-bao-identity.nix
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Glue: point the matrix container's minter at the bao leaf minted for it.
|
||||
#
|
||||
# ONE PAIRING PER FILE — minter ← bao, and nothing else. Deleting this leaves a
|
||||
# minter that takes operator-provided certificate paths, which is what any
|
||||
# deployment not minting its own already does.
|
||||
#
|
||||
# ⚠️ The minting is NOT here. ./glue-bao-tls.nix holds the CA and signs the
|
||||
# leaf, because the thing that owns a private key owns issuing from it. What
|
||||
# belongs here is the pairing: which paths the minter presents.
|
||||
#
|
||||
# ⚠️ Gated on the leaf existing, not on the store being enabled — the same rule
|
||||
# ./glue-secret-publisher-bao-identity.nix states, and it bites harder here: a
|
||||
# swarm runs ONE homeserver, so the hive hosting it is the one least likely to
|
||||
# also be the hive hosting the store.
|
||||
#
|
||||
# Everything is `mkDefault`. An operator naming their own paths wins.
|
||||
{
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
hyperhiveCfg = config.services.hyperhive;
|
||||
deployCfg = hyperhiveCfg.deploy;
|
||||
baoDeploy = deployCfg.bao;
|
||||
|
||||
# Where ./glue-bao-tls.nix puts the leaves, derived from the reader's own path
|
||||
# rather than repeating that file's directory literal: an operator who moves
|
||||
# the PKI moves both, and the two cannot drift apart.
|
||||
haveMintedPki = baoDeploy.clientCertFile != null;
|
||||
pkiDir = if haveMintedPki then builtins.dirOf baoDeploy.clientCertFile else null;
|
||||
in
|
||||
{
|
||||
config = lib.mkIf (hyperhiveCfg.enable && deployCfg.matrix.enable && haveMintedPki) {
|
||||
services.hyperhive.deploy.matrix = {
|
||||
minterBaoClientCertFile = lib.mkDefault "${pkiDir}/matrix-minter.pem";
|
||||
minterBaoClientKeyFile = lib.mkDefault "${pkiDir}/matrix-minter-key.pem";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -26,6 +26,11 @@ let
|
|||
# `null/.well-known/…`.
|
||||
autheliaCfg = config.services.hyperhive.swarm.authelia;
|
||||
deployCfg = config.services.hyperhive.deploy;
|
||||
|
||||
# Where the swarm's secret store answers. Swarm-tier, identical on every
|
||||
# host, which is what lets the container address it without knowing whether
|
||||
# it stands here.
|
||||
baoCfg = config.services.hyperhive.swarm.bao;
|
||||
autheliaUrl = autheliaCfg.url;
|
||||
|
||||
# The all-local case: this host runs BOTH the homeserver and the swarm's
|
||||
|
|
@ -59,18 +64,19 @@ let
|
|||
# namespace below, with no shared registration secret in the picture.
|
||||
appserviceId = "hyperhive";
|
||||
|
||||
# The appservice's own user, and deliberately the hive admin account.
|
||||
# Loading a registration CREATES its `sender_localpart` user when absent
|
||||
# (tuwunel `src/service/appservice/mod.rs`), on a zero-user database,
|
||||
# inside `Services::start()` — before the HTTP listener accepts anything.
|
||||
# That is what lets the `admin_execute` promotion below land on the very
|
||||
# first boot of a fresh homeserver, instead of depending on hive-c0re
|
||||
# racing to register the first account and win the auto-admin grant.
|
||||
# The appservice's own user, and the account the hive acts as. An
|
||||
# ordinary user, not a homeserver admin: everything the hive does with
|
||||
# it — the Space, the chat room, the invites — it does as the creator of
|
||||
# those rooms. Loading a registration CREATES its `sender_localpart` user
|
||||
# when absent (tuwunel `src/service/appservice/mod.rs`), on a zero-user
|
||||
# database, inside `Services::start()` — before the HTTP listener accepts
|
||||
# anything, so the account exists on the very first boot of a fresh
|
||||
# homeserver without hive-c0re racing to register it.
|
||||
#
|
||||
# ⚠️ Must equal `matrix::HIVE_ADMIN_LOCALPART` in hive-c0re, which derives
|
||||
# ⚠️ Must equal `matrix::HIVE_LOCALPART` in hive-c0re, which derives
|
||||
# it independently with nothing wiring an override across — same
|
||||
# agreement, and same reason for saying so, as the token path below.
|
||||
adminLocalpart = "hive";
|
||||
hiveLocalpart = "hive";
|
||||
|
||||
# The `as_token`, and the `hs_token` the spec requires alongside it. Both
|
||||
# minted by the render script below, mode 0600; the `as_token` is the one
|
||||
|
|
@ -107,7 +113,62 @@ let
|
|||
appserviceCredentialId = "${appserviceId}-appservice.yaml";
|
||||
appserviceCredentialDir = "/run/credentials/tuwunel.service";
|
||||
|
||||
# Every local user this hive may provision — agents, the hive admin, and
|
||||
# ── swarm-matrix-minter ────────────────────────────────────────────────
|
||||
#
|
||||
# The oneshot that publishes the `@hive:` account's access token to
|
||||
# the swarm's secret store. It runs INSIDE the container, beside tuwunel,
|
||||
# because the appservice token that authorises the mint is already in here —
|
||||
# `appserviceDir` below is bound read-only precisely so the homeserver can
|
||||
# load it — and minting anywhere else would create a second holder of that
|
||||
# secret, which is the thing this whole arrangement exists to stop.
|
||||
#
|
||||
# Gated on the identity, not on `deploy.bao.enable`: a swarm's ONE homeserver
|
||||
# is the host least likely to also be the host running the store, so
|
||||
# "co-located with bao" would leave the intended deployment silently minting
|
||||
# nothing. Same rule ./swarm-secret-publisher.nix's `haveClientIdentity`
|
||||
# states, for a sharper reason.
|
||||
minterActive =
|
||||
deployCfg.matrix.minterBaoClientCertFile != null && deployCfg.matrix.minterBaoClientKeyFile != null;
|
||||
|
||||
# The role on the store's `cert` auth mount, and so the single source of the
|
||||
# string both ends must agree on: ./swarm-bao.nix writes the role under
|
||||
# `matrixMinterPolicyName` and this hands it to the binary in the
|
||||
# environment, so the binary itself spells no role at all.
|
||||
minterCertRole = "swarm-matrix-minter";
|
||||
|
||||
# Every host directory the minter's bao identity is spread across. Normally
|
||||
# one — ./glue-bao-tls.nix puts all three files in ./glue's PKI dir — but
|
||||
# derived rather than assumed, because an operator naming paths by hand is
|
||||
# exactly the deployment the gate above is written for.
|
||||
#
|
||||
# Directories rather than the files, for the reason `appserviceDir`'s own
|
||||
# comment gives: a re-issued leaf is a new inode, and binding the file would
|
||||
# pin the one the container saw when it started.
|
||||
minterPkiDirs = lib.optionals minterActive (
|
||||
lib.unique (
|
||||
map builtins.dirOf (
|
||||
[
|
||||
deployCfg.matrix.minterBaoClientCertFile
|
||||
deployCfg.matrix.minterBaoClientKeyFile
|
||||
]
|
||||
++ lib.optional (deployCfg.bao.serverCaFile != null) deployCfg.bao.serverCaFile
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
minterBindMounts = lib.genAttrs minterPkiDirs (dir: {
|
||||
hostPath = dir;
|
||||
isReadOnly = true;
|
||||
});
|
||||
|
||||
# Where a reader of the published credential is told the token is good for.
|
||||
# Empty when this hive serves no vhost: `matrix::Credential.homeserver` is an
|
||||
# `Option`, and the minter reads an empty variable as absent rather than as
|
||||
# the string "null" — which is what a hive with no gateway host actually
|
||||
# knows about itself.
|
||||
minterHomeserverUrl = if cfg.gatewayHost == null then "" else "https://${toString cfg.gatewayHost}";
|
||||
|
||||
# Every local user this hive may provision — agents, `@hive:` itself, and
|
||||
# the operator accounts `hivectl matrix create-user` makes, which is the
|
||||
# whole matrix localpart charset.
|
||||
#
|
||||
|
|
@ -173,7 +234,7 @@ let
|
|||
cat <<'REGISTRATION'
|
||||
id: ${appserviceId}
|
||||
url: null
|
||||
sender_localpart: ${adminLocalpart}
|
||||
sender_localpart: ${hiveLocalpart}
|
||||
rate_limited: false
|
||||
namespaces:
|
||||
users:
|
||||
|
|
@ -686,6 +747,56 @@ in
|
|||
match authelia's register.
|
||||
'';
|
||||
};
|
||||
|
||||
minterPackage = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
defaultText = lib.literalExpression "hyperhive.packages.\${system}.swarm-matrix-minter";
|
||||
description = ''
|
||||
The `swarm-matrix-minter` build run inside the matrix container.
|
||||
|
||||
⚠️ Named `minterPackage`, not folded into `package` above: that one is
|
||||
the homeserver, and this is a hyperhive binary that happens to run
|
||||
beside it. Same split, and same reason, as
|
||||
{option}`services.hyperhive.deploy.nats.authPackage`.
|
||||
'';
|
||||
};
|
||||
|
||||
minterBaoClientCertFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = ''
|
||||
Client certificate the matrix container's minter presents to the
|
||||
swarm's secret store. Its subject must be
|
||||
{option}`services.hyperhive.deploy.bao.matrixMinterCommonName` — cert
|
||||
auth matches on the CN, and the role accepts nothing else.
|
||||
|
||||
⚠️ **Not the hive's own leaf**, and that is the whole deliverable of
|
||||
giving this container an identity: the hive's certificate reads every
|
||||
secret in the store, while this one may write a single path. Pointing
|
||||
this at `deploy.bao.clientCertFile` would evaluate, deploy and work —
|
||||
and give away the separation in one line.
|
||||
|
||||
No default: a module that guessed would be holding the CA opinion
|
||||
./swarm-bao.nix deliberately does not hold.
|
||||
./glue-matrix-minter-bao-identity.nix points it at the leaf
|
||||
./glue-bao-tls.nix mints, where this host mints one.
|
||||
|
||||
The file and its key are bind-mounted into the container read-only.
|
||||
Co-located in the hive's filesystem and therefore readable by hive
|
||||
**root** — accepted: the boundary this buys is identity (no hive
|
||||
*process* holds the appservice token), not physical isolation.
|
||||
'';
|
||||
};
|
||||
|
||||
minterBaoClientKeyFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
description = ''
|
||||
Private key for
|
||||
{option}`services.hyperhive.deploy.matrix.minterBaoClientCertFile`.
|
||||
Both or neither — the minter unit does not exist unless each is set.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf deployCfg.matrix.enable {
|
||||
|
|
@ -1082,7 +1193,11 @@ in
|
|||
isReadOnly = true;
|
||||
};
|
||||
}
|
||||
// caTrust.bindMount;
|
||||
// caTrust.bindMount
|
||||
# The minter's bao client identity, and nothing else of the hive's. See
|
||||
# `minterPkiDirs` above for why it is a derived set of directories
|
||||
# rather than one literal.
|
||||
// minterBindMounts;
|
||||
config =
|
||||
{ ... }:
|
||||
{
|
||||
|
|
@ -1192,30 +1307,21 @@ in
|
|||
# `Services::start()`, before the listener accepts anything.
|
||||
appservice_dir = appserviceCredentialDir;
|
||||
|
||||
# The zero-user bootstrap, and the only thing here that needs
|
||||
# no account to already exist. These run after startup and
|
||||
# BEFORE the HTTP listener, with no sender and no permission
|
||||
# check — which is what makes them the one lever that can
|
||||
# promote the hive admin on a homeserver where nobody is admin
|
||||
# yet. The appservice registration above creates
|
||||
# `@${adminLocalpart}` as its sender user moments earlier;
|
||||
# this joins it to the admin room at power level 100.
|
||||
# No `admin_execute` promotion for `@${hiveLocalpart}`. The
|
||||
# hive's account is an ordinary user: it creates the hive
|
||||
# Space and chat room and invites agents into them, all of
|
||||
# which ride on being the rooms' own creator at power level
|
||||
# 100, and none of which is a homeserver-admin capability.
|
||||
# Granting it server admin at boot would hand a credential
|
||||
# that every hive reads far more than the work needs.
|
||||
#
|
||||
# Idempotent by upstream's own guard: `make_user_admin`
|
||||
# short-circuits when the user is already joined at 100, so a
|
||||
# hive that has had an admin for months emits nothing.
|
||||
admin_execute = [
|
||||
"users make-user-admin @${adminLocalpart}:${effectiveServerName}"
|
||||
];
|
||||
|
||||
# ⚠️ Load-bearing, not tidiness. An `admin_execute` command
|
||||
# that fails aborts startup outright when this is false — so
|
||||
# the one boot where the promotion cannot work (a homeserver
|
||||
# that has no `@${adminLocalpart}` and no appservice user yet,
|
||||
# e.g. a registration file that arrived late) would take the
|
||||
# homeserver down with it rather than converging on the next
|
||||
# start.
|
||||
admin_execute_errors_ignore = true;
|
||||
# The two operations that do need an admin sender —
|
||||
# `!admin users make-user-admin` and
|
||||
# `!admin users reset-password`, both messages into
|
||||
# `#admins:${effectiveServerName}` — therefore have no
|
||||
# working sender here. They are swarm-level operations and
|
||||
# are being rehomed as such; until then they fail, loudly,
|
||||
# rather than being served by an over-privileged token.
|
||||
# Server-side E2EE is opt-in (default off); the agent matrix
|
||||
# client always supports decryption regardless.
|
||||
allow_encryption = cfg.allowEncryption;
|
||||
|
|
@ -1286,6 +1392,57 @@ in
|
|||
"oidc_client_secret:${toString deployCfg.matrix.sso.clientSecretFile}"
|
||||
];
|
||||
|
||||
# Publish the `@hive:` account's access token to the swarm
|
||||
# store, once, under an identity that belongs to this container and
|
||||
# not to the hive. See `minterActive` above for why it runs here.
|
||||
#
|
||||
# A `oneshot` with no timer and no retry loop of its own: the whole
|
||||
# of "and only once" is the binary's first act, a read of the path it
|
||||
# would write. `Restart=on-failure` covers a store that is sealed or
|
||||
# a homeserver still starting; `RemainAfterExit` is deliberately NOT
|
||||
# set, because the unit having succeeded is not the idempotency
|
||||
# record — the store is, and it outlives this machine.
|
||||
systemd.services.swarm-matrix-minter = lib.mkIf minterActive {
|
||||
description = "publish the @hive: matrix credential to the swarm secret store";
|
||||
# Ordered after the homeserver because both of the ladder's arms
|
||||
# are client-server API calls. `wants`, not `requires`: a run that
|
||||
# finds the credential already published never touches tuwunel at
|
||||
# all, so a homeserver that is slow to come up should delay this,
|
||||
# not cancel it.
|
||||
after = [ "tuwunel.service" ];
|
||||
wants = [ "tuwunel.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
ExecStart = "${deployCfg.matrix.minterPackage}/bin/swarm-matrix-minter";
|
||||
Restart = "on-failure";
|
||||
RestartSec = 30;
|
||||
# Bounded here rather than left to systemd's default, for the
|
||||
# reason ./swarm-secret-publisher.nix states: a sealed store
|
||||
# answers on the port and never answers the read.
|
||||
TimeoutStartSec = 60;
|
||||
SyslogIdentifier = "swarm-matrix-minter";
|
||||
};
|
||||
environment = {
|
||||
BAO_ADDR = "https://${baoCfg.domain}:${toString baoCfg.port}";
|
||||
BAO_CLIENT_CERT = deployCfg.matrix.minterBaoClientCertFile;
|
||||
BAO_CLIENT_KEY = deployCfg.matrix.minterBaoClientKeyFile;
|
||||
MATRIX_MINTER_CERT_ROLE = minterCertRole;
|
||||
# Loopback: this container shares the host netns, so the
|
||||
# homeserver it must talk to is the one in this very unit's
|
||||
# netns and needs no name, no vhost and no TLS.
|
||||
MATRIX_MINTER_API_URL = "http://127.0.0.1:${toString cfg.httpPort}";
|
||||
# The bind-mounted registration, which IS the as_token. A path,
|
||||
# never a value.
|
||||
MATRIX_MINTER_REGISTRATION = appserviceRegistrationPath;
|
||||
MATRIX_MINTER_LOCALPART = hiveLocalpart;
|
||||
MATRIX_MINTER_HOMESERVER = minterHomeserverUrl;
|
||||
}
|
||||
// lib.optionalAttrs (deployCfg.bao.serverCaFile != null) {
|
||||
BAO_CACERT = deployCfg.bao.serverCaFile;
|
||||
};
|
||||
};
|
||||
|
||||
environment.systemPackages = [ deployCfg.matrix.package ];
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -236,6 +236,35 @@ let
|
|||
}
|
||||
'';
|
||||
|
||||
# The identity the matrix container's minter presents. Named outside `hive-*`
|
||||
# for the reason its two siblings above give — the controller may rewrite
|
||||
# every policy under that prefix, and a policy it can rewrite constrains
|
||||
# nothing.
|
||||
matrixMinterPolicyName = "swarm-matrix-minter";
|
||||
matrixMinterCn = baoDeploy.matrixMinterCommonName;
|
||||
|
||||
# ONE path, and every narrowing in it is load-bearing.
|
||||
#
|
||||
# `secret/data/` is KV v2's ACL prefix, inserted by the engine rather than
|
||||
# written by the caller — the same trap as the two grants above.
|
||||
#
|
||||
# Not `swarm/services/*` like the publisher's: this principal produces
|
||||
# exactly one secret, the `@hive:` account's access token, and a
|
||||
# homeserver is not entitled to overwrite Grafana's OIDC client. The path is
|
||||
# spelled to the leaf for that reason, not for tidiness.
|
||||
#
|
||||
# `read` as well as write, unlike either sibling, and it is what makes "and
|
||||
# only once" mechanical: the minter's first act is to read this path back and
|
||||
# stop if something is there, so without the capability every container
|
||||
# restart would mint a second access token and invalidate the hive's. A read
|
||||
# here recovers one secret this principal itself wrote, which is a much
|
||||
# narrower grant than the publisher's would have been.
|
||||
matrixMinterPolicyText = ''
|
||||
path "${credentialMountPath}/data/swarm/services/matrix/hive-access-token" {
|
||||
capabilities = ["create", "update", "read"]
|
||||
}
|
||||
'';
|
||||
|
||||
# The KV v2 engine the controller writes agent credentials through. Named
|
||||
# once because the grant above and the `secrets enable` in the bootstrap unit
|
||||
# have to agree: a policy pointing at a mount nobody created is precisely the
|
||||
|
|
@ -659,6 +688,29 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
matrixMinterCommonName = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "swarm-matrix-minter";
|
||||
example = "swarm-matrix-minter.svc";
|
||||
description = ''
|
||||
Subject the store's matrix-minter cert-auth role accepts — the
|
||||
identity the oneshot inside the matrix container presents when it
|
||||
publishes the `@hive:` account's access token.
|
||||
|
||||
A **third** identity rather than reuse of either sibling above, and
|
||||
the narrowest of the three: its grant is one path, that
|
||||
credential itself. The point of the whole arrangement is that the
|
||||
process holding the appservice token is not the hive, so handing it
|
||||
the hive's own leaf — which reads every secret in the store — would
|
||||
give the separation away in one line.
|
||||
|
||||
⚠️ Same collision as its siblings, and the same answer: ./swarm.nix
|
||||
feeds this value into the guard on
|
||||
{option}`services.hyperhive.swarm.hives`, so a hive named after it
|
||||
fails evaluation rather than silently receiving the minter's grant.
|
||||
'';
|
||||
};
|
||||
|
||||
serverCaFile = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = null;
|
||||
|
|
@ -1222,6 +1274,54 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
# A THIRD sibling, for the reason the second one's comment gives: these
|
||||
# unit names are operator-facing strings, and the grant written here
|
||||
# belongs to a principal neither of the other two names. Same `after`
|
||||
# rather than `requires`, and for the same stated reason — the first unit
|
||||
# creates the mounts this one writes into, but a failed oneshot still
|
||||
# counts as finished, so only ordering plus this unit's own retry
|
||||
# converges.
|
||||
systemd.services.swarm-bao-matrix-minter-policy = lib.mkIf haveBootstrapToken {
|
||||
description = "write the swarm matrix minter's bao policy and cert-auth role";
|
||||
after = [
|
||||
"container@${cfg.machine}.service"
|
||||
"swarm-bao-controller-policy.service"
|
||||
];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
path = [
|
||||
baoCli
|
||||
pkgs.coreutils
|
||||
];
|
||||
unitConfig.ConditionPathExists = baoDeploy.bootstrapTokenFile;
|
||||
# Same unseal wait as its two siblings above, for the reason stated
|
||||
# there: under `seal = "shamir"` a human unseals by hand.
|
||||
startLimitBurst = 2880;
|
||||
startLimitIntervalSec = 90000;
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
RemainAfterExit = true;
|
||||
Restart = "on-failure";
|
||||
RestartSec = 30;
|
||||
};
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
|
||||
BAO_TOKEN="$(cat ${lib.escapeShellArg baoDeploy.bootstrapTokenFile})"
|
||||
export BAO_TOKEN
|
||||
|
||||
printf '%s' ${lib.escapeShellArg matrixMinterPolicyText} |
|
||||
bao policy write ${lib.escapeShellArg matrixMinterPolicyName} -
|
||||
''
|
||||
+ lib.optionalString (baoDeploy.clientCaFile != null) ''
|
||||
|
||||
bao write auth/cert/certs/${lib.escapeShellArg matrixMinterPolicyName} \
|
||||
certificate=@${tlsDir}/client-ca.pem \
|
||||
allowed_common_names=${lib.escapeShellArg matrixMinterCn} \
|
||||
token_policies=${lib.escapeShellArg matrixMinterPolicyName} \
|
||||
display_name=${lib.escapeShellArg matrixMinterCn}
|
||||
'';
|
||||
};
|
||||
|
||||
containers.${cfg.machine} = {
|
||||
autoStart = true;
|
||||
ephemeral = false;
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ let
|
|||
certAuthCns = [
|
||||
deployCfg.bao.controllerCommonName
|
||||
deployCfg.bao.secretPublisherCommonName
|
||||
deployCfg.bao.matrixMinterCommonName
|
||||
];
|
||||
|
||||
# Public hostnames of the swarm's own services, in declaration order.
|
||||
|
|
|
|||
|
|
@ -140,6 +140,56 @@ let
|
|||
baoGrantHere.systemd.services.swarm-bao-secret-publisher-policy.after
|
||||
);
|
||||
}
|
||||
{
|
||||
# The third principal's grant, and the narrowest of the three: ONE path,
|
||||
# spelled to the leaf. The negative arms are the property — a homeserver
|
||||
# is not entitled to overwrite Grafana's OIDC client, so widening this to
|
||||
# the `services/` prefix the publisher holds would be a real loss even
|
||||
# though it would read as tidier.
|
||||
#
|
||||
# ⚠️ `services` is PLURAL, because the path segment comes from
|
||||
# `Kind::Service`'s `#[strum(serialize = "services")]` and not from
|
||||
# `Kind::label`, which renders the singular for error text. The singular
|
||||
# spelling evaluates, deploys, and 403s every read with "permission
|
||||
# denied" and nothing else.
|
||||
name = "the matrix minter's grant is the hive credential's path and nothing else";
|
||||
ok =
|
||||
let
|
||||
s = baoGrantHere.systemd.services.swarm-bao-matrix-minter-policy.script;
|
||||
in
|
||||
lib.hasInfix "path \"secret/data/swarm/services/matrix/hive-access-token\" {" s
|
||||
&& !(lib.hasInfix "secret/data/swarm/services/*" s)
|
||||
&& !(lib.hasInfix "secret/data/swarm/agents" s)
|
||||
&& !(lib.hasInfix "secret/data/swarm/hives" s)
|
||||
&& !(lib.hasInfix "sys/policies/acl" s);
|
||||
}
|
||||
{
|
||||
# 🩸 `read` is load-bearing here and is the one capability neither
|
||||
# sibling has. The minter's first act is to read this path back and stop
|
||||
# if something is there — that read IS "and only once", so without the
|
||||
# capability every container restart would mint a second access token and
|
||||
# invalidate the hive's.
|
||||
name = "the matrix minter may read back the one path it writes";
|
||||
ok =
|
||||
let
|
||||
s = baoGrantHere.systemd.services.swarm-bao-matrix-minter-policy.script;
|
||||
in
|
||||
lib.hasInfix "capabilities = [\"create\", \"update\", \"read\"]" s
|
||||
&& lib.hasInfix "auth/cert/certs/swarm-matrix-minter" s
|
||||
&& lib.hasInfix "allowed_common_names=swarm-matrix-minter" s;
|
||||
}
|
||||
{
|
||||
# Same two controls its siblings carry: ordered after the unit that makes
|
||||
# the mounts it writes into, and rendered on the HOST rather than inside
|
||||
# the store's container, where it would have neither an identity nor a
|
||||
# route to the store.
|
||||
name = "the minter's granting unit is ordered after the mounts and rendered on the host";
|
||||
ok =
|
||||
lib.elem "swarm-bao-controller-policy.service" (
|
||||
baoGrantHere.systemd.services.swarm-bao-matrix-minter-policy.after
|
||||
)
|
||||
&& !(baoGrantHere.containers.swarm-bao.config.systemd.services ? swarm-bao-matrix-minter-policy);
|
||||
}
|
||||
{
|
||||
# The policy authorising this route lives in another file, and nothing
|
||||
# else relates the grants to the paths the code actually writes.
|
||||
|
|
|
|||
|
|
@ -37,6 +37,12 @@ let
|
|||
deploy.bao.clientCertFile = "/etc/pki/bao-client.pem";
|
||||
deploy.bao.clientKeyFile = "/etc/pki/bao-client-key.pem";
|
||||
};
|
||||
|
||||
# A homeserver on a hive with NO store identity at all — neither a local
|
||||
# store nor a hand-placed leaf. The absence arm for the minter cases below
|
||||
# needs it, and defining it here rather than importing keeps each group's
|
||||
# fixture set its own, as ./lib.nix asks.
|
||||
matrixNoBaoIdentity = hive { deploy.matrix.enable = true; };
|
||||
cases = [
|
||||
{
|
||||
# A login failure is the store being unreachable, sealed, or not yet
|
||||
|
|
@ -219,6 +225,110 @@ let
|
|||
&& (s.hive-c0re.environment.BAO_CACERT or null) == "%d/bao-ca.pem"
|
||||
&& lib.any (c: lib.hasPrefix "bao-ca.pem:" c) s.hive-c0re.serviceConfig.LoadCredential;
|
||||
}
|
||||
{
|
||||
# The same hole a third time, and the leaf whose absence is hardest to
|
||||
# see from outside: it is consumed by a unit INSIDE a container, so a
|
||||
# missing pairing renders as a container that comes up fine and publishes
|
||||
# nothing.
|
||||
name = "the store mints a leaf for the matrix minter, and the container is pointed at it";
|
||||
ok =
|
||||
let
|
||||
m = baoWithMatrix;
|
||||
p = m.services.hyperhive.deploy.matrix;
|
||||
in
|
||||
lib.hasInfix "matrix-minter.pem" m.systemd.services.swarm-bao-pki.script
|
||||
&& p.minterBaoClientCertFile == "/var/lib/swarm-bao-pki/matrix-minter.pem"
|
||||
&& p.minterBaoClientKeyFile == "/var/lib/swarm-bao-pki/matrix-minter-key.pem";
|
||||
}
|
||||
{
|
||||
# 🩸 The identity separation this whole arrangement buys, stated as the
|
||||
# one thing that would silently undo it. The container gets the MINTER's
|
||||
# leaf — whose grant is a single path — and not the hive's, which reads
|
||||
# every secret in the store. Both files exist in the same directory and
|
||||
# both would evaluate, deploy and work.
|
||||
name = "the matrix minter presents its own leaf, never the hive's store-wide one";
|
||||
ok =
|
||||
let
|
||||
env = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-minter.environment;
|
||||
hiveLeaf = baoWithMatrix.services.hyperhive.deploy.bao.clientCertFile;
|
||||
in
|
||||
env.BAO_CLIENT_CERT == "/var/lib/swarm-bao-pki/matrix-minter.pem"
|
||||
&& env.BAO_CLIENT_CERT != hiveLeaf;
|
||||
}
|
||||
{
|
||||
# The bind mount is what makes the environment above resolvable: without
|
||||
# it the unit names two paths the container does not have, and fails at
|
||||
# the TLS handshake naming no cause. Read off the mount table rather than
|
||||
# the option, so a pairing that stops reaching `bindMounts` still fails.
|
||||
#
|
||||
# The second arm is the shape guard: `bindMounts` is one literal plus two
|
||||
# merges, and a rewrite that dropped the appservice registration would
|
||||
# take the homeserver's own credential with it.
|
||||
name = "the matrix container binds the minter's PKI read-only, without losing the appservice registration";
|
||||
ok =
|
||||
let
|
||||
mounts = baoWithMatrix.containers.hive-matrix.bindMounts;
|
||||
in
|
||||
mounts ? "/var/lib/swarm-bao-pki"
|
||||
&& mounts."/var/lib/swarm-bao-pki".isReadOnly
|
||||
&& mounts ? "/var/lib/hyperhive/matrix-appservice";
|
||||
}
|
||||
{
|
||||
# What the unit is for, read as the two agreements it cannot get wrong:
|
||||
# the cert role ./host-modules/swarm-bao.nix writes, and a homeserver
|
||||
# address that is loopback because the container shares the host netns. A
|
||||
# vhost here would be a request out through the gateway and back.
|
||||
name = "the matrix minter is handed the store role and the loopback homeserver";
|
||||
ok =
|
||||
let
|
||||
m = baoWithMatrix;
|
||||
u = m.containers.hive-matrix.config.systemd.services.swarm-matrix-minter;
|
||||
port = m.services.hyperhive.swarm.matrix.httpPort;
|
||||
in
|
||||
u.environment.MATRIX_MINTER_CERT_ROLE == "swarm-matrix-minter"
|
||||
&& u.environment.MATRIX_MINTER_API_URL == "http://127.0.0.1:${toString port}"
|
||||
&& u.environment.MATRIX_MINTER_REGISTRATION == "/var/lib/hyperhive/matrix-appservice/hyperhive.yaml"
|
||||
&& u.serviceConfig.Type == "oneshot";
|
||||
}
|
||||
{
|
||||
# 🩸 A secret is a path, never a value — checked on the one unit in this
|
||||
# tree whose whole job is an `as_token`. Every variable it is given names
|
||||
# a file or an address; the token itself is read out of the bind-mounted
|
||||
# registration at runtime, so nothing here can be a token and an
|
||||
# environment block is world-readable through `systemctl show`.
|
||||
name = "the matrix minter's environment carries paths and addresses, never a token";
|
||||
ok =
|
||||
let
|
||||
env = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-minter.environment;
|
||||
in
|
||||
!(lib.any (v: lib.hasInfix "as_token" v || lib.hasInfix "syt_" v) (lib.attrValues env));
|
||||
}
|
||||
{
|
||||
# The absence arm, and the deployment it protects: a homeserver on a hive
|
||||
# with no store identity at all. Without it the unit would exist naming
|
||||
# `null` as its certificate, which nixos renders as the literal string.
|
||||
name = "a matrix container with no store identity runs no minter and binds no PKI";
|
||||
ok =
|
||||
let
|
||||
units = matrixNoBaoIdentity.containers.hive-matrix.config.systemd.services;
|
||||
in
|
||||
!(units ? swarm-matrix-minter)
|
||||
&& !(matrixNoBaoIdentity.containers.hive-matrix.bindMounts ? "/var/lib/swarm-bao-pki");
|
||||
}
|
||||
{
|
||||
# 🩸 The privilege arm of the credential this slice publishes: the
|
||||
# account it belongs to must not be a homeserver admin. Read on the
|
||||
# rendered homeserver settings rather than on an option, because the
|
||||
# grant was never an option — it was a boot command in `admin_execute`,
|
||||
# and a command list is exactly the shape a later edit re-adds without
|
||||
# anything noticing.
|
||||
name = "the homeserver promotes no account to admin at boot";
|
||||
ok =
|
||||
let
|
||||
g = baoWithMatrix.containers.hive-matrix.config.services.matrix-tuwunel.settings.global;
|
||||
in
|
||||
!(g ? admin_execute) || g.admin_execute == [ ];
|
||||
}
|
||||
];
|
||||
in
|
||||
runGroup "bao-matrix-reader" cases
|
||||
|
|
|
|||
|
|
@ -52,6 +52,17 @@ let
|
|||
swarm.hives.pubctl.domain = "p.t.local";
|
||||
};
|
||||
|
||||
# The THIRD element of the same list, colliding on its own so neither of the
|
||||
# two above can carry it. The minter's grant is one path rather than a whole
|
||||
# prefix, which is exactly why a dead entry here would be easy to miss: a
|
||||
# hive that inherited it would not obviously break anything, it would
|
||||
# silently gain the ability to overwrite the swarm's matrix credential.
|
||||
hiveNamedAfterMinterSubject = hive {
|
||||
deploy.swarm-otel.enable = false;
|
||||
deploy.bao.matrixMinterCommonName = "mintctl";
|
||||
swarm.hives.mintctl.domain = "m.t.local";
|
||||
};
|
||||
|
||||
hiveNameWithComposedWord = hive {
|
||||
deploy.swarm-otel.enable = false;
|
||||
swarm.hives."h1-agent".domain = "a.t.local";
|
||||
|
|
@ -88,6 +99,18 @@ let
|
|||
a: !a.assertion && lib.hasInfix "'pubctl'" a.message
|
||||
) hiveNamedAfterPublisherSubject.assertions;
|
||||
}
|
||||
{
|
||||
# And the third, for the reason the second one's comment gives one list
|
||||
# element earlier. `certAuthCns` is where a role added beside the others
|
||||
# has to register itself, and nothing but a case per element notices when
|
||||
# one forgets.
|
||||
name = "a hive named after the matrix minter's subject is refused too";
|
||||
ok =
|
||||
equalityGuardFired hiveNamedAfterMinterSubject
|
||||
&& lib.any (
|
||||
a: !a.assertion && lib.hasInfix "'mintctl'" a.message
|
||||
) hiveNamedAfterMinterSubject.assertions;
|
||||
}
|
||||
{
|
||||
# Without this the case above proves nothing: an arm that fires for every
|
||||
# roster is not a guard, and `hives` is non-empty in both fixtures.
|
||||
|
|
|
|||
|
|
@ -163,6 +163,13 @@ in
|
|||
# rather than every hive's.
|
||||
swarm-nats-auth = mkBinPackage "swarm-nats-auth" "hyperhive swarm queue auth-callout responder";
|
||||
|
||||
# The matrix admin credential's minter. Out of `daemonBins` for the same
|
||||
# "runs *inside* a container, not on the host" reason as the responder
|
||||
# above, and with a second one: putting it in the core bundle would place
|
||||
# the binary that reads the appservice token on every hive's filesystem,
|
||||
# which is the arrangement it exists to end.
|
||||
swarm-matrix-minter = mkBinPackage "swarm-matrix-minter" "hyperhive matrix admin-credential minter";
|
||||
|
||||
# The only process allowed to write swarm-authelia's users database —
|
||||
# same "runs *inside* a container, not on the host" placement as
|
||||
# `swarm-nats-auth` above (this one lives in `swarm-authelia`'s
|
||||
|
|
|
|||
Loading…
Reference in a new issue