Watch
0
0
Fork
You've already forked hyperhive
0

keep the matrix appservice registration in bao, with the host file as the last copy

This commit is contained in:
atlas 2026-10-27 13:52:17 +01:00
commit d5c5862654
4 changed files with 1014 additions and 273 deletions

View file

@ -137,11 +137,12 @@ let
#
# The homeserver's one appservice registration, and the swarm's identity on
# it: `swarm-controller` creates every agent's account and every hive's
# `@hive-<hive>` sender with its token. Minted INSIDE this container by
# `swarm-matrix-ctl appservice render` and published to the store by
# `appservice publish`, which is why it is gated on matrix-ctl's store
# identity: with nobody to publish it, a registration here would be an
# admin credential nobody reads.
# `@hive-<hive>` sender with its token. The store holds its tokens;
# `swarm-matrix-ctl appservice sync` keeps this container's copy in step with
# them, which is why it is gated on matrix-ctl's store identity: with nobody
# to sync it, a registration here would be an admin credential nobody reads.
# `appservice render` minted a copy here only for a host that has none, and
# renders the one the homeserver starts from.
#
# Its sender is promoted to homeserver admin at boot (`admin_execute`
# below), so its token goes only to a store path no hive's policy reaches.
@ -1002,9 +1003,11 @@ in
# below is `requiredBy` it and local only.
++ lib.optional ctlActive "${swarmAppserviceCredentialId}:${swarmAppserviceDir}/swarm.yaml";
# The swarm registration, minted and rendered before the homeserver
# loads it. No network and no store: this is on tuwunel's start
# path, and a store outage must not keep the homeserver down.
# The swarm registration, rendered before the homeserver loads it from
# the tokens in the state dir: the last copy of the store's that
# `swarm-matrix-appservice-sync` took, or a minted pair on a host with
# none. No network and no store: this is on tuwunel's start path, and
# a store outage must not keep the homeserver down.
systemd.services.swarm-matrix-appservice-render = lib.mkIf ctlActive {
description = "render the swarm's appservice registration";
before = [ "tuwunel.service" ];
@ -1027,21 +1030,31 @@ in
};
};
# Hand the swarm registration's token to swarm-controller, through
# the store, under matrix-ctl's identity; the write lands at a path
# only matrix-ctl and the controller are granted (./swarm-bao.nix).
# The store holds the swarm registration; this keeps the state dir
# the render above reads in step with it, and publishes the
# `as_token` to the path `swarm-controller` reads, under matrix-ctl's
# identity (the grants are in ./swarm-bao.nix). A store without the
# registration is seeded from the state dir, so nothing rotates.
#
# ⚠️ Not `before`/`requiredBy` tuwunel, and nothing orders tuwunel
# after it: the homeserver starts from the state dir's last copy
# whether or not the store answers. When the registration is newer than
# tuwunel's last start, this restarts tuwunel onto it.
#
# `Restart=on-failure` covers a sealed store or one still starting;
# the start timeout is bounded for the reason
# the timer below repeats the run, which a unit without `RemainAfterExit` allows.
# The start timeout is bounded for the reason
# ./swarm-secret-publisher.nix states: a sealed store answers on the
# port and never answers the read.
systemd.services.swarm-matrix-appservice-publish = lib.mkIf ctlActive {
description = "publish the swarm's appservice token to the swarm secret store";
systemd.services.swarm-matrix-appservice-sync = lib.mkIf ctlActive {
description = "keep the swarm's appservice registration in step with the swarm secret store";
after = [ "swarm-matrix-appservice-render.service" ];
requires = [ "swarm-matrix-appservice-render.service" ];
wantedBy = [ "multi-user.target" ];
path = [ pkgs.systemd ];
serviceConfig = {
Type = "oneshot";
ExecStart = "${deployCfg.matrix.ctlPackage}/bin/swarm-matrix-ctl appservice publish";
ExecStart = "${deployCfg.matrix.ctlPackage}/bin/swarm-matrix-ctl appservice sync";
Restart = "on-failure";
RestartSec = 30;
TimeoutStartSec = 60;
@ -1053,12 +1066,19 @@ in
BAO_CLIENT_KEY = deployCfg.matrix.ctlBaoClientKeyFile;
MATRIX_APPSERVICE_CERT_ROLE = ctlCertRole;
MATRIX_APPSERVICE_DIR = swarmAppserviceDir;
MATRIX_APPSERVICE_SENDER = swarmSenderLocalpart;
MATRIX_APPSERVICE_USER_REGEX = appserviceUserRegex;
MATRIX_APPSERVICE_RESTART_UNIT = "tuwunel.service";
}
// lib.optionalAttrs (deployCfg.bao.serverCaFile != null) {
BAO_CACERT = deployCfg.bao.serverCaFile;
};
};
systemd.timers.swarm-matrix-appservice-sync = lib.mkIf ctlActive (
storeRefreshTimer "re-read the swarm's appservice registration from the swarm secret store"
);
environment.systemPackages = [ deployCfg.matrix.package ];
};
};

View file

@ -618,7 +618,7 @@ let
matrixCtlPolicyName = "swarm-matrix-ctl";
matrixCtlCn = baoDeploy.matrixCtlCommonName;
# Three paths, each spelled to the leaf, and every narrowing in them is
# Four paths, each spelled to the leaf, and every narrowing in them is
# load-bearing.
#
# `secret/data/` is KV v2's ACL prefix, inserted by the engine rather than
@ -628,18 +628,27 @@ let
# to overwrite Grafana's OIDC client. The paths are spelled to the leaf for
# that reason, not for tidiness.
#
# The first is the swarm appservice's token, which matrix-ctl mints inside
# the container and publishes here for the controller. `read` as well as
# write, unlike either sibling, because publish compares before it writes.
# The first is the swarm appservice's token, which matrix-ctl derives from
# the registration and publishes here for the controller. `read` as well as
# write, unlike either sibling, because sync compares before it writes.
#
# The second is the homeserver's own OIDC client secret, read-only:
# The second is the registration's tokens: the store's copy, which matrix-ctl
# seeds from the container's state dir and then follows. `create` and `read`
# only, never `update`: a registration that exists is replaced by an
# operator, not by a unit.
#
# The third is the homeserver's own OIDC client secret, read-only:
# `swarm-bao-matrix-oidc` on the homeserver's host reads it under this leaf.
# The third, also read-only, is the client secret of the journal forwarder in
# The fourth, also read-only, is the client secret of the journal forwarder in
# the homeserver's container.
matrixCtlPolicyText = ''
path "${credentialMountPath}/data/${swarmAppserviceTokenLeaf}" {
capabilities = ["create", "update", "read"]
}
path "${credentialMountPath}/data/${swarmAppserviceRegistrationLeaf}" {
capabilities = ["create", "read"]
}
''
+ readStanza "${credentialMountPath}/data/swarm/services/${hyperhiveCfg.swarm.matrix.sso.clientId}/oidc/client"
+ readStanza "${credentialMountPath}/data/swarm/services/${hyperhiveCfg.swarm.matrix.otel.clientId}/oidc/client";
@ -650,6 +659,11 @@ let
# homeserver's admin.
swarmAppserviceTokenLeaf = "swarm/controller/swarm-controller/matrix/appservice-token";
# The swarm appservice registration's leaf, the nix half of
# `swarm_secret_client::matrix::swarm_appservice_registration_path`. Only
# matrix-ctl reaches it; the controller reads the token above, not this.
swarmAppserviceRegistrationLeaf = "swarm/controller/swarm-controller/matrix/appservice-registration";
# The controller's own OIDC client secret, the nix half of
# `swarm_secret_client::queue::controller_client_path`. The publisher writes
# it; the controller reads it and holds no other copy.
@ -1902,12 +1916,13 @@ in
example = "swarm-matrix-ctl.svc";
description = ''
Subject the store's matrix-ctl cert-auth role accepts — the
identity the matrix container's `swarm-matrix-appservice-publish`
unit presents when it publishes the swarm appservice's token.
identity the matrix container's `swarm-matrix-appservice-sync`
unit presents when it syncs the swarm appservice's registration and
publishes its token.
A **third** identity rather than reuse of either sibling above. Its
grant is that credential itself, plus read on two OIDC client
secrets: the homeserver's, fetched by `swarm-bao-matrix-oidc`, and
grant is that credential and the registration it derives from, plus
read on two OIDC client secrets: the homeserver's, fetched by `swarm-bao-matrix-oidc`, and
that of the journal forwarder in the homeserver's container. The
point of the whole arrangement is that the
process holding the appservice token is not the hive, so handing it

View file

@ -575,12 +575,13 @@ let
# prefix the publisher holds would be a real loss even though it would
# read as tidier.
#
# The first leaf is the swarm appservice token, which matrix-ctl mints
# and publishes for the controller; the second and third are the
# homeserver's own OIDC client secret and its container forwarder's,
# read-only. The whole list, so a fourth stanza fails rather than riding
# The first leaf is the swarm appservice token, which matrix-ctl derives
# and publishes for the controller; the second is the registration it
# derives that from, `create` and `read` only; the third and fourth are
# the homeserver's own OIDC client secret and its container forwarder's,
# read-only. The whole list, so a fifth stanza fails rather than riding
# along beside correct ones.
name = "matrix-ctl's grant is the swarm appservice token and the homeserver's and its forwarder's OIDC secrets, nothing else";
name = "matrix-ctl's grant is the swarm appservice token and registration and the homeserver's and its forwarder's OIDC secrets, nothing else";
ok =
let
s = baoGrantHere.systemd.services.swarm-bao-matrix-ctl-policy.script;
@ -594,6 +595,13 @@ let
"read"
];
}
{
path = "secret/data/swarm/controller/swarm-controller/matrix/appservice-registration";
caps = [
"create"
"read"
];
}
{
path = "secret/data/swarm/services/tuwunel/oidc/client";
caps = [ "read" ];
@ -611,8 +619,9 @@ let
}
{
# 🩸 `read` is load-bearing here, unlike on the publisher: matrix-ctl's
# `appservice publish` reads the swarm appservice token back and writes
# only when the store's copy differs.
# `appservice sync` reads the swarm appservice token back and writes
# only when the store's copy differs, and reads the registration before
# it creates it.
name = "matrix-ctl may read back what it writes";
ok =
let
@ -2238,7 +2247,9 @@ let
# it: matrix-ctl is its one writer. Pinned as the whole stanza, so an
# added capability fails.
name = "the controller reads the swarm appservice token and cannot write it";
ok = lib.hasInfix "path \"secret/data/swarm/controller/swarm-controller/matrix/appservice-token\" {\n capabilities = [\"read\"]\n}" baoGrantHere.systemd.services.swarm-bao-controller-policy.script;
ok =
lib.hasInfix "path \"secret/data/swarm/controller/swarm-controller/matrix/appservice-token\" {\n capabilities = [\"read\"]\n}" baoGrantHere.systemd.services.swarm-bao-controller-policy.script
&& !(lib.hasInfix "appservice-registration" baoGrantHere.systemd.services.swarm-bao-controller-policy.script);
}
{
# The controller's own OIDC client secret, which the publisher writes and

View file

@ -171,7 +171,7 @@ let
ok =
let
env =
baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-appservice-publish.environment;
baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-appservice-sync.environment;
hiveLeaf = baoWithMatrix.services.hyperhive.deploy.bao.clientCertFile;
in
env.BAO_CLIENT_CERT == "/var/lib/swarm-bao-pki/matrix-ctl.pem" && env.BAO_CLIENT_CERT != hiveLeaf;
@ -193,24 +193,26 @@ let
# agreement the unit cannot get wrong, and the verb: a bare invocation
# exits non-zero with clap's usage, a deploy-time failure with no local
# signal.
name = "the publish unit is handed the store role and invokes its verb";
name = "the sync unit is handed the store role, the sender and the homeserver unit, and invokes its verb";
ok =
let
u = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-appservice-publish;
u = baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-appservice-sync;
in
u.environment.MATRIX_APPSERVICE_CERT_ROLE == "swarm-matrix-ctl"
&& lib.hasSuffix "/bin/swarm-matrix-ctl appservice publish" u.serviceConfig.ExecStart;
&& u.environment.MATRIX_APPSERVICE_RESTART_UNIT == "tuwunel.service"
&& u.environment.MATRIX_APPSERVICE_SENDER == "swarm"
&& lib.hasSuffix "/bin/swarm-matrix-ctl appservice sync" u.serviceConfig.ExecStart;
}
{
# 🩸 A secret is a path, never a value. Every variable the unit is given
# names a file or an address; the token itself is read out of the state
# dir at runtime, so nothing here can be a token and an environment block
# is world-readable through `systemctl show`.
name = "the publish unit's environment carries paths and addresses, never a token";
name = "the sync unit's environment carries paths and addresses, never a token";
ok =
let
env =
baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-appservice-publish.environment;
baoWithMatrix.containers.hive-matrix.config.systemd.services.swarm-matrix-appservice-sync.environment;
in
!(lib.any (v: lib.hasInfix "as_token" v || lib.hasInfix "syt_" v) (lib.attrValues env));
}
@ -272,6 +274,52 @@ let
&& lib.hasSuffix "swarm-matrix-ctl appservice render" r.serviceConfig.ExecStart
&& !(r.environment ? BAO_ADDR);
}
{
# The start path stays free of the store. The render is what tuwunel
# waits for and it carries no store address or identity; the sync unit
# that does is neither before tuwunel nor required by it, and tuwunel
# names it nowhere, so a store outage leaves the homeserver starting on
# the state dir's last copy. The presence arms are the render's own
# ordering (the same lookup finds `before`/`requiredBy` where they are)
# and the sync's order after the render.
name = "the sync unit is off tuwunel's start path and the render holds no store identity";
ok =
let
c = baoWithMatrix.containers.hive-matrix.config.systemd;
r = c.services.swarm-matrix-appservice-render;
s = c.services.swarm-matrix-appservice-sync;
t = c.services.tuwunel;
names =
u:
lib.concatLists [
(u.after or [ ])
(u.requires or [ ])
(u.wants or [ ])
(u.bindsTo or [ ])
];
sync = "swarm-matrix-appservice-sync.service";
in
lib.elem "tuwunel.service" r.before
&& lib.elem "swarm-matrix-appservice-render.service" s.after
&& !(lib.elem "tuwunel.service" (s.before or [ ]))
&& !(lib.elem "tuwunel.service" (s.requiredBy or [ ]))
&& !(lib.elem "tuwunel.service" (s.wantedBy or [ ]))
&& !(lib.elem sync (names t))
&& !(lib.elem sync (r.before or [ ]))
&& !(lib.any (lib.hasPrefix "BAO_") (lib.attrNames r.environment));
}
{
# A timer repeats the sync, which needs a unit that does not stay
# `active` after a run.
name = "the sync unit is repeated by a timer and does not stay active";
ok =
let
c = baoWithMatrix.containers.hive-matrix.config.systemd;
in
c.timers.swarm-matrix-appservice-sync.timerConfig.OnUnitInactiveSec == "10min"
&& !(c.services.swarm-matrix-appservice-sync.serviceConfig.RemainAfterExit or false)
&& (c.services.swarm-matrix-appservice-render.serviceConfig.RemainAfterExit or false);
}
{
# The absence arm for the four above: with no matrix-ctl identity there
# is nobody to publish the swarm token, so no admin, no registration and
@ -284,7 +332,8 @@ let
in
!(g ? admin_execute)
&& !(c.systemd.services ? swarm-matrix-appservice-render)
&& !(c.systemd.services ? swarm-matrix-appservice-publish)
&& !(c.systemd.services ? swarm-matrix-appservice-sync)
&& !(c.systemd.timers ? swarm-matrix-appservice-sync)
&& !(lib.any (lib.hasPrefix "swarm-appservice.yaml") c.systemd.services.tuwunel.serviceConfig.LoadCredential);
}
];