keep the matrix appservice registration in bao, with the host file as the last copy
This commit is contained in:
parent
fb6cd36b5f
commit
d5c5862654
12 changed files with 1014 additions and 273 deletions
|
|
@ -129,10 +129,11 @@ creation doesn't depend on registration being open to anyone who learns
|
||||||
a token.
|
a token.
|
||||||
|
|
||||||
1. **`swarm-matrix-ctl appservice render`**, inside the `hive-matrix`
|
1. **`swarm-matrix-ctl appservice render`**, inside the `hive-matrix`
|
||||||
container and before tuwunel starts, mints the registration's
|
container and before tuwunel starts, renders the registration to
|
||||||
`as_token` and its spec-required `hs_token` when they're missing, and
|
`/var/lib/swarm-matrix-appservice/swarm.yaml` (mode `0600 root:root`)
|
||||||
renders the registration to
|
from the `as_token` and spec-required `hs_token` beside it. It mints
|
||||||
`/var/lib/swarm-matrix-appservice/swarm.yaml` (mode `0600 root:root`).
|
them only on a container that has none, and never reaches the store, so
|
||||||
|
a store outage can't keep the homeserver down.
|
||||||
2. **systemd `LoadCredential=`** copies the registration into
|
2. **systemd `LoadCredential=`** copies the registration into
|
||||||
`/run/credentials/tuwunel.service/swarm-appservice.yaml`, owned by
|
`/run/credentials/tuwunel.service/swarm-appservice.yaml`, owned by
|
||||||
tuwunel's dynamic user with mode `0400`, at service start. The source
|
tuwunel's dynamic user with mode `0400`, at service start. The source
|
||||||
|
|
@ -168,13 +169,21 @@ namespace non-exclusive. It's the swarm's identity on the homeserver:
|
||||||
`swarm-controller` creates every agent's account and every hive's sender
|
`swarm-controller` creates every agent's account and every hive's sender
|
||||||
account with it.
|
account with it.
|
||||||
|
|
||||||
`swarm-matrix-ctl appservice render` mints its tokens inside the
|
The swarm secret store holds the registration's tokens, at
|
||||||
`hive-matrix` container, before tuwunel starts, and renders the
|
`swarm/controller/swarm-controller/matrix/appservice-registration`.
|
||||||
registration tuwunel loads as a credential. `appservice publish`
|
`swarm-matrix-ctl appservice render` renders the registration tuwunel
|
||||||
then writes its `as_token` to
|
loads as a credential from the copy of them in the `hive-matrix`
|
||||||
`swarm/controller/swarm-controller/matrix/appservice-token`. No hive's
|
container, before tuwunel starts and without the store.
|
||||||
policy reaches that path: only matrix-ctl (which writes it) and
|
`appservice sync` runs beside tuwunel on a timer. The store's tokens win:
|
||||||
`swarm-controller` (which reads it) hold a grant to it.
|
when they differ from the container's, it writes them there. It
|
||||||
|
restarts tuwunel whenever the registration is newer than tuwunel's last
|
||||||
|
start, so the next run retries one that failed after writing. A store that holds none takes the container's
|
||||||
|
tokens with a create-only write, so a running swarm keeps its value, and
|
||||||
|
a container that has none mints a pair. `sync` then writes the `as_token`
|
||||||
|
to `swarm/controller/swarm-controller/matrix/appservice-token`. No hive's
|
||||||
|
policy reaches either path: only matrix-ctl (which holds `create` and
|
||||||
|
`read` on the registration, and writes the token) and `swarm-controller`
|
||||||
|
(which reads only the token) hold a grant.
|
||||||
|
|
||||||
`@swarm` is the **one** account the homeserver promotes to admin at boot
|
`@swarm` is the **one** account the homeserver promotes to admin at boot
|
||||||
(`admin_execute`). Only those two principals read its token, which is
|
(`admin_execute`). Only those two principals read its token, which is
|
||||||
|
|
@ -277,9 +286,9 @@ required.
|
||||||
|
|
||||||
<details><summary>Appservice identity</summary>
|
<details><summary>Appservice identity</summary>
|
||||||
|
|
||||||
`swarm-matrix-ctl appservice render` mints the appservice tokens and
|
`swarm-matrix-ctl appservice render` renders the registration from the
|
||||||
renders the registration before tuwunel starts, so every boot has both
|
container's copy of the appservice tokens before tuwunel starts, so every
|
||||||
halves.
|
boot has both halves, with the store up or down.
|
||||||
|
|
||||||
<!-- vale write-good.Passive = NO -->
|
<!-- vale write-good.Passive = NO -->
|
||||||
- **Access tokens live on the device that minted them.**
|
- **Access tokens live on the device that minted them.**
|
||||||
|
|
@ -289,8 +298,8 @@ halves.
|
||||||
(above); a hive's sender account is never promoted.
|
(above); a hive's sender account is never promoted.
|
||||||
- **The value that matters lives at
|
- **The value that matters lives at
|
||||||
`swarm/controller/swarm-controller/matrix/appservice-token`.**
|
`swarm/controller/swarm-controller/matrix/appservice-token`.**
|
||||||
`appservice publish` writes the container's copy there whenever the
|
`appservice sync` writes it there whenever the store's differs from the
|
||||||
store's differs, and `swarm-controller` reads it from there.
|
registration's `as_token`, and `swarm-controller` reads it from there.
|
||||||
<!-- vale write-good.Passive = YES -->
|
<!-- vale write-good.Passive = YES -->
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,12 @@ the rest of the cell says how.
|
||||||
<!-- vale write-good.Passive = NO -->
|
<!-- vale write-good.Passive = NO -->
|
||||||
|
|
||||||
| store path | minter | reader | automatic re-mint | automatic re-pull |
|
| store path | minter | reader | automatic re-mint | automatic re-pull |
|
||||||
| ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `swarm/agents/<agent>/matrix/main` | `swarm-controller`, at agent creation and every 5 minutes | the agent | ✅ when missing or rejected by the homeserver | ✅ the daemon restarts within 5 minutes of a rejection |
|
| `swarm/agents/<agent>/matrix/main` | `swarm-controller`, at agent creation and every 5 minutes | the agent | ✅ when missing or rejected by the homeserver | ✅ the daemon restarts within 5 minutes of a rejection |
|
||||||
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller`, from a credential you hand it | the agent | must be stated | ✅ retried within 2 to 16 minutes after a rejection |
|
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller`, from a credential you hand it | the agent | must be stated | ✅ retried within 2 to 16 minutes after a rejection |
|
||||||
| `swarm/agents/<agent>/matrix-recovery/<account>` | `swarm-controller`, a random passphrase per account | the agent | ❌ never replaced: the account's key backup opens only with this passphrase | ❌ read when the account comes up |
|
| `swarm/agents/<agent>/matrix-recovery/<account>` | `swarm-controller`, a random passphrase per account | the agent | ❌ never replaced: the account's key backup opens only with this passphrase | ❌ read when the account comes up |
|
||||||
| `swarm/controller/swarm-controller/matrix/appservice-token` | `swarm-matrix-ctl` in `hive-matrix`, once | `swarm-controller` | ❌ minted once | ✅ read every 5 minutes |
|
| `swarm/controller/swarm-controller/matrix/appservice-token` | `swarm-matrix-ctl appservice sync` in `hive-matrix`, from the registration's `as_token` | `swarm-controller` | ❌ follows the registration below | ✅ read every 5 minutes |
|
||||||
|
| `swarm/controller/swarm-controller/matrix/appservice-registration` | `swarm-matrix-ctl appservice sync` in `hive-matrix`: seeded from the container's own tokens when the store holds none, a new pair only when the container has none either | `swarm-matrix-ctl` in `hive-matrix`; the controller has no grant to it | ❌ minted once; an operator who replaces it makes `sync` write the new pair into the container and restart the homeserver | ✅ `sync` re-reads it every 10 minutes |
|
||||||
| `swarm/controller/swarm-controller/oidc/client` | authelia, first boot | `swarm-controller` | ❌ minted once; `swarm-secret-publish` republishes a re-mint | ✅ re-read when authelia refuses it |
|
| `swarm/controller/swarm-controller/oidc/client` | authelia, first boot | `swarm-controller` | ❌ minted once; `swarm-secret-publish` republishes a re-mint | ✅ re-read when authelia refuses it |
|
||||||
| `swarm/controller/swarm-controller/forge/token` | the forge, once | `swarm-controller` | ❌ minted once; delete `/var/lib/forgejo/swarm-controller-token-name` in the forge container to mint a new one | ✅ re-read on a 401 |
|
| `swarm/controller/swarm-controller/forge/token` | the forge, once | `swarm-controller` | ❌ minted once; delete `/var/lib/forgejo/swarm-controller-token-name` in the forge container to mint a new one | ✅ re-read on a 401 |
|
||||||
| `swarm/controller/swarm-controller/grafana/token` | `swarm-bao-grafana-controller-token` on Grafana's host | `swarm-controller` | ✅ every 10 minutes, when Grafana refuses it or none is stored | ✅ re-read on a 401 |
|
| `swarm/controller/swarm-controller/grafana/token` | `swarm-bao-grafana-controller-token` on Grafana's host | `swarm-controller` | ✅ every 10 minutes, when Grafana refuses it or none is stored | ✅ re-read on a 401 |
|
||||||
|
|
@ -103,6 +104,7 @@ pieces fit, and the reasoning behind them, is in [`credentials.md`](credentials.
|
||||||
| swarm-services root CA (cert + key) | `swarm-bao.nix`'s granting unit, inside the store's `pki` mount | the **key** never leaves the mount; the **cert** lands at `/var/lib/swarm-bao-services-pki/services-root.pem` (0644) and at `/var/lib/hive-tls/swarm-services-root.pem` on every hive | nothing to copy — every hive receives the cert with the leaf it asks the store for, or from `pki/ca/pem` where it fronts no swarm name and needs a store identity |
|
| swarm-services root CA (cert + key) | `swarm-bao.nix`'s granting unit, inside the store's `pki` mount | the **key** never leaves the mount; the **cert** lands at `/var/lib/swarm-bao-services-pki/services-root.pem` (0644) and at `/var/lib/hive-tls/swarm-services-root.pem` on every hive | nothing to copy — every hive receives the cert with the leaf it asks the store for, or from `pki/ca/pem` where it fronts no swarm name and needs a store identity |
|
||||||
| authelia session, JWT, storage-encryption and OIDC HMAC keys | `authelia-swarm-secrets` in authelia's container. It stores each in the swarm secret store at `swarm/services/swarm-authelia/keys/<name>` when the store holds none: the value of the key an earlier release generated, or a new one when there is no such file. The files stay on disk. While the store's policy doesn't grant the leaf yet, or the store is down, the unit runs on the files. A container with no secret publisher identity generates the files, as before | `/var/lib/authelia-swarm/{session,jwt,storage-encryption,oidc-hmac}.key` | nothing to place: the publisher's store leaf is the one in the row below. A store that stays unreachable on a start with no key file keeps authelia from starting |
|
| authelia session, JWT, storage-encryption and OIDC HMAC keys | `authelia-swarm-secrets` in authelia's container. It stores each in the swarm secret store at `swarm/services/swarm-authelia/keys/<name>` when the store holds none: the value of the key an earlier release generated, or a new one when there is no such file. The files stay on disk. While the store's policy doesn't grant the leaf yet, or the store is down, the unit runs on the files. A container with no secret publisher identity generates the files, as before | `/var/lib/authelia-swarm/{session,jwt,storage-encryption,oidc-hmac}.key` | nothing to place: the publisher's store leaf is the one in the row below. A store that stays unreachable on a start with no key file keeps authelia from starting |
|
||||||
| authelia OIDC issuer key (RSA) | same unit, same store path (`oidc-issuer`) | `/var/lib/authelia-swarm/oidc-issuer.key` | same — relying parties verify against the **public** half at `/jwks.json` |
|
| authelia OIDC issuer key (RSA) | same unit, same store path (`oidc-issuer`) | `/var/lib/authelia-swarm/oidc-issuer.key` | same — relying parties verify against the **public** half at `/jwks.json` |
|
||||||
|
| swarm appservice registration (`as_token`, `hs_token`) | `swarm-matrix-ctl appservice sync` in `hive-matrix`. The store's pair wins; a store holding none takes the container's own tokens, so the value doesn't change by moving. Only `appservice render` on a container with neither mints a new pair | `/var/lib/swarm-matrix-appservice/{as-token,hs-token,swarm.yaml}` in `hive-matrix`, the last copy `sync` took, which tuwunel starts from; the store path is `swarm/controller/swarm-controller/matrix/appservice-registration` | nothing to place: matrix-ctl's store leaf is the one the container binds. A store that's down leaves the homeserver running on the files |
|
||||||
| OIDC client secret, plaintext half | `authelia crypto hash generate --random` | `/var/lib/authelia-swarm/oidc-clients/<id>.secret` | operator provides the file and names it in whichever option reads it — `sso.clientSecretFile` for a service; the forge and, by default, the hive collector need none — `swarm-bao-forge-oidc` and `swarm-bao-hive-otel-oidc` fetch it from the store ([how](credentials.md#hive-level-secrets)) |
|
| OIDC client secret, plaintext half | `authelia crypto hash generate --random` | `/var/lib/authelia-swarm/oidc-clients/<id>.secret` | operator provides the file and names it in whichever option reads it — `sso.clientSecretFile` for a service; the forge and, by default, the hive collector need none — `swarm-bao-forge-oidc` and `swarm-bao-hive-otel-oidc` fetch it from the store ([how](credentials.md#hive-level-secrets)) |
|
||||||
| OIDC client secret, digest half | the same mint | `oidc-clients/<id>.digest` | authelia's own half; merged at runtime via `settingsFiles` |
|
| OIDC client secret, digest half | the same mint | `oidc-clients/<id>.digest` | authelia's own half; merged at runtime via `settingsFiles` |
|
||||||
| the swarm collector's copy of its OIDC secret | `swarm-bao-otel-oidc.service` reads it out of the swarm secret store, **on every host that runs the collector and holds a store identity** | `/run/swarm-otel-oidc/<id>.secret` on the host's tmpfs, bind-mounted read-only into the `swarm-otel` container — never on persistent disk, so a reboot fetches it again | same unit, same path — one route, co-located or not. A collector with no store identity has `clientSecretFile == null`, its already-supported unauthenticated-push degrade — see [swarm-level secrets](credentials.md#swarm-level-secrets) |
|
| the swarm collector's copy of its OIDC secret | `swarm-bao-otel-oidc.service` reads it out of the swarm secret store, **on every host that runs the collector and holds a store identity** | `/run/swarm-otel-oidc/<id>.secret` on the host's tmpfs, bind-mounted read-only into the `swarm-otel` container — never on persistent disk, so a reboot fetches it again | same unit, same path — one route, co-located or not. A collector with no store identity has `clientSecretFile == null`, its already-supported unauthenticated-push degrade — see [swarm-level secrets](credentials.md#swarm-level-secrets) |
|
||||||
|
|
|
||||||
|
|
@ -137,11 +137,12 @@ let
|
||||||
#
|
#
|
||||||
# The homeserver's one appservice registration, and the swarm's identity on
|
# The homeserver's one appservice registration, and the swarm's identity on
|
||||||
# it: `swarm-controller` creates every agent's account and every hive's
|
# it: `swarm-controller` creates every agent's account and every hive's
|
||||||
# `@hive-<hive>` sender with its token. Minted INSIDE this container by
|
# `@hive-<hive>` sender with its token. The store holds its tokens;
|
||||||
# `swarm-matrix-ctl appservice render` and published to the store by
|
# `swarm-matrix-ctl appservice sync` keeps this container's copy in step with
|
||||||
# `appservice publish`, which is why it is gated on matrix-ctl's store
|
# them, which is why it is gated on matrix-ctl's store identity: with nobody
|
||||||
# identity: with nobody to publish it, a registration here would be an
|
# to sync it, a registration here would be an admin credential nobody reads.
|
||||||
# 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`
|
# 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.
|
# 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.
|
# below is `requiredBy` it and local only.
|
||||||
++ lib.optional ctlActive "${swarmAppserviceCredentialId}:${swarmAppserviceDir}/swarm.yaml";
|
++ lib.optional ctlActive "${swarmAppserviceCredentialId}:${swarmAppserviceDir}/swarm.yaml";
|
||||||
|
|
||||||
# The swarm registration, minted and rendered before the homeserver
|
# The swarm registration, rendered before the homeserver loads it from
|
||||||
# loads it. No network and no store: this is on tuwunel's start
|
# the tokens in the state dir: the last copy of the store's that
|
||||||
# path, and a store outage must not keep the homeserver down.
|
# `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 {
|
systemd.services.swarm-matrix-appservice-render = lib.mkIf ctlActive {
|
||||||
description = "render the swarm's appservice registration";
|
description = "render the swarm's appservice registration";
|
||||||
before = [ "tuwunel.service" ];
|
before = [ "tuwunel.service" ];
|
||||||
|
|
@ -1027,21 +1030,31 @@ in
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# Hand the swarm registration's token to swarm-controller, through
|
# The store holds the swarm registration; this keeps the state dir
|
||||||
# the store, under matrix-ctl's identity; the write lands at a path
|
# the render above reads in step with it, and publishes the
|
||||||
# only matrix-ctl and the controller are granted (./swarm-bao.nix).
|
# `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;
|
# `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
|
# ./swarm-secret-publisher.nix states: a sealed store answers on the
|
||||||
# port and never answers the read.
|
# port and never answers the read.
|
||||||
systemd.services.swarm-matrix-appservice-publish = lib.mkIf ctlActive {
|
systemd.services.swarm-matrix-appservice-sync = lib.mkIf ctlActive {
|
||||||
description = "publish the swarm's appservice token to the swarm secret store";
|
description = "keep the swarm's appservice registration in step with the swarm secret store";
|
||||||
after = [ "swarm-matrix-appservice-render.service" ];
|
after = [ "swarm-matrix-appservice-render.service" ];
|
||||||
requires = [ "swarm-matrix-appservice-render.service" ];
|
requires = [ "swarm-matrix-appservice-render.service" ];
|
||||||
wantedBy = [ "multi-user.target" ];
|
wantedBy = [ "multi-user.target" ];
|
||||||
|
path = [ pkgs.systemd ];
|
||||||
serviceConfig = {
|
serviceConfig = {
|
||||||
Type = "oneshot";
|
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";
|
Restart = "on-failure";
|
||||||
RestartSec = 30;
|
RestartSec = 30;
|
||||||
TimeoutStartSec = 60;
|
TimeoutStartSec = 60;
|
||||||
|
|
@ -1053,12 +1066,19 @@ in
|
||||||
BAO_CLIENT_KEY = deployCfg.matrix.ctlBaoClientKeyFile;
|
BAO_CLIENT_KEY = deployCfg.matrix.ctlBaoClientKeyFile;
|
||||||
MATRIX_APPSERVICE_CERT_ROLE = ctlCertRole;
|
MATRIX_APPSERVICE_CERT_ROLE = ctlCertRole;
|
||||||
MATRIX_APPSERVICE_DIR = swarmAppserviceDir;
|
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) {
|
// lib.optionalAttrs (deployCfg.bao.serverCaFile != null) {
|
||||||
BAO_CACERT = deployCfg.bao.serverCaFile;
|
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 ];
|
environment.systemPackages = [ deployCfg.matrix.package ];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -618,7 +618,7 @@ let
|
||||||
matrixCtlPolicyName = "swarm-matrix-ctl";
|
matrixCtlPolicyName = "swarm-matrix-ctl";
|
||||||
matrixCtlCn = baoDeploy.matrixCtlCommonName;
|
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.
|
# load-bearing.
|
||||||
#
|
#
|
||||||
# `secret/data/` is KV v2's ACL prefix, inserted by the engine rather than
|
# `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
|
# to overwrite Grafana's OIDC client. The paths are spelled to the leaf for
|
||||||
# that reason, not for tidiness.
|
# that reason, not for tidiness.
|
||||||
#
|
#
|
||||||
# The first is the swarm appservice's token, which matrix-ctl mints inside
|
# The first is the swarm appservice's token, which matrix-ctl derives from
|
||||||
# the container and publishes here for the controller. `read` as well as
|
# the registration and publishes here for the controller. `read` as well as
|
||||||
# write, unlike either sibling, because publish compares before it writes.
|
# 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.
|
# `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.
|
# the homeserver's container.
|
||||||
matrixCtlPolicyText = ''
|
matrixCtlPolicyText = ''
|
||||||
path "${credentialMountPath}/data/${swarmAppserviceTokenLeaf}" {
|
path "${credentialMountPath}/data/${swarmAppserviceTokenLeaf}" {
|
||||||
capabilities = ["create", "update", "read"]
|
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.sso.clientId}/oidc/client"
|
||||||
+ readStanza "${credentialMountPath}/data/swarm/services/${hyperhiveCfg.swarm.matrix.otel.clientId}/oidc/client";
|
+ readStanza "${credentialMountPath}/data/swarm/services/${hyperhiveCfg.swarm.matrix.otel.clientId}/oidc/client";
|
||||||
|
|
@ -650,6 +659,11 @@ let
|
||||||
# homeserver's admin.
|
# homeserver's admin.
|
||||||
swarmAppserviceTokenLeaf = "swarm/controller/swarm-controller/matrix/appservice-token";
|
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
|
# The controller's own OIDC client secret, the nix half of
|
||||||
# `swarm_secret_client::queue::controller_client_path`. The publisher writes
|
# `swarm_secret_client::queue::controller_client_path`. The publisher writes
|
||||||
# it; the controller reads it and holds no other copy.
|
# it; the controller reads it and holds no other copy.
|
||||||
|
|
@ -1902,12 +1916,13 @@ in
|
||||||
example = "swarm-matrix-ctl.svc";
|
example = "swarm-matrix-ctl.svc";
|
||||||
description = ''
|
description = ''
|
||||||
Subject the store's matrix-ctl cert-auth role accepts — the
|
Subject the store's matrix-ctl cert-auth role accepts — the
|
||||||
identity the matrix container's `swarm-matrix-appservice-publish`
|
identity the matrix container's `swarm-matrix-appservice-sync`
|
||||||
unit presents when it publishes the swarm appservice's token.
|
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
|
A **third** identity rather than reuse of either sibling above. Its
|
||||||
grant is that credential itself, plus read on two OIDC client
|
grant is that credential and the registration it derives from, plus
|
||||||
secrets: the homeserver's, fetched by `swarm-bao-matrix-oidc`, and
|
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
|
that of the journal forwarder in the homeserver's container. The
|
||||||
point of the whole arrangement is that the
|
point of the whole arrangement is that the
|
||||||
process holding the appservice token is not the hive, so handing it
|
process holding the appservice token is not the hive, so handing it
|
||||||
|
|
|
||||||
|
|
@ -575,12 +575,13 @@ let
|
||||||
# prefix the publisher holds would be a real loss even though it would
|
# prefix the publisher holds would be a real loss even though it would
|
||||||
# read as tidier.
|
# read as tidier.
|
||||||
#
|
#
|
||||||
# The first leaf is the swarm appservice token, which matrix-ctl mints
|
# The first leaf is the swarm appservice token, which matrix-ctl derives
|
||||||
# and publishes for the controller; the second and third are the
|
# and publishes for the controller; the second is the registration it
|
||||||
# homeserver's own OIDC client secret and its container forwarder's,
|
# derives that from, `create` and `read` only; the third and fourth are
|
||||||
# read-only. The whole list, so a fourth stanza fails rather than riding
|
# 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.
|
# 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 =
|
ok =
|
||||||
let
|
let
|
||||||
s = baoGrantHere.systemd.services.swarm-bao-matrix-ctl-policy.script;
|
s = baoGrantHere.systemd.services.swarm-bao-matrix-ctl-policy.script;
|
||||||
|
|
@ -594,6 +595,13 @@ let
|
||||||
"read"
|
"read"
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
path = "secret/data/swarm/controller/swarm-controller/matrix/appservice-registration";
|
||||||
|
caps = [
|
||||||
|
"create"
|
||||||
|
"read"
|
||||||
|
];
|
||||||
|
}
|
||||||
{
|
{
|
||||||
path = "secret/data/swarm/services/tuwunel/oidc/client";
|
path = "secret/data/swarm/services/tuwunel/oidc/client";
|
||||||
caps = [ "read" ];
|
caps = [ "read" ];
|
||||||
|
|
@ -611,8 +619,9 @@ let
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
# 🩸 `read` is load-bearing here, unlike on the publisher: matrix-ctl's
|
# 🩸 `read` is load-bearing here, unlike on the publisher: matrix-ctl's
|
||||||
# `appservice publish` reads the swarm appservice token back and writes
|
# `appservice sync` reads the swarm appservice token back and writes
|
||||||
# only when the store's copy differs.
|
# only when the store's copy differs, and reads the registration before
|
||||||
|
# it creates it.
|
||||||
name = "matrix-ctl may read back what it writes";
|
name = "matrix-ctl may read back what it writes";
|
||||||
ok =
|
ok =
|
||||||
let
|
let
|
||||||
|
|
@ -2238,7 +2247,9 @@ let
|
||||||
# it: matrix-ctl is its one writer. Pinned as the whole stanza, so an
|
# it: matrix-ctl is its one writer. Pinned as the whole stanza, so an
|
||||||
# added capability fails.
|
# added capability fails.
|
||||||
name = "the controller reads the swarm appservice token and cannot write it";
|
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
|
# The controller's own OIDC client secret, which the publisher writes and
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,7 @@ let
|
||||||
ok =
|
ok =
|
||||||
let
|
let
|
||||||
env =
|
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;
|
hiveLeaf = baoWithMatrix.services.hyperhive.deploy.bao.clientCertFile;
|
||||||
in
|
in
|
||||||
env.BAO_CLIENT_CERT == "/var/lib/swarm-bao-pki/matrix-ctl.pem" && env.BAO_CLIENT_CERT != hiveLeaf;
|
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
|
# 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
|
# exits non-zero with clap's usage, a deploy-time failure with no local
|
||||||
# signal.
|
# 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 =
|
ok =
|
||||||
let
|
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
|
in
|
||||||
u.environment.MATRIX_APPSERVICE_CERT_ROLE == "swarm-matrix-ctl"
|
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
|
# 🩸 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
|
# 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
|
# dir at runtime, so nothing here can be a token and an environment block
|
||||||
# is world-readable through `systemctl show`.
|
# 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 =
|
ok =
|
||||||
let
|
let
|
||||||
env =
|
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
|
in
|
||||||
!(lib.any (v: lib.hasInfix "as_token" v || lib.hasInfix "syt_" v) (lib.attrValues env));
|
!(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
|
&& lib.hasSuffix "swarm-matrix-ctl appservice render" r.serviceConfig.ExecStart
|
||||||
&& !(r.environment ? BAO_ADDR);
|
&& !(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
|
# 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
|
# is nobody to publish the swarm token, so no admin, no registration and
|
||||||
|
|
@ -284,7 +332,8 @@ let
|
||||||
in
|
in
|
||||||
!(g ? admin_execute)
|
!(g ? admin_execute)
|
||||||
&& !(c.systemd.services ? swarm-matrix-appservice-render)
|
&& !(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);
|
&& !(lib.any (lib.hasPrefix "swarm-appservice.yaml") c.systemd.services.tuwunel.serviceConfig.LoadCredential);
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ pub(super) async fn appservice_token(store: &SecretStore) -> Result<String> {
|
||||||
Some(c) if !c.value.trim().is_empty() => Ok(c.value.trim().to_owned()),
|
Some(c) if !c.value.trim().is_empty() => Ok(c.value.trim().to_owned()),
|
||||||
_ => bail!(
|
_ => bail!(
|
||||||
"nothing at {path}: the matrix container has not published the swarm \
|
"nothing at {path}: the matrix container has not published the swarm \
|
||||||
appservice token (swarm-matrix-appservice-publish)"
|
appservice token (swarm-matrix-appservice-sync)"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,18 @@ crate.
|
||||||
|
|
||||||
### `appservice render`
|
### `appservice render`
|
||||||
|
|
||||||
Mints the swarm appservice registration's tokens when absent and renders the
|
Renders the swarm appservice registration tuwunel loads from the tokens in the
|
||||||
registration tuwunel loads. Runs before the homeserver and needs no network.
|
state dir, minting them only on a host that has none. Runs before the
|
||||||
|
homeserver and needs no network: the tokens on disk are the last copy `sync`
|
||||||
|
took from the store.
|
||||||
|
|
||||||
### `appservice publish`
|
### `appservice sync`
|
||||||
|
|
||||||
Writes the rendered `as_token` to the swarm secret store for `swarm-controller`,
|
The store holds the registration's tokens. This writes them into the state dir
|
||||||
when the store's copy differs.
|
when they differ, restarts the homeserver whenever the registration is
|
||||||
|
newer than its last start, and publishes the `as_token` for `swarm-controller`. A store holding none is seeded from the
|
||||||
|
state dir, create-only, so nothing rotates. It runs beside the homeserver, so a
|
||||||
|
store outage leaves the homeserver running on the last copy.
|
||||||
|
|
||||||
Both are configured entirely by the `MATRIX_APPSERVICE_*` environment the units
|
Both are configured entirely by the `MATRIX_APPSERVICE_*` environment the units
|
||||||
set — no flags. A systemd `Environment=` block is what a nix module can render; a
|
set — no flags. A systemd `Environment=` block is what a nix module can render; a
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,38 @@
|
||||||
//! `swarm-matrix-ctl appservice` — the **swarm's** own appservice
|
//! `swarm-matrix-ctl appservice` — the **swarm's** own appservice
|
||||||
//! registration: minted here, loaded by the homeserver beside us, and published
|
//! registration. The store holds it; this container keeps a copy the
|
||||||
//! to the one store path `swarm-controller` reads it from.
|
//! homeserver starts from.
|
||||||
//!
|
//!
|
||||||
//! Two verbs, because they have opposite failure rules:
|
//! Two verbs, because they have opposite failure rules:
|
||||||
//!
|
//!
|
||||||
//! - [`render`] runs before tuwunel and touches nothing but this container's
|
//! - [`render`] runs before tuwunel and touches nothing but this container's
|
||||||
//! state dir. tuwunel loads the file it writes through `LoadCredential`, and
|
//! state dir. tuwunel loads the file it writes through `LoadCredential`, and
|
||||||
//! a missing credential source fails the homeserver's start, so this half
|
//! a missing credential source fails the homeserver's start, so this half
|
||||||
//! must not be able to fail on a network.
|
//! must not be able to fail on a network. It renders from the tokens on
|
||||||
//! - [`publish`] runs after it and needs the store. A sealed store delays it
|
//! disk, which are the last copy [`sync`] took from the store, and mints
|
||||||
|
//! them only on a host that has none.
|
||||||
|
//! - [`sync`] runs beside the homeserver, never before it, and needs the store.
|
||||||
|
//! The store's tokens win: it writes the state dir only when they differ, and
|
||||||
|
//! restarts the homeserver whenever the registration is newer than the
|
||||||
|
//! homeserver's last start. A store holding none is seeded from
|
||||||
|
//! the state dir, create-only, so nothing rotates. A sealed store delays it
|
||||||
//! and nothing else.
|
//! and nothing else.
|
||||||
//!
|
//!
|
||||||
//! "Only once": the token file in the state dir is the record. [`render`]
|
|
||||||
//! mints only when it is absent and re-renders from it every time; [`publish`]
|
|
||||||
//! writes the store only when the store's copy differs.
|
|
||||||
//!
|
|
||||||
//! This registration's sender is promoted to homeserver admin at boot
|
//! This registration's sender is promoted to homeserver admin at boot
|
||||||
//! (`nix/host-modules/hive-matrix.nix`), which is why its token goes to
|
//! (`nix/host-modules/hive-matrix.nix`), which is why its `as_token` goes to
|
||||||
//! `swarm_secret_client::matrix::swarm_appservice_token_path` — a path no
|
//! `swarm_secret_client::matrix::swarm_appservice_token_path` for
|
||||||
//! hive's policy reaches — and to nowhere else.
|
//! `swarm-controller` — a path no hive's policy reaches — and to nowhere else.
|
||||||
|
|
||||||
use std::io::Write as _;
|
use std::io::Write as _;
|
||||||
use std::os::unix::fs::OpenOptionsExt as _;
|
use std::os::unix::fs::OpenOptionsExt as _;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result, bail};
|
||||||
use swarm_secret_client::{
|
use swarm_secret_client::{
|
||||||
SecretStore,
|
SecretStore,
|
||||||
client::{DEFAULT_CERT_MOUNT, Settings},
|
client::{DEFAULT_CERT_MOUNT, Settings},
|
||||||
matrix,
|
matrix::{self, Registration},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::registration;
|
|
||||||
|
|
||||||
/// This container's state dir for the registration and its two tokens.
|
/// This container's state dir for the registration and its two tokens.
|
||||||
const ENV_DIR: &str = "MATRIX_APPSERVICE_DIR";
|
const ENV_DIR: &str = "MATRIX_APPSERVICE_DIR";
|
||||||
/// The registration's `sender_localpart`: the account the homeserver creates
|
/// The registration's `sender_localpart`: the account the homeserver creates
|
||||||
|
|
@ -40,8 +40,11 @@ const ENV_DIR: &str = "MATRIX_APPSERVICE_DIR";
|
||||||
const ENV_SENDER: &str = "MATRIX_APPSERVICE_SENDER";
|
const ENV_SENDER: &str = "MATRIX_APPSERVICE_SENDER";
|
||||||
/// The user namespace regex, rendered by nix.
|
/// The user namespace regex, rendered by nix.
|
||||||
const ENV_USER_REGEX: &str = "MATRIX_APPSERVICE_USER_REGEX";
|
const ENV_USER_REGEX: &str = "MATRIX_APPSERVICE_USER_REGEX";
|
||||||
/// Role on the store's `cert` auth mount that [`publish`] logs in with.
|
/// Role on the store's `cert` auth mount that [`sync`] logs in with.
|
||||||
const ENV_CERT_ROLE: &str = "MATRIX_APPSERVICE_CERT_ROLE";
|
const ENV_CERT_ROLE: &str = "MATRIX_APPSERVICE_CERT_ROLE";
|
||||||
|
/// The unit that loads the registration, which [`sync`] restarts when the
|
||||||
|
/// registration is newer than its last start.
|
||||||
|
const ENV_RESTART_UNIT: &str = "MATRIX_APPSERVICE_RESTART_UNIT";
|
||||||
|
|
||||||
/// The registration's `id`.
|
/// The registration's `id`.
|
||||||
const ID: &str = "swarm";
|
const ID: &str = "swarm";
|
||||||
|
|
@ -85,11 +88,8 @@ fn render_into(dir: &Path, sender: &str, regex: &str) -> Result<()> {
|
||||||
|
|
||||||
/// The token at `path`, minting and writing one first when there is none.
|
/// The token at `path`, minting and writing one first when there is none.
|
||||||
fn existing_or_minted(path: &Path) -> Result<String> {
|
fn existing_or_minted(path: &Path) -> Result<String> {
|
||||||
match std::fs::read_to_string(path) {
|
if let Some(t) = read_token(path)? {
|
||||||
Ok(t) if !t.trim().is_empty() => return Ok(t.trim().to_owned()),
|
return Ok(t);
|
||||||
Ok(_) => {}
|
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
|
||||||
Err(e) => return Err(e).with_context(|| format!("reading {}", path.display())),
|
|
||||||
}
|
}
|
||||||
let token = swarm_matrix_client::random_hex(TOKEN_BYTES)?;
|
let token = swarm_matrix_client::random_hex(TOKEN_BYTES)?;
|
||||||
write_secret(path, &token)?;
|
write_secret(path, &token)?;
|
||||||
|
|
@ -97,6 +97,16 @@ fn existing_or_minted(path: &Path) -> Result<String> {
|
||||||
Ok(token)
|
Ok(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The trimmed token at `path`; `None` when the file is missing or blank.
|
||||||
|
fn read_token(path: &Path) -> Result<Option<String>> {
|
||||||
|
match std::fs::read_to_string(path) {
|
||||||
|
Ok(t) if !t.trim().is_empty() => Ok(Some(t.trim().to_owned())),
|
||||||
|
Ok(_) => Ok(None),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(e) => Err(e).with_context(|| format!("reading {}", path.display())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Write `contents` to `path` as a `0600` file, through a rename so a reader
|
/// Write `contents` to `path` as a `0600` file, through a rename so a reader
|
||||||
/// never sees half of it.
|
/// never sees half of it.
|
||||||
fn write_secret(path: &Path, contents: &str) -> Result<()> {
|
fn write_secret(path: &Path, contents: &str) -> Result<()> {
|
||||||
|
|
@ -136,23 +146,74 @@ fn registration_yaml(sender: &str, regex: &str, as_token: &str, hs_token: &str)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the store needs the local token written to it.
|
/// What [`sync`] needs of the store, so the decisions can be tested without
|
||||||
fn needs_publish(stored: Option<&matrix::Credential>, local: &str) -> bool {
|
/// one.
|
||||||
stored.is_none_or(|c| c.value.trim() != local)
|
trait Store {
|
||||||
|
/// The registration's tokens, or `None` when nothing is stored.
|
||||||
|
async fn registration(&self) -> Result<Option<Registration>>;
|
||||||
|
/// Store `r` only if nothing is stored yet.
|
||||||
|
async fn create_registration(&self, r: &Registration) -> Result<()>;
|
||||||
|
/// What `swarm-controller` reads.
|
||||||
|
async fn controller_token(&self) -> Result<Option<matrix::Credential>>;
|
||||||
|
async fn write_controller_token(&self, as_token: &str) -> Result<()>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write the rendered registration's `as_token` to the store, unless the store
|
/// The real store, logged in as matrix-ctl.
|
||||||
/// already holds it.
|
struct Bao(SecretStore);
|
||||||
|
|
||||||
|
impl Store for Bao {
|
||||||
|
async fn registration(&self) -> Result<Option<Registration>> {
|
||||||
|
let path = matrix::swarm_appservice_registration_path()?;
|
||||||
|
self.0
|
||||||
|
.read_optional(&path)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("reading {path}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_registration(&self, r: &Registration) -> Result<()> {
|
||||||
|
let path = matrix::swarm_appservice_registration_path()?;
|
||||||
|
self.0
|
||||||
|
.create(&path, r)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("creating {path}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn controller_token(&self) -> Result<Option<matrix::Credential>> {
|
||||||
|
let path = matrix::swarm_appservice_token_path()?;
|
||||||
|
self.0
|
||||||
|
.read_optional(&path)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("reading {path}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_controller_token(&self, as_token: &str) -> Result<()> {
|
||||||
|
let path = matrix::swarm_appservice_token_path()?;
|
||||||
|
self.0
|
||||||
|
.write(
|
||||||
|
&path,
|
||||||
|
&matrix::Credential {
|
||||||
|
value: as_token.to_owned(),
|
||||||
|
homeserver: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("writing {path}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Make the state dir hold the store's registration, and the controller's token
|
||||||
|
/// match it.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
/// If a variable is missing, the registration has not been rendered, or the
|
/// If a variable is missing, the store refuses the login, a read or a write,
|
||||||
/// store refuses the login, the read or the write.
|
/// the state dir cannot be written, or the homeserver cannot be restarted.
|
||||||
pub async fn publish() -> Result<()> {
|
pub async fn sync() -> Result<()> {
|
||||||
let get = |k: &str| std::env::var(k).ok();
|
let get = |k: &str| std::env::var(k).ok();
|
||||||
let dir = PathBuf::from(required(&get, ENV_DIR)?);
|
let dir = PathBuf::from(required(&get, ENV_DIR)?);
|
||||||
|
let sender = required(&get, ENV_SENDER)?;
|
||||||
|
let regex = required(&get, ENV_USER_REGEX)?;
|
||||||
let cert_role = required(&get, ENV_CERT_ROLE)?;
|
let cert_role = required(&get, ENV_CERT_ROLE)?;
|
||||||
let registration = dir.join(REGISTRATION);
|
let unit = required(&get, ENV_RESTART_UNIT)?;
|
||||||
let local = registration::as_token(®istration.to_string_lossy())?;
|
|
||||||
|
|
||||||
let settings = Settings::from_env().context("reading the store's BAO_* environment")?;
|
let settings = Settings::from_env().context("reading the store's BAO_* environment")?;
|
||||||
let store = SecretStore::connect(&settings, &cert_role, DEFAULT_CERT_MOUNT)
|
let store = SecretStore::connect(&settings, &cert_role, DEFAULT_CERT_MOUNT)
|
||||||
|
|
@ -160,31 +221,228 @@ pub async fn publish() -> Result<()> {
|
||||||
.with_context(|| {
|
.with_context(|| {
|
||||||
format!("logging in to the swarm secret store as cert role {cert_role}")
|
format!("logging in to the swarm secret store as cert role {cert_role}")
|
||||||
})?;
|
})?;
|
||||||
let path = matrix::swarm_appservice_token_path()?;
|
sync_with(&Bao(store), &dir, &sender, ®ex, mint, &Systemd(unit)).await
|
||||||
let stored: Option<matrix::Credential> = store
|
}
|
||||||
.read_optional(&path)
|
|
||||||
.await
|
/// [`sync`] against an explicit store, directory and homeserver unit.
|
||||||
.with_context(|| format!("reading {path}"))?;
|
///
|
||||||
if !needs_publish(stored.as_ref(), &local) {
|
/// The store's registration wins. Where it holds none, the state dir's tokens
|
||||||
tracing::info!(%path, "the swarm appservice token is already published");
|
/// seed it, and only a host with neither mints a pair. The homeserver is
|
||||||
return Ok(());
|
/// restarted whenever the registration was written after it last started, so
|
||||||
|
/// a run that failed between the write and the restart is finished by the next.
|
||||||
|
async fn sync_with(
|
||||||
|
store: &impl Store,
|
||||||
|
dir: &Path,
|
||||||
|
sender: &str,
|
||||||
|
regex: &str,
|
||||||
|
mint: impl FnOnce() -> Result<Registration>,
|
||||||
|
unit: &impl Homeserver,
|
||||||
|
) -> Result<()> {
|
||||||
|
let local = read_local(dir)?;
|
||||||
|
let current = stored_or_seeded(store, local.as_ref(), mint).await?;
|
||||||
|
|
||||||
|
let want = registration_yaml(sender, regex, ¤t.as_token, ¤t.hs_token);
|
||||||
|
let have = std::fs::read_to_string(dir.join(REGISTRATION)).ok();
|
||||||
|
if local.as_ref() != Some(¤t) || have.as_deref() != Some(&want) {
|
||||||
|
write_secret(&dir.join(AS_TOKEN), ¤t.as_token)?;
|
||||||
|
write_secret(&dir.join(HS_TOKEN), ¤t.hs_token)?;
|
||||||
|
write_secret(&dir.join(REGISTRATION), &want)?;
|
||||||
|
tracing::info!("took the swarm appservice registration from the store");
|
||||||
|
}
|
||||||
|
restart_if_stale(unit, &dir.join(REGISTRATION))?;
|
||||||
|
|
||||||
|
if needs_publish(store.controller_token().await?.as_ref(), ¤t.as_token) {
|
||||||
|
store.write_controller_token(¤t.as_token).await?;
|
||||||
|
tracing::info!("published the swarm appservice token for the controller");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The store's registration; where it holds none, `local` or a minted pair is
|
||||||
|
/// created there first. A refused create is a lost race or a denial, and the
|
||||||
|
/// read-back tells which: it finds the winner's value, or nothing.
|
||||||
|
async fn stored_or_seeded(
|
||||||
|
store: &impl Store,
|
||||||
|
local: Option<&Registration>,
|
||||||
|
mint: impl FnOnce() -> Result<Registration>,
|
||||||
|
) -> Result<Registration> {
|
||||||
|
if let Some(stored) = store.registration().await? {
|
||||||
|
return Ok(stored);
|
||||||
|
}
|
||||||
|
let seed = match local {
|
||||||
|
Some(l) => l.clone(),
|
||||||
|
None => mint()?,
|
||||||
|
};
|
||||||
|
if let Err(e) = store.create_registration(&seed).await {
|
||||||
|
tracing::warn!(error = %e, "could not create the swarm appservice registration; reading it back");
|
||||||
}
|
}
|
||||||
store
|
store
|
||||||
.write(
|
.registration()
|
||||||
&path,
|
.await?
|
||||||
&matrix::Credential {
|
.context("the store holds no swarm appservice registration after creating one")
|
||||||
value: local,
|
}
|
||||||
homeserver: None,
|
|
||||||
},
|
/// The tokens on disk, when both are there.
|
||||||
)
|
fn read_local(dir: &Path) -> Result<Option<Registration>> {
|
||||||
.await
|
let as_token = read_token(&dir.join(AS_TOKEN))?;
|
||||||
.with_context(|| format!("writing the swarm appservice token to {path}"))?;
|
let hs_token = read_token(&dir.join(HS_TOKEN))?;
|
||||||
tracing::info!(%path, "published the swarm appservice token");
|
Ok(as_token
|
||||||
|
.zip(hs_token)
|
||||||
|
.map(|(as_token, hs_token)| Registration { as_token, hs_token }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mint() -> Result<Registration> {
|
||||||
|
Ok(Registration {
|
||||||
|
as_token: swarm_matrix_client::random_hex(TOKEN_BYTES)?,
|
||||||
|
hs_token: swarm_matrix_client::random_hex(TOKEN_BYTES)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the store needs the local token written to it.
|
||||||
|
fn needs_publish(stored: Option<&matrix::Credential>, local: &str) -> bool {
|
||||||
|
stored.is_none_or(|c| c.value.trim() != local)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What [`sync`] needs of the unit that loads the registration.
|
||||||
|
trait Homeserver {
|
||||||
|
/// When the unit last became active, in microseconds since the epoch;
|
||||||
|
/// `None` when it never has.
|
||||||
|
fn started_us(&self) -> Result<Option<u64>>;
|
||||||
|
/// Restart it onto the registration on disk.
|
||||||
|
fn restart(&self) -> Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The real unit, through `systemctl`.
|
||||||
|
struct Systemd(String);
|
||||||
|
|
||||||
|
impl Homeserver for Systemd {
|
||||||
|
fn started_us(&self) -> Result<Option<u64>> {
|
||||||
|
let out = std::process::Command::new("systemctl")
|
||||||
|
.args([
|
||||||
|
"show",
|
||||||
|
"--timestamp=us+utc",
|
||||||
|
"-p",
|
||||||
|
"ActiveEnterTimestamp",
|
||||||
|
"--value",
|
||||||
|
])
|
||||||
|
.arg(&self.0)
|
||||||
|
.output()
|
||||||
|
.context("running systemctl show")?;
|
||||||
|
if !out.status.success() {
|
||||||
|
bail!("systemctl show {} exited with {}", self.0, out.status);
|
||||||
|
}
|
||||||
|
parse_timestamp_us(String::from_utf8_lossy(&out.stdout).trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restart(&self) -> Result<()> {
|
||||||
|
restart_via(&self.0, systemctl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restart the homeserver when the registration on disk is newer than its last
|
||||||
|
/// start. Derived from the files and the unit on every run rather than from
|
||||||
|
/// what this run wrote, so a run that failed after writing is retried by the
|
||||||
|
/// next. A start time that cannot be read leaves the unit as it is: the next
|
||||||
|
/// run decides again.
|
||||||
|
fn restart_if_stale(unit: &impl Homeserver, registration: &Path) -> Result<()> {
|
||||||
|
let started = match unit.started_us() {
|
||||||
|
Ok(started) => started.unwrap_or(0),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(error = %e, "cannot tell when the homeserver last started; leaving it as it is");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let written = std::fs::metadata(registration)
|
||||||
|
.and_then(|m| m.modified())
|
||||||
|
.with_context(|| format!("reading the time of {}", registration.display()))?
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.context("the registration is dated before the epoch")?
|
||||||
|
.as_micros();
|
||||||
|
if written <= u128::from(started) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
tracing::info!("the registration is newer than the homeserver's start; restarting it");
|
||||||
|
unit.restart()
|
||||||
|
.context("restarting the homeserver onto the new registration")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `systemctl systemd-timestamp` output (`--timestamp=us+utc`, for example
|
||||||
|
/// `Tue 2026-10-27 12:00:00.123456 UTC`) as microseconds since the epoch. Empty
|
||||||
|
/// is `None`: a unit that never became active has no timestamp.
|
||||||
|
fn parse_timestamp_us(s: &str) -> Result<Option<u64>> {
|
||||||
|
if s.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let bad = || anyhow::anyhow!("unreadable systemd timestamp {s:?}");
|
||||||
|
let token = |sep: char| {
|
||||||
|
s.split_whitespace()
|
||||||
|
.find(|t| t.contains(sep))
|
||||||
|
.ok_or_else(bad)
|
||||||
|
};
|
||||||
|
let numbers = |text: &str, sep: char| -> Result<Vec<i64>> {
|
||||||
|
text.split(sep)
|
||||||
|
.map(|n| n.parse().map_err(|_| bad()))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
let time = token(':')?;
|
||||||
|
let (clock, fraction) = time.split_once('.').unwrap_or((time, ""));
|
||||||
|
let [year, month, day] = numbers(token('-')?, '-')?[..] else {
|
||||||
|
return Err(bad());
|
||||||
|
};
|
||||||
|
let [hour, minute, second] = numbers(clock, ':')?[..] else {
|
||||||
|
return Err(bad());
|
||||||
|
};
|
||||||
|
let micros: i64 = format!("{fraction:0<6}")[..6].parse().map_err(|_| bad())?;
|
||||||
|
// Days since 1970-01-01 of a proleptic Gregorian date.
|
||||||
|
let year = if month <= 2 { year - 1 } else { year };
|
||||||
|
let era = year.div_euclid(400);
|
||||||
|
let year_of_era = year - era * 400;
|
||||||
|
let day_of_year = (153 * ((month + 9) % 12) + 2) / 5 + day - 1;
|
||||||
|
let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year;
|
||||||
|
let days = era * 146_097 + day_of_era - 719_468;
|
||||||
|
let seconds = days * 86_400 + hour * 3600 + minute * 60 + second;
|
||||||
|
u64::try_from(seconds * 1_000_000 + micros)
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|_| bad())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run `systemctl` with `args`, returning its exit status.
|
||||||
|
fn systemctl(args: &[&str]) -> Result<std::process::ExitStatus> {
|
||||||
|
std::process::Command::new("systemctl")
|
||||||
|
.args(args)
|
||||||
|
.status()
|
||||||
|
.with_context(|| format!("running systemctl {}", args.join(" ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restart `unit` onto the new registration: a running one is restarted, and a
|
||||||
|
/// failed one — it may have failed for want of the file — is reset and started.
|
||||||
|
/// One stopped on purpose stays stopped. `--no-block`, so this run does not
|
||||||
|
/// wait on the homeserver's start. Every call but the `is-failed` probe must
|
||||||
|
/// exit zero.
|
||||||
|
fn restart_via(
|
||||||
|
unit: &str,
|
||||||
|
run: impl Fn(&[&str]) -> Result<std::process::ExitStatus>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let must = |args: &[&str]| {
|
||||||
|
let status = run(args)?;
|
||||||
|
if status.success() {
|
||||||
Ok(())
|
Ok(())
|
||||||
|
} else {
|
||||||
|
bail!("systemctl {} exited with {status}", args.join(" "))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if run(&["is-failed", "--quiet", unit])?.success() {
|
||||||
|
must(&["reset-failed", unit])?;
|
||||||
|
must(&["start", "--no-block", unit])
|
||||||
|
} else {
|
||||||
|
must(&["try-restart", "--no-block", unit])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::cell::{Cell, RefCell};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
const REGEX: &str = "^@[a-z0-9._=/-]+:t\\.local$";
|
const REGEX: &str = "^@[a-z0-9._=/-]+:t\\.local$";
|
||||||
|
|
@ -202,24 +460,152 @@ mod tests {
|
||||||
dir
|
dir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reg(tag: &str) -> Registration {
|
||||||
|
Registration {
|
||||||
|
as_token: format!("as-{tag}"),
|
||||||
|
hs_token: format!("hs-{tag}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A store in memory. `down` fails every call, as a sealed or unreachable
|
||||||
|
/// store does; `racer` is what another writer stores between this run's
|
||||||
|
/// read and its create.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Fake {
|
||||||
|
registration: RefCell<Option<Registration>>,
|
||||||
|
token: RefCell<Option<String>>,
|
||||||
|
down: bool,
|
||||||
|
racer: RefCell<Option<Registration>>,
|
||||||
|
creates: Cell<u32>,
|
||||||
|
token_writes: Cell<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Store for Fake {
|
||||||
|
async fn registration(&self) -> Result<Option<Registration>> {
|
||||||
|
if self.down {
|
||||||
|
bail!("the store is down");
|
||||||
|
}
|
||||||
|
Ok(self.registration.borrow().clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_registration(&self, r: &Registration) -> Result<()> {
|
||||||
|
self.creates.set(self.creates.get() + 1);
|
||||||
|
if let Some(winner) = self.racer.borrow_mut().take() {
|
||||||
|
*self.registration.borrow_mut() = Some(winner);
|
||||||
|
bail!("already created");
|
||||||
|
}
|
||||||
|
*self.registration.borrow_mut() = Some(r.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn controller_token(&self) -> Result<Option<matrix::Credential>> {
|
||||||
|
Ok(self.token.borrow().clone().map(|value| matrix::Credential {
|
||||||
|
value,
|
||||||
|
homeserver: None,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write_controller_token(&self, as_token: &str) -> Result<()> {
|
||||||
|
self.token_writes.set(self.token_writes.get() + 1);
|
||||||
|
*self.token.borrow_mut() = Some(as_token.to_owned());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the state dir as `render` leaves it for `r`.
|
||||||
|
fn host_with(dir: &Path, r: &Registration) {
|
||||||
|
write_secret(&dir.join(AS_TOKEN), &r.as_token).expect("as");
|
||||||
|
write_secret(&dir.join(HS_TOKEN), &r.hs_token).expect("hs");
|
||||||
|
write_secret(
|
||||||
|
&dir.join(REGISTRATION),
|
||||||
|
®istration_yaml("swarm", REGEX, &r.as_token, &r.hs_token),
|
||||||
|
)
|
||||||
|
.expect("yaml");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn files(dir: &Path) -> Vec<String> {
|
||||||
|
[AS_TOKEN, HS_TOKEN, REGISTRATION]
|
||||||
|
.iter()
|
||||||
|
.map(|f| std::fs::read_to_string(dir.join(f)).unwrap_or_default())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The homeserver unit. `started` is when it last became active; a restart
|
||||||
|
/// that succeeds makes it later than every file.
|
||||||
|
struct Unit {
|
||||||
|
started: Cell<Option<u64>>,
|
||||||
|
fail_restart: Cell<bool>,
|
||||||
|
restarts: Cell<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Unit {
|
||||||
|
/// Last started at the epoch, before any file in a test.
|
||||||
|
fn long_running() -> Self {
|
||||||
|
Self::started(Some(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Last started after every file in a test.
|
||||||
|
fn just_started() -> Self {
|
||||||
|
Self::started(Some(u64::MAX))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn started(at: Option<u64>) -> Self {
|
||||||
|
Self {
|
||||||
|
started: Cell::new(at),
|
||||||
|
fail_restart: Cell::new(false),
|
||||||
|
restarts: Cell::new(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Homeserver for Unit {
|
||||||
|
fn started_us(&self) -> Result<Option<u64>> {
|
||||||
|
Ok(self.started.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restart(&self) -> Result<()> {
|
||||||
|
self.restarts.set(self.restarts.get() + 1);
|
||||||
|
if self.fail_restart.get() {
|
||||||
|
bail!("systemctl refused");
|
||||||
|
}
|
||||||
|
self.started.set(Some(u64::MAX));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run one sync against `unit`, returning how often it minted.
|
||||||
|
async fn run(store: &Fake, dir: &Path, unit: &Unit) -> (Result<()>, u32) {
|
||||||
|
let mints = Cell::new(0);
|
||||||
|
let out = sync_with(
|
||||||
|
store,
|
||||||
|
dir,
|
||||||
|
"swarm",
|
||||||
|
REGEX,
|
||||||
|
|| {
|
||||||
|
mints.set(mints.get() + 1);
|
||||||
|
Ok(reg("minted"))
|
||||||
|
},
|
||||||
|
unit,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
(out, mints.get())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_rendered_registration_carries_the_token_it_minted() {
|
fn the_rendered_registration_carries_the_token_it_minted() {
|
||||||
let dir = scratch();
|
let dir = scratch();
|
||||||
render_into(&dir, "swarm", REGEX).expect("renders");
|
render_into(&dir, "swarm", REGEX).expect("renders");
|
||||||
let token = registration::as_token(&dir.join(REGISTRATION).to_string_lossy())
|
let yaml = std::fs::read_to_string(dir.join(REGISTRATION)).expect("rendered");
|
||||||
.expect("the as_token line parses");
|
let token = std::fs::read_to_string(dir.join(AS_TOKEN)).expect("minted");
|
||||||
assert_eq!(token.len(), TOKEN_BYTES * 2);
|
assert_eq!(token.len(), TOKEN_BYTES * 2);
|
||||||
assert_eq!(
|
assert!(yaml.contains(&format!("\nas_token: {token}\n")), "{yaml}");
|
||||||
std::fs::read_to_string(dir.join(AS_TOKEN)).expect("minted"),
|
|
||||||
token
|
|
||||||
);
|
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_second_render_keeps_the_token() {
|
fn a_second_render_keeps_the_token() {
|
||||||
// "Only once": a re-mint on every boot would hand the controller a
|
// "Only once": a re-mint on every boot would hand the controller a
|
||||||
// token the homeserver no longer loads until publish catches up.
|
// token the homeserver no longer loads until sync catches up.
|
||||||
let dir = scratch();
|
let dir = scratch();
|
||||||
render_into(&dir, "swarm", REGEX).expect("first");
|
render_into(&dir, "swarm", REGEX).expect("first");
|
||||||
let first = std::fs::read_to_string(dir.join(REGISTRATION)).expect("rendered");
|
let first = std::fs::read_to_string(dir.join(REGISTRATION)).expect("rendered");
|
||||||
|
|
@ -254,8 +640,288 @@ mod tests {
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn seeding_from_the_host_leaves_every_file_unchanged() {
|
||||||
|
// A host that has run for months with the store empty: the store takes
|
||||||
|
// its value, and nothing on the host moves, so nothing rotates.
|
||||||
|
let dir = scratch();
|
||||||
|
let on_disk = reg("old");
|
||||||
|
host_with(&dir, &on_disk);
|
||||||
|
let before = files(&dir);
|
||||||
|
let store = Fake::default();
|
||||||
|
|
||||||
|
let unit = Unit::just_started();
|
||||||
|
let (out, mints) = run(&store, &dir, &unit).await;
|
||||||
|
let restarts = unit.restarts.get();
|
||||||
|
|
||||||
|
out.expect("syncs");
|
||||||
|
assert_eq!(*store.registration.borrow(), Some(on_disk));
|
||||||
|
assert_eq!(files(&dir), before);
|
||||||
|
assert_eq!(mints, 0, "a host that has the value mints nothing");
|
||||||
|
assert_eq!(restarts, 0, "an unchanged host does not restart");
|
||||||
|
assert_eq!(store.token.borrow().as_deref(), Some("as-old"));
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_store_wins_when_both_hold_a_value() {
|
||||||
|
let dir = scratch();
|
||||||
|
host_with(&dir, ®("disk"));
|
||||||
|
let store = Fake::default();
|
||||||
|
*store.registration.borrow_mut() = Some(reg("store"));
|
||||||
|
*store.token.borrow_mut() = Some("as-disk".to_owned());
|
||||||
|
|
||||||
|
let unit = Unit::long_running();
|
||||||
|
let (out, _) = run(&store, &dir, &unit).await;
|
||||||
|
let restarts = unit.restarts.get();
|
||||||
|
|
||||||
|
out.expect("syncs");
|
||||||
|
let want = registration_yaml("swarm", REGEX, "as-store", "hs-store");
|
||||||
|
assert_eq!(
|
||||||
|
files(&dir),
|
||||||
|
vec!["as-store".to_owned(), "hs-store".to_owned(), want]
|
||||||
|
);
|
||||||
|
assert_eq!(*store.registration.borrow(), Some(reg("store")));
|
||||||
|
assert_eq!(store.creates.get(), 0, "a held value is never rewritten");
|
||||||
|
assert_eq!(restarts, 1);
|
||||||
|
assert_eq!(store.token.borrow().as_deref(), Some("as-store"));
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_host_without_the_files_takes_them_from_the_store() {
|
||||||
|
let dir = scratch();
|
||||||
|
let store = Fake::default();
|
||||||
|
*store.registration.borrow_mut() = Some(reg("store"));
|
||||||
|
|
||||||
|
let unit = Unit::long_running();
|
||||||
|
let (out, mints) = run(&store, &dir, &unit).await;
|
||||||
|
let restarts = unit.restarts.get();
|
||||||
|
|
||||||
|
out.expect("syncs");
|
||||||
|
assert_eq!(read_local(&dir).expect("reads"), Some(reg("store")));
|
||||||
|
assert!(dir.join(REGISTRATION).exists());
|
||||||
|
assert_eq!((restarts, mints), (1, 0));
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_fresh_host_mints_into_the_store() {
|
||||||
|
let dir = scratch();
|
||||||
|
let store = Fake::default();
|
||||||
|
|
||||||
|
let unit = Unit::long_running();
|
||||||
|
let (out, mints) = run(&store, &dir, &unit).await;
|
||||||
|
let restarts = unit.restarts.get();
|
||||||
|
|
||||||
|
out.expect("syncs");
|
||||||
|
assert_eq!(mints, 1);
|
||||||
|
assert_eq!(*store.registration.borrow(), Some(reg("minted")));
|
||||||
|
assert_eq!(read_local(&dir).expect("reads"), Some(reg("minted")));
|
||||||
|
assert_eq!(restarts, 1);
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_lost_race_adopts_the_winners_registration() {
|
||||||
|
let dir = scratch();
|
||||||
|
host_with(&dir, ®("disk"));
|
||||||
|
let store = Fake::default();
|
||||||
|
*store.racer.borrow_mut() = Some(reg("winner"));
|
||||||
|
|
||||||
|
let unit = Unit::long_running();
|
||||||
|
let (out, _) = run(&store, &dir, &unit).await;
|
||||||
|
let restarts = unit.restarts.get();
|
||||||
|
|
||||||
|
out.expect("syncs");
|
||||||
|
assert_eq!(read_local(&dir).expect("reads"), Some(reg("winner")));
|
||||||
|
assert_eq!(restarts, 1);
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_store_that_is_down_leaves_the_host_as_it_is() {
|
||||||
|
let dir = scratch();
|
||||||
|
host_with(&dir, ®("disk"));
|
||||||
|
let before = files(&dir);
|
||||||
|
let store = Fake {
|
||||||
|
down: true,
|
||||||
|
..Fake::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let unit = Unit::just_started();
|
||||||
|
let (out, mints) = run(&store, &dir, &unit).await;
|
||||||
|
let restarts = unit.restarts.get();
|
||||||
|
|
||||||
|
assert!(out.is_err(), "the unit reports the outage");
|
||||||
|
assert_eq!(files(&dir), before, "the homeserver keeps the last copy");
|
||||||
|
assert_eq!((restarts, mints), (0, 0));
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_start_path_renders_the_copy_sync_took_from_the_store() {
|
||||||
|
// `render` has no store: after an outage it renders what the last sync
|
||||||
|
// wrote, without minting over it.
|
||||||
|
let dir = scratch();
|
||||||
|
let store = Fake::default();
|
||||||
|
*store.registration.borrow_mut() = Some(reg("store"));
|
||||||
|
run(&store, &dir, &Unit::long_running())
|
||||||
|
.await
|
||||||
|
.0
|
||||||
|
.expect("syncs");
|
||||||
|
let synced = files(&dir);
|
||||||
|
|
||||||
|
render_into(&dir, "swarm", REGEX).expect("renders");
|
||||||
|
|
||||||
|
assert_eq!(files(&dir), synced);
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_second_sync_restarts_nothing() {
|
||||||
|
let dir = scratch();
|
||||||
|
let store = Fake::default();
|
||||||
|
*store.registration.borrow_mut() = Some(reg("store"));
|
||||||
|
|
||||||
|
let unit = Unit::long_running();
|
||||||
|
|
||||||
|
run(&store, &dir, &unit).await.0.expect("first");
|
||||||
|
let first = unit.restarts.get();
|
||||||
|
let (out, _) = run(&store, &dir, &unit).await;
|
||||||
|
let second = unit.restarts.get() - first;
|
||||||
|
|
||||||
|
out.expect("syncs");
|
||||||
|
assert_eq!((first, second), (1, 0));
|
||||||
|
assert_eq!(
|
||||||
|
store.token_writes.get(),
|
||||||
|
1,
|
||||||
|
"the controller's copy is written once"
|
||||||
|
);
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_restart_that_failed_is_retried_by_the_next_run() {
|
||||||
|
// The files are written and the restart is refused. The next run finds
|
||||||
|
// nothing to write, and still restarts: the decision comes from the
|
||||||
|
// files' age against the unit's start, not from this run's writes.
|
||||||
|
let dir = scratch();
|
||||||
|
let store = Fake::default();
|
||||||
|
*store.registration.borrow_mut() = Some(reg("store"));
|
||||||
|
let unit = Unit::long_running();
|
||||||
|
unit.fail_restart.set(true);
|
||||||
|
|
||||||
|
let (first, _) = run(&store, &dir, &unit).await;
|
||||||
|
assert!(first.is_err(), "the refused restart fails the run");
|
||||||
|
let written = files(&dir);
|
||||||
|
|
||||||
|
unit.fail_restart.set(false);
|
||||||
|
let (second, _) = run(&store, &dir, &unit).await;
|
||||||
|
|
||||||
|
second.expect("the retry succeeds");
|
||||||
|
assert_eq!(files(&dir), written, "nothing was left to write");
|
||||||
|
assert_eq!(unit.restarts.get(), 2);
|
||||||
|
assert_eq!(read_local(&dir).expect("reads"), Some(reg("store")));
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_unit_that_started_after_the_files_is_left_alone() {
|
||||||
|
// The control for the retry above: the same files, a unit that started
|
||||||
|
// after them, and no restart.
|
||||||
|
let dir = scratch();
|
||||||
|
host_with(&dir, ®("store"));
|
||||||
|
let store = Fake::default();
|
||||||
|
*store.registration.borrow_mut() = Some(reg("store"));
|
||||||
|
let unit = Unit::just_started();
|
||||||
|
|
||||||
|
run(&store, &dir, &unit).await.0.expect("syncs");
|
||||||
|
|
||||||
|
assert_eq!(unit.restarts.get(), 0);
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_start_time_that_cannot_be_read_restarts_nothing() {
|
||||||
|
struct Unreadable(Cell<u32>);
|
||||||
|
impl Homeserver for Unreadable {
|
||||||
|
fn started_us(&self) -> Result<Option<u64>> {
|
||||||
|
bail!("no such unit")
|
||||||
|
}
|
||||||
|
fn restart(&self) -> Result<()> {
|
||||||
|
self.0.set(self.0.get() + 1);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let dir = scratch();
|
||||||
|
let store = Fake::default();
|
||||||
|
*store.registration.borrow_mut() = Some(reg("store"));
|
||||||
|
let unit = Unreadable(Cell::new(0));
|
||||||
|
|
||||||
|
let out = sync_with(&store, &dir, "swarm", REGEX, || Ok(reg("minted")), &unit).await;
|
||||||
|
|
||||||
|
out.expect("the run goes on");
|
||||||
|
assert_eq!(unit.0.get(), 0);
|
||||||
|
assert_eq!(store.token.borrow().as_deref(), Some("as-store"));
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `restart_via` against a `systemctl` that exits with the given codes for the
|
||||||
|
/// verbs named (zero for the rest), returning the verbs it was called with.
|
||||||
|
fn restart_script(exits: &[(&'static str, i32)]) -> (Result<()>, Vec<String>) {
|
||||||
|
use std::os::unix::process::ExitStatusExt as _;
|
||||||
|
let calls = RefCell::new(Vec::new());
|
||||||
|
let out = restart_via("tuwunel.service", |args| {
|
||||||
|
calls.borrow_mut().push(args[0].to_owned());
|
||||||
|
let code = exits.iter().find(|(v, _)| *v == args[0]).map_or(0, |e| e.1);
|
||||||
|
Ok(std::process::ExitStatus::from_raw(code << 8))
|
||||||
|
});
|
||||||
|
(out, calls.into_inner())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn publish_writes_only_what_the_store_lacks() {
|
fn a_running_unit_is_restarted_and_a_refusal_fails_the_run() {
|
||||||
|
let (out, calls) = restart_script(&[("is-failed", 1)]);
|
||||||
|
out.expect("restarts");
|
||||||
|
assert_eq!(calls, ["is-failed", "try-restart"]);
|
||||||
|
|
||||||
|
let (out, _) = restart_script(&[("is-failed", 1), ("try-restart", 1)]);
|
||||||
|
assert!(out.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failed_unit_is_reset_and_started_and_a_refusal_of_either_fails_the_run() {
|
||||||
|
let (out, calls) = restart_script(&[]);
|
||||||
|
out.expect("resets and starts");
|
||||||
|
assert_eq!(calls, ["is-failed", "reset-failed", "start"]);
|
||||||
|
|
||||||
|
let (out, calls) = restart_script(&[("reset-failed", 1)]);
|
||||||
|
assert!(out.is_err(), "a refused reset fails the run");
|
||||||
|
assert_eq!(calls, ["is-failed", "reset-failed"], "and nothing starts");
|
||||||
|
|
||||||
|
let (out, _) = restart_script(&[("start", 1)]);
|
||||||
|
assert!(out.is_err(), "a refused start fails the run");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn systemd_timestamps_read_as_microseconds_since_the_epoch() {
|
||||||
|
let at = |s| parse_timestamp_us(s).expect("parses");
|
||||||
|
assert_eq!(at("Thu 1970-01-01 00:00:01.000002 UTC"), Some(1_000_002));
|
||||||
|
assert_eq!(
|
||||||
|
at("Wed 2000-03-01 00:00:00.5 UTC"),
|
||||||
|
Some(951_868_800_500_000)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
at("Tue 2026-10-27 12:00:00 UTC"),
|
||||||
|
Some(1_793_102_400_000_000)
|
||||||
|
);
|
||||||
|
assert_eq!(at(""), None, "a unit that never started has none");
|
||||||
|
parse_timestamp_us("n/a garbage").expect_err("not a timestamp");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_controllers_copy_is_written_only_when_it_differs() {
|
||||||
let same = matrix::Credential {
|
let same = matrix::Credential {
|
||||||
value: "aa".to_owned(),
|
value: "aa".to_owned(),
|
||||||
homeserver: None,
|
homeserver: None,
|
||||||
|
|
@ -271,7 +937,13 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_variable_is_scoped_to_the_verb() {
|
fn every_variable_is_scoped_to_the_verb() {
|
||||||
for var in [ENV_DIR, ENV_SENDER, ENV_USER_REGEX, ENV_CERT_ROLE] {
|
for var in [
|
||||||
|
ENV_DIR,
|
||||||
|
ENV_SENDER,
|
||||||
|
ENV_USER_REGEX,
|
||||||
|
ENV_CERT_ROLE,
|
||||||
|
ENV_RESTART_UNIT,
|
||||||
|
] {
|
||||||
assert!(var.starts_with("MATRIX_APPSERVICE_"), "{var}");
|
assert!(var.starts_with("MATRIX_APPSERVICE_"), "{var}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,13 @@
|
||||||
//! here is a verb below, not a new crate.
|
//! here is a verb below, not a new crate.
|
||||||
//!
|
//!
|
||||||
//! [`appservice`] mints the **swarm's** own appservice registration and
|
//! [`appservice`] mints the **swarm's** own appservice registration and
|
||||||
//! publishes its token for `swarm-controller`.
|
//! keeps it in step with the store and publishes its token for `swarm-controller`.
|
||||||
//!
|
//!
|
||||||
//! 🩸 **A secret is a path, never a value.** The only identifier any verb here
|
//! 🩸 **A secret is a path, never a value.** The only identifier any verb here
|
||||||
//! logs is the store path; see `swarm_matrix_client`'s module doc for the same rule
|
//! logs is the store path; see `swarm_matrix_client`'s module doc for the same rule
|
||||||
//! applied to error messages.
|
//! applied to error messages.
|
||||||
|
|
||||||
mod appservice;
|
mod appservice;
|
||||||
mod registration;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
|
|
@ -43,9 +42,10 @@ enum Appservice {
|
||||||
/// Mint the tokens when absent and render the registration tuwunel loads.
|
/// Mint the tokens when absent and render the registration tuwunel loads.
|
||||||
/// Local only: it runs before the homeserver and must not need a network.
|
/// Local only: it runs before the homeserver and must not need a network.
|
||||||
Render,
|
Render,
|
||||||
/// Write the rendered `as_token` to the swarm secret store when the
|
/// Make the state dir hold the store's registration, seeding the store
|
||||||
/// store's copy differs.
|
/// from it when the store holds none, and restart the homeserver when the
|
||||||
Publish,
|
/// registration is newer than its last start. Runs beside the homeserver, never before it.
|
||||||
|
Sync,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
|
|
@ -59,7 +59,7 @@ async fn main() -> Result<()> {
|
||||||
|
|
||||||
match Cli::parse().command {
|
match Cli::parse().command {
|
||||||
Command::Appservice(Appservice::Render) => appservice::render(),
|
Command::Appservice(Appservice::Render) => appservice::render(),
|
||||||
Command::Appservice(Appservice::Publish) => appservice::publish().await,
|
Command::Appservice(Appservice::Sync) => appservice::sync().await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -83,12 +83,8 @@ mod tests {
|
||||||
cli.command,
|
cli.command,
|
||||||
Command::Appservice(Appservice::Render)
|
Command::Appservice(Appservice::Render)
|
||||||
));
|
));
|
||||||
let cli =
|
let cli = Cli::try_parse_from(["swarm-matrix-ctl", "appservice", "sync"]).expect("a verb");
|
||||||
Cli::try_parse_from(["swarm-matrix-ctl", "appservice", "publish"]).expect("a verb");
|
assert!(matches!(cli.command, Command::Appservice(Appservice::Sync)));
|
||||||
assert!(matches!(
|
|
||||||
cli.command,
|
|
||||||
Command::Appservice(Appservice::Publish)
|
|
||||||
));
|
|
||||||
Cli::try_parse_from(["swarm-matrix-ctl", "appservice"])
|
Cli::try_parse_from(["swarm-matrix-ctl", "appservice"])
|
||||||
.expect_err("a sub-verb is required");
|
.expect_err("a sub-verb is required");
|
||||||
}
|
}
|
||||||
|
|
@ -99,7 +95,7 @@ mod tests {
|
||||||
.expect_err("only declared verbs are accepted");
|
.expect_err("only declared verbs are accepted");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A bare invocation must not silently do something. `appservice publish`
|
/// A bare invocation must not silently do something. `appservice sync`
|
||||||
/// writes a credential, so "no verb" defaulting to it would make a typo in
|
/// writes a credential, so "no verb" defaulting to it would make a typo in
|
||||||
/// the unit write rather than fail.
|
/// the unit write rather than fail.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
//! Reading the `as_token` out of the appservice registration the matrix
|
|
||||||
//! container already has.
|
|
||||||
//!
|
|
||||||
//! No new credential is delivered for this. The registration **is** the
|
|
||||||
//! `as_token`, and `appservice render` writes it into this container's own
|
|
||||||
//! state dir — so the file this module opens is one this process could already
|
|
||||||
//! read, and one the homeserver beside it reads too.
|
|
||||||
//!
|
|
||||||
//! Scanned line-by-line rather than parsed as YAML. The file has exactly one
|
|
||||||
//! renderer (`registration_yaml` in `appservice.rs`, a `format!` of
|
|
||||||
//! `as_token: <hex>`), so a parser would be a second, looser reading of a shape
|
|
||||||
//! this repo writes itself — and it would pull a YAML crate into a binary whose
|
|
||||||
//! only other input is JSON.
|
|
||||||
|
|
||||||
use anyhow::{Context, Result, bail};
|
|
||||||
|
|
||||||
/// The key the token is stored under, and the whole of the agreement with the
|
|
||||||
/// renderer.
|
|
||||||
const KEY: &str = "as_token:";
|
|
||||||
|
|
||||||
/// Read the registration at `path` and return its `as_token`.
|
|
||||||
///
|
|
||||||
/// # Errors
|
|
||||||
/// When the file cannot be read, or holds no `as_token` with a value — which is
|
|
||||||
/// what a registration rendered by something other than this repo looks like
|
|
||||||
/// from here.
|
|
||||||
pub fn as_token(path: &str) -> Result<String> {
|
|
||||||
let text = std::fs::read_to_string(path)
|
|
||||||
.with_context(|| format!("reading the appservice registration at {path}"))?;
|
|
||||||
// The path, not the file's contents: every line of it is either a secret or
|
|
||||||
// a shape this module already knows.
|
|
||||||
extract(&text).with_context(|| format!("no `as_token` in the registration at {path}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// [`as_token`] over text already in hand, so the agreement with the renderer
|
|
||||||
/// can be tested without a file.
|
|
||||||
fn extract(text: &str) -> Result<String> {
|
|
||||||
for line in text.lines() {
|
|
||||||
if let Some(rest) = line.strip_prefix(KEY) {
|
|
||||||
let token = rest.trim();
|
|
||||||
if token.is_empty() {
|
|
||||||
bail!("the registration's `as_token` is empty");
|
|
||||||
}
|
|
||||||
return Ok(token.to_owned());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bail!("the registration carries no `as_token` line")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// The registration in the shape `registration_yaml` renders it, tokens
|
|
||||||
/// last. Reproduced rather than rendered so this module's tests do not
|
|
||||||
/// depend on the renderer's.
|
|
||||||
const RENDERED: &str = "id: swarm\n\
|
|
||||||
url: null\n\
|
|
||||||
sender_localpart: swarm\n\
|
|
||||||
rate_limited: false\n\
|
|
||||||
namespaces:\n \
|
|
||||||
users:\n \
|
|
||||||
- exclusive: false\n \
|
|
||||||
regex: '@[a-z0-9._=/+-]+:example\\.test$'\n \
|
|
||||||
aliases: []\n \
|
|
||||||
rooms: []\n\
|
|
||||||
as_token: deadbeef\n\
|
|
||||||
hs_token: cafebabe\n";
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_token_is_taken_from_the_registration_this_repo_renders() {
|
|
||||||
assert_eq!(
|
|
||||||
extract(RENDERED).expect("the rendered shape parses"),
|
|
||||||
"deadbeef"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn the_homeservers_own_token_is_not_mistaken_for_the_appservices() {
|
|
||||||
// `hs_token` authenticates the homeserver TO the appservice and is a
|
|
||||||
// different secret with a confusingly similar name; a substring search
|
|
||||||
// would find it inside neither, but a `contains("s_token")`-shaped one
|
|
||||||
// would. The control is that the line order in `RENDERED` puts
|
|
||||||
// `as_token` first, so this arm needs the reverse to mean anything.
|
|
||||||
let reversed = "hs_token: cafebabe\nas_token: deadbeef\n";
|
|
||||||
assert_eq!(
|
|
||||||
extract(reversed).expect("order does not matter"),
|
|
||||||
"deadbeef"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_registration_with_no_token_is_an_error_rather_than_an_empty_string() {
|
|
||||||
// An empty token authenticates nothing, and a homeserver answers a
|
|
||||||
// request carrying one with a 403 that names the account rather than
|
|
||||||
// the credential — so failing here is the only report an operator can
|
|
||||||
// act on.
|
|
||||||
for bad in ["id: swarm\n", "as_token:\n", "as_token: \n"] {
|
|
||||||
assert!(extract(bad).is_err(), "{bad:?} must not yield a token");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -129,6 +129,44 @@ pub fn swarm_appservice_token_path() -> Result<String, Error> {
|
||||||
Ok(format!("{prefix}/matrix/appservice-token"))
|
Ok(format!("{prefix}/matrix/appservice-token"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The path holding the swarm appservice's registration: the `as_token` and
|
||||||
|
/// `hs_token` the homeserver loads.
|
||||||
|
///
|
||||||
|
/// The store is the source of truth for both tokens. The matrix container keeps a
|
||||||
|
/// copy on disk that the homeserver starts from, and derives
|
||||||
|
/// [`swarm_appservice_token_path`] from the `as_token` here for
|
||||||
|
/// `swarm-controller`. It sits beside that path, under the one kind no hive's
|
||||||
|
/// policy reads, for the same reason.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Never in practice: the name segment is a constant. The `Result` is
|
||||||
|
/// [`principal_prefix`]'s.
|
||||||
|
pub fn swarm_appservice_registration_path() -> Result<String, Error> {
|
||||||
|
let prefix = principal_prefix(Kind::Controller, CONTROLLER)?;
|
||||||
|
Ok(format!("{prefix}/matrix/appservice-registration"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What [`swarm_appservice_registration_path`] holds. The registration's
|
||||||
|
/// namespaces are configuration rendered by nix, not part of it.
|
||||||
|
///
|
||||||
|
/// No `Debug` derive: both fields authenticate to the homeserver.
|
||||||
|
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Registration {
|
||||||
|
/// The token the appservice presents to the homeserver.
|
||||||
|
pub as_token: String,
|
||||||
|
/// The token the homeserver presents to the appservice.
|
||||||
|
pub hs_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for Registration {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("Registration")
|
||||||
|
.field("as_token", &"<redacted>")
|
||||||
|
.field("hs_token", &"<redacted>")
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// What an account's path holds: the token, plus the homeserver it belongs to.
|
/// What an account's path holds: the token, plus the homeserver it belongs to.
|
||||||
///
|
///
|
||||||
/// The homeserver rides with the token because the agent's daemon learns both
|
/// The homeserver rides with the token because the agent's daemon learns both
|
||||||
|
|
@ -266,6 +304,28 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_swarm_appservice_registration_lands_beside_its_token() {
|
||||||
|
assert_eq!(
|
||||||
|
swarm_appservice_registration_path().expect("a constant segment"),
|
||||||
|
"swarm/controller/swarm-controller/matrix/appservice-registration"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_registration_debug_prints_no_token() {
|
||||||
|
let r = Registration {
|
||||||
|
as_token: "as-secret".to_owned(),
|
||||||
|
hs_token: "hs-secret".to_owned(),
|
||||||
|
};
|
||||||
|
let shown = format!("{r:?}");
|
||||||
|
assert!(
|
||||||
|
!shown.contains("as-secret") && !shown.contains("hs-secret"),
|
||||||
|
"{shown}"
|
||||||
|
);
|
||||||
|
assert!(shown.contains("Registration"), "{shown}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_hive_policy_reaches_the_swarm_appservice_token() {
|
fn no_hive_policy_reaches_the_swarm_appservice_token() {
|
||||||
// 🩸 The swarm's sender is a homeserver admin, so its token must not
|
// 🩸 The swarm's sender is a homeserver admin, so its token must not
|
||||||
|
|
@ -280,8 +340,12 @@ mod tests {
|
||||||
.filter_map(|p| p.strip_suffix("*\" {"))
|
.filter_map(|p| p.strip_suffix("*\" {"))
|
||||||
.collect();
|
.collect();
|
||||||
let reads = |path: &str| granted.iter().any(|g| path.starts_with(g));
|
let reads = |path: &str| granted.iter().any(|g| path.starts_with(g));
|
||||||
let path = swarm_appservice_token_path().expect("a constant segment");
|
for path in [
|
||||||
|
swarm_appservice_token_path().expect("a constant segment"),
|
||||||
|
swarm_appservice_registration_path().expect("a constant segment"),
|
||||||
|
] {
|
||||||
assert!(!reads(&path), "{path} is readable under {granted:?}");
|
assert!(!reads(&path), "{path} is readable under {granted:?}");
|
||||||
|
}
|
||||||
// The control: an agent's account path IS under a hive stanza, so the
|
// The control: an agent's account path IS under a hive stanza, so the
|
||||||
// assertion above can fail at all.
|
// assertion above can fail at all.
|
||||||
let agent = account_path("atlas", "main").expect("legal");
|
let agent = account_path("atlas", "main").expect("legal");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue