swarm options: no swarm.* value depends on a deploy option or this host's hive
- swarm.serviceDomains lists every swarm service name on every host. The
store's issuing role is built from it, so the store now admits a leaf
for grafana, metrics, otel, logs, the bao UI and the apex whichever host
runs the store. Each gateway's own leaf stays narrowed by
localServiceDomains.
- swarm.matrix.apiUrl defaults to http://<swarm.matrix.gatewayHost>
(null when gatewayHost is null) instead of loopback on the matrix host.
- The authelia vhost proxies /bridge/ to swarm-authelia-bridge with the
prefix stripped, and deploy.swarm-controller.authBridgeUrl defaults to
${swarm.authelia.url}/bridge on every host. swarm.authelia.bridgeUrl is
removed.
- swarm-authelia-bridge admits only swarm-controller's OIDC client on
every request (403 for any other active token), configured by
SWARM_AUTHELIA_BRIDGE_CONTROLLER_CLIENT_ID (renamed from
..._OPERATOR_READER_CLIENT_ID, which gated ListOperators alone).
- swarm.peerHives is removed; swarm-wireguard.nix filters this host's own
hive out of swarm.hives itself.
Closes #4933
This commit is contained in:
parent
207b089498
commit
bed4358e19
21 changed files with 226 additions and 283 deletions
|
|
@ -82,10 +82,10 @@ pin the value before the homeserver mints any ids under the default.
|
||||||
|
|
||||||
## Default-closed firewall
|
## Default-closed firewall
|
||||||
|
|
||||||
`deploy.matrix.openFirewall` defaults to `false`: the host
|
`deploy.matrix.openFirewall` defaults to `false`: hive-c0re and agent
|
||||||
reaches the homeserver on loopback, and agent containers reach it
|
containers on every hive reach the homeserver at `chat.<swarm-domain>`
|
||||||
at `chat.<swarm-domain>` via the gateway — so the firewall hole only
|
via the gateway, so the firewall hole only matters for direct access to
|
||||||
matters for access from *outside* the host. Flip to `true` when
|
`httpPort` from *outside* the host. Flip to `true` when
|
||||||
announcing the homeserver to other hives or when an external matrix
|
announcing the homeserver to other hives or when an external matrix
|
||||||
client needs to reach the client-server API directly.
|
client needs to reach the client-server API directly.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ You rarely switch it on yourself. `gateway.enable` defaults to off, and every mo
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `<swarm>/` | swarm-ui dist (static), behind an authelia subrequest | `swarm-ui.nix`, `deploy.swarm-ui.enable` |
|
| `<swarm>/` | swarm-ui dist (static), behind an authelia subrequest | `swarm-ui.nix`, `deploy.swarm-ui.enable` |
|
||||||
| `auth.<swarm>/` | authelia (`9091`) | `swarm-authelia.nix`, `deploy.authelia.enable` |
|
| `auth.<swarm>/` | authelia (`9091`) | `swarm-authelia.nix`, `deploy.authelia.enable` |
|
||||||
|
| `auth.<swarm>/bridge/*` | swarm-authelia-bridge (`9092`), prefix stripped; answers swarm-controller's client only | `swarm-authelia.nix`, `deploy.authelia.enable` |
|
||||||
| `forge.<swarm>/` | forgejo (`3000`) | `hive-forge/`, `deploy.forgejo.enable` |
|
| `forge.<swarm>/` | forgejo (`3000`) | `hive-forge/`, `deploy.forgejo.enable` |
|
||||||
| `chat.<swarm>/_matrix/*` | tuwunel (`8008`) | `hive-matrix.nix`, `swarm.matrix.gatewayHost != null` |
|
| `chat.<swarm>/_matrix/*` | tuwunel (`8008`) | `hive-matrix.nix`, `swarm.matrix.gatewayHost != null` |
|
||||||
| `chat.<swarm>/` | fluffychat-web static | `hive-matrix.nix`, `swarm.matrix.gatewayHost != null` |
|
| `chat.<swarm>/` | fluffychat-web static | `hive-matrix.nix`, `swarm.matrix.gatewayHost != null` |
|
||||||
|
|
|
||||||
|
|
@ -472,9 +472,9 @@ services.hyperhive = {
|
||||||
host's routing table).
|
host's routing table).
|
||||||
- It opens UDP port 51820 (or `listenPort`) on the host firewall.
|
- It opens UDP port 51820 (or `listenPort`) on the host firewall.
|
||||||
- `swarm-wireguard.nix` reads each entry's `wireguardAddress` directly
|
- `swarm-wireguard.nix` reads each entry's `wireguardAddress` directly
|
||||||
from `services.hyperhive.swarm.peerHives` to build `wg-hive`'s
|
from `services.hyperhive.swarm.hives`, minus this host's own hive, to
|
||||||
`allowedIPs`, so intra-swarm traffic can route over the mesh address
|
build `wg-hive`'s `allowedIPs`, so intra-swarm traffic can route over
|
||||||
rather than the public domain.
|
the mesh address rather than the public domain.
|
||||||
- It sets `persistentKeepalive = 25` by default; override or null to
|
- It sets `persistentKeepalive = 25` by default; override or null to
|
||||||
disable (not needed when both sides have public IPs and no NAT).
|
disable (not needed when both sides have public IPs and no NAT).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,8 @@ copies from. Every hive does this with its own identity, so holding the
|
||||||
swarm root's private key stopped being what decides whether a hive can
|
swarm root's private key stopped being what decides whether a hive can
|
||||||
serve its swarm's names.
|
serve its swarm's names.
|
||||||
|
|
||||||
`services.hyperhive.swarm.serviceDomains` populates the role's
|
`services.hyperhive.swarm.serviceDomains`, every swarm service name and the
|
||||||
|
same list on every host, populates the role's
|
||||||
`allowed_domains`, so asking for a name nobody configured is a refusal
|
`allowed_domains`, so asking for a name nobody configured is a refusal
|
||||||
from the store naming that name — not a certificate quietly issued for
|
from the store naming that name — not a certificate quietly issued for
|
||||||
it. A host's own SANs are narrower still: it asks only for the names it
|
it. A host's own SANs are narrower still: it asks only for the names it
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,14 @@ there is one IdP and one auth path.
|
||||||
Agent subjects come from swarm-controller's agent-creation job, written
|
Agent subjects come from swarm-controller's agent-creation job, written
|
||||||
into the users database by `swarm-authelia-bridge`; human ones come from
|
into the users database by `swarm-authelia-bridge`; human ones come from
|
||||||
`swarmctl user add` → [setup.md § 2](../getting-started/setup.md#2--your-sso-account).
|
`swarmctl user add` → [setup.md § 2](../getting-started/setup.md#2--your-sso-account).
|
||||||
|
|
||||||
|
The bridge listens on loopback on the authelia host, and the gateway
|
||||||
|
publishes it as
|
||||||
|
`https://<swarm.authelia.domain>/bridge/`, which is where
|
||||||
|
`deploy.swarm-controller.authBridgeUrl` points by default on every host.
|
||||||
|
It answers only swarm-controller's OIDC client
|
||||||
|
(`swarm.controller.queueClientId`) and refuses every other token `403`.
|
||||||
|
|
||||||
On first boot this module seeds the users database with a disabled placeholder subject, so authelia has a non-empty store to start against before any real account exists (`swarm-authelia.nix:873`). Authelia generates session and storage keys in the container on
|
On first boot this module seeds the users database with a disabled placeholder subject, so authelia has a non-empty store to start against before any real account exists (`swarm-authelia.nix:873`). Authelia generates session and storage keys in the container on
|
||||||
first boot and never rotates them automatically; replacing one
|
first boot and never rotates them automatically; replacing one
|
||||||
invalidates data already written (sessions, the encrypted store), so
|
invalidates data already written (sessions, the encrypted store), so
|
||||||
|
|
|
||||||
|
|
@ -249,9 +249,10 @@ HTML instead of the failure that actually happened:
|
||||||
- `/api/oidc/token`, `/.well-known/openid-configuration` — a client
|
- `/api/oidc/token`, `/.well-known/openid-configuration` — a client
|
||||||
parsing an error page as its JSON document
|
parsing an error page as its JSON document
|
||||||
|
|
||||||
The gateway routes authelia's `/api/` and `/.well-known/` prefixes
|
The gateway routes authelia's `/api/` and `/.well-known/` prefixes, and
|
||||||
**without** error interception. The split is by _audience_, not by an enumerated path
|
the bridge's `/bridge/`, **without** error interception. The split is by
|
||||||
list: a human gets the page, every machine caller gets the status.
|
_audience_, not by an enumerated path list: a human gets the page, every
|
||||||
|
machine caller gets the status.
|
||||||
Enumerating endpoints individually would leave the next one added
|
Enumerating endpoints individually would leave the next one added
|
||||||
silently intercepted.
|
silently intercepted.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -451,17 +451,16 @@ async fn handle_set_resource_limits(
|
||||||
|
|
||||||
/// Guard: matrix provisioning needs a homeserver to provision against.
|
/// Guard: matrix provisioning needs a homeserver to provision against.
|
||||||
///
|
///
|
||||||
/// Answers "is one configured", not "is one running here" — the message names
|
/// Answers "is one configured", not "is one running here": the address is
|
||||||
/// both ways to get there, since a hive that talks to someone else's
|
/// the swarm's homeserver, wherever it runs.
|
||||||
/// homeserver never enables the local container at all.
|
|
||||||
fn require_matrix_present() -> Result<()> {
|
fn require_matrix_present() -> Result<()> {
|
||||||
if crate::matrix::is_present() {
|
if crate::matrix::is_present() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
"no matrix homeserver configured — set services.hyperhive.deploy.matrix.enable = true to run one \
|
"no matrix homeserver configured — set services.hyperhive.swarm.matrix.apiUrl (it defaults \
|
||||||
here, or services.hyperhive.swarm.matrix.apiUrl to point at an existing one, before \
|
to the swarm.matrix.gatewayHost vhost, and is null when that is) before provisioning \
|
||||||
provisioning matrix users"
|
matrix users"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -127,9 +127,9 @@ in
|
||||||
./options.nix
|
./options.nix
|
||||||
./theme.nix
|
./theme.nix
|
||||||
# Hive-CA trust for this daemon's outbound TLS. Nothing it is given by
|
# Hive-CA trust for this daemon's outbound TLS. Nothing it is given by
|
||||||
# default is https — the forge and matrix URLs resolve to plain http or
|
# default is https — the forge and matrix URLs are plain http, and the
|
||||||
# loopback, and the queue's TLS is verified against
|
# queue's TLS is verified against `HIVE_C0RE_OIDC_CA_FILE`
|
||||||
# `HIVE_C0RE_OIDC_CA_FILE` (../hive-tls.nix), not this bundle — so this
|
# (../hive-tls.nix), not this bundle — so this
|
||||||
# changes nothing on an all-local hive. It matters for the split-host
|
# changes nothing on an all-local hive. It matters for the split-host
|
||||||
# shape the options invite: `swarm.matrix.apiUrl`'s own example is
|
# shape the options invite: `swarm.matrix.apiUrl`'s own example is
|
||||||
# `https://matrix.example.com`, and pointing it at another hive's
|
# `https://matrix.example.com`, and pointing it at another hive's
|
||||||
|
|
|
||||||
|
|
@ -180,15 +180,10 @@ in
|
||||||
// lib.optionalAttrs (config.services.hyperhive.swarm.matrix.apiUrl != null) {
|
// lib.optionalAttrs (config.services.hyperhive.swarm.matrix.apiUrl != null) {
|
||||||
# Client-server API base hive-c0re uses to provision matrix (register
|
# Client-server API base hive-c0re uses to provision matrix (register
|
||||||
# agent users, create the hive space + chat room, invite members).
|
# agent users, create the hive space + chat room, invite members).
|
||||||
# Supplied by `services.hyperhive.swarm.matrix.apiUrl`, which the matrix
|
# Supplied by `services.hyperhive.swarm.matrix.apiUrl`, which defaults to
|
||||||
# module fills in with its own loopback listener when it is the thing
|
# the same gateway vhost as HIVE_MATRIX_URL above but is its own option,
|
||||||
# running tuwunel — and which the operator sets by hand when the
|
# so an operator can point provisioning elsewhere without changing what
|
||||||
# homeserver lives on another machine.
|
# agents are handed.
|
||||||
#
|
|
||||||
# NOT the agent-facing HIVE_MATRIX_URL above: that one is the gateway
|
|
||||||
# vhost, and it is absent whenever there is no vhost. Reusing it here
|
|
||||||
# would silently stop provisioning on a hive that runs matrix without
|
|
||||||
# one.
|
|
||||||
HIVE_MATRIX_API_URL = config.services.hyperhive.swarm.matrix.apiUrl;
|
HIVE_MATRIX_API_URL = config.services.hyperhive.swarm.matrix.apiUrl;
|
||||||
}
|
}
|
||||||
// {
|
// {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@
|
||||||
let
|
let
|
||||||
cfg = config.services.hyperhive.swarm.matrix;
|
cfg = config.services.hyperhive.swarm.matrix;
|
||||||
swarmDomain = config.services.hyperhive.swarm.domain;
|
swarmDomain = config.services.hyperhive.swarm.domain;
|
||||||
deployCfg = config.services.hyperhive.deploy;
|
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
options.services.hyperhive.swarm.matrix = {
|
options.services.hyperhive.swarm.matrix = {
|
||||||
|
|
@ -65,11 +64,10 @@ in
|
||||||
|
|
||||||
apiUrl = lib.mkOption {
|
apiUrl = lib.mkOption {
|
||||||
type = lib.types.nullOr lib.types.str;
|
type = lib.types.nullOr lib.types.str;
|
||||||
default = if deployCfg.matrix.enable then "http://127.0.0.1:${toString cfg.httpPort}" else null;
|
default = if cfg.gatewayHost == null then null else "http://${cfg.gatewayHost}";
|
||||||
defaultText = lib.literalExpression ''
|
defaultText = lib.literalExpression ''
|
||||||
if services.hyperhive.deploy.matrix.enable
|
if services.hyperhive.swarm.matrix.gatewayHost == null then null
|
||||||
then "http://127.0.0.1:''${toString services.hyperhive.swarm.matrix.httpPort}"
|
else "http://''${services.hyperhive.swarm.matrix.gatewayHost}"
|
||||||
else null
|
|
||||||
'';
|
'';
|
||||||
example = "https://matrix.example.com";
|
example = "https://matrix.example.com";
|
||||||
description = ''
|
description = ''
|
||||||
|
|
@ -79,12 +77,10 @@ in
|
||||||
`hyperhive.matrix.url`, which is the gateway vhost handed to
|
`hyperhive.matrix.url`, which is the gateway vhost handed to
|
||||||
each agent's `hive-matrix-daemon`.
|
each agent's `hive-matrix-daemon`.
|
||||||
|
|
||||||
Defaults to the loopback listener **only when this module is the
|
Defaults to the homeserver's gateway vhost (`gatewayHost`), the
|
||||||
thing running tuwunel** — in that case the address is not a
|
same name on every hive whichever host runs tuwunel, and `null`
|
||||||
guess, it is where this module just put the container. Set it
|
when there is no vhost. Set it explicitly when the homeserver is
|
||||||
explicitly (with `enable = false`) when the homeserver runs on
|
reachable only some other way.
|
||||||
another machine; "everything on one host" is a special case of
|
|
||||||
the full deployment, not the assumption.
|
|
||||||
|
|
||||||
`null` means hive-c0re has no homeserver to provision against
|
`null` means hive-c0re has no homeserver to provision against
|
||||||
and matrix provisioning no-ops. There is deliberately no
|
and matrix provisioning no-ops. There is deliberately no
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@
|
||||||
let
|
let
|
||||||
cfg = config.services.hyperhive.swarm.authelia;
|
cfg = config.services.hyperhive.swarm.authelia;
|
||||||
swarmDomain = config.services.hyperhive.swarm.domain;
|
swarmDomain = config.services.hyperhive.swarm.domain;
|
||||||
deployCfg = config.services.hyperhive.deploy;
|
|
||||||
|
|
||||||
# The unit name upstream's `services.authelia.instances.<name>` derives,
|
# The unit name upstream's `services.authelia.instances.<name>` derives,
|
||||||
# which `unit` publishes. ./swarm-authelia.nix binds the same two names for
|
# which `unit` publishes. ./swarm-authelia.nix binds the same two names for
|
||||||
|
|
@ -432,32 +431,10 @@ in
|
||||||
(`127.0.0.1:''${bridgePort}`) — one above authelia's own default
|
(`127.0.0.1:''${bridgePort}`) — one above authelia's own default
|
||||||
`port` (9091), outside hyperhive's other claimed ranges.
|
`port` (9091), outside hyperhive's other claimed ranges.
|
||||||
|
|
||||||
Reachable directly from this host's other processes (this
|
Not published on its own: the authelia vhost proxies
|
||||||
container shares the host netns, same as authelia's own `port`)
|
`/bridge/` to it, and that is the address `swarm-controller`
|
||||||
without going through the gateway — this is an internal
|
uses from any host. The bridge answers only
|
||||||
service-to-service endpoint, not something meant to be exposed
|
`swarm-controller`'s OIDC client.
|
||||||
publicly.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
bridgeUrl = lib.mkOption {
|
|
||||||
type = lib.types.nullOr lib.types.str;
|
|
||||||
readOnly = true;
|
|
||||||
default = if deployCfg.authelia.enable then "http://127.0.0.1:${toString cfg.bridgePort}" else null;
|
|
||||||
defaultText = lib.literalExpression ''if enable then "http://127.0.0.1:''${bridgePort}" else null'';
|
|
||||||
description = ''
|
|
||||||
Where `swarm-authelia-bridge` answers, **as seen from this
|
|
||||||
host** — correct only when a caller (`swarm-controller`) also
|
|
||||||
runs on this host, the same co-location assumption
|
|
||||||
`swarm.nix`'s `clientSecretFile` documents for its own
|
|
||||||
cross-host case. `null` when this host doesn't run
|
|
||||||
`swarm-authelia` at all.
|
|
||||||
|
|
||||||
A split-host swarm has no automated delivery for this address:
|
|
||||||
the operator points `swarm-controller`'s own option at wherever
|
|
||||||
this host has made the bridge reachable (a firewall rule, a
|
|
||||||
different bind address), the same manual-copy shape used
|
|
||||||
throughout this codebase's other cross-host cases.
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -759,6 +759,13 @@ in
|
||||||
locations."/.well-known/" = {
|
locations."/.well-known/" = {
|
||||||
proxyPass = "http://127.0.0.1:${toString cfg.port}";
|
proxyPass = "http://127.0.0.1:${toString cfg.port}";
|
||||||
};
|
};
|
||||||
|
# `swarm-authelia-bridge`, a separate process from authelia, also a
|
||||||
|
# machine surface. The trailing slash on `proxyPass` strips the
|
||||||
|
# prefix, so `/bridge/requests` reaches the bridge as `/requests`.
|
||||||
|
# The bridge itself refuses every client but swarm-controller's.
|
||||||
|
locations."/bridge/" = {
|
||||||
|
proxyPass = "http://127.0.0.1:${toString cfg.bridgePort}/";
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
# Order the container after the host CA generator, so the bind source
|
# Order the container after the host CA generator, so the bind source
|
||||||
|
|
@ -924,10 +931,10 @@ in
|
||||||
# includes `bridgeClient`) — same file this container's own
|
# includes `bridgeClient`) — same file this container's own
|
||||||
# `renderClient` reads the digest half of.
|
# `renderClient` reads the digest half of.
|
||||||
SWARM_AUTHELIA_BRIDGE_CLIENT_SECRET_FILE = "${clientsDir}/${bridgeClientId}.secret";
|
SWARM_AUTHELIA_BRIDGE_CLIENT_SECRET_FILE = "${clientsDir}/${bridgeClientId}.secret";
|
||||||
# The operator list names who holds operator rights, so the
|
# The one client the bridge answers. Every other active token
|
||||||
# bridge answers it to the controller's client and refuses every
|
# (each hive and its agents hold one) is refused, on every
|
||||||
# other active token (each hive and its agents hold one).
|
# request, since `/bridge/` is reachable from any host.
|
||||||
SWARM_AUTHELIA_BRIDGE_OPERATOR_READER_CLIENT_ID = hyperhiveCfg.swarm.controller.queueClientId;
|
SWARM_AUTHELIA_BRIDGE_CONTROLLER_CLIENT_ID = hyperhiveCfg.swarm.controller.queueClientId;
|
||||||
SWARM_AUTHELIA_BRIDGE_OPERATOR_GROUP = operatorGroup;
|
SWARM_AUTHELIA_BRIDGE_OPERATOR_GROUP = operatorGroup;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -587,17 +587,14 @@ in
|
||||||
|
|
||||||
authBridgeUrl = lib.mkOption {
|
authBridgeUrl = lib.mkOption {
|
||||||
type = lib.types.nullOr lib.types.str;
|
type = lib.types.nullOr lib.types.str;
|
||||||
default = if deployCfg.authelia.enable then autheliaCfg.bridgeUrl else null;
|
default = "${autheliaCfg.url}/bridge";
|
||||||
defaultText = lib.literalExpression ''
|
defaultText = lib.literalExpression ''"''${services.hyperhive.swarm.authelia.url}/bridge"'';
|
||||||
authelia's own `bridgeUrl` when this host also runs
|
example = "https://auth.example.com/bridge";
|
||||||
`swarm-authelia`, else null
|
|
||||||
'';
|
|
||||||
example = "http://127.0.0.1:9092";
|
|
||||||
description = ''
|
description = ''
|
||||||
Where `swarm-authelia-bridge` (the only writer of authelia's
|
Where `swarm-authelia-bridge` (the only writer of authelia's
|
||||||
users database) answers — see that option's own doc comment for
|
users database) answers. The default is the `/bridge/` location
|
||||||
the cross-host caveat, since this default is only correct when
|
on the authelia vhost, the same address from every host whether
|
||||||
this host also runs `swarm-authelia`.
|
or not it runs `swarm-authelia`; the daemon appends `/requests`.
|
||||||
|
|
||||||
No new credential to configure: the bearer token presented to
|
No new credential to configure: the bearer token presented to
|
||||||
the bridge is minted from THIS daemon's own existing queue OIDC
|
the bridge is minted from THIS daemon's own existing queue OIDC
|
||||||
|
|
@ -879,8 +876,7 @@ in
|
||||||
environment = {
|
environment = {
|
||||||
SWARM_CONTROLLER_SOCKET = deployCfg.swarm-controller.socketPath;
|
SWARM_CONTROLLER_SOCKET = deployCfg.swarm-controller.socketPath;
|
||||||
# The swarm's hive directory, JSON-encoded — the full directory
|
# The swarm's hive directory, JSON-encoded — the full directory
|
||||||
# (this daemon has no "self" hive to exclude, unlike
|
# (this daemon has no "self" hive to exclude) rather than
|
||||||
# `swarm.peerHives`, `swarm.hives` minus this hive) rather than
|
|
||||||
# peers-minus-self. Consumed by `GET /api/hives`
|
# peers-minus-self. Consumed by `GET /api/hives`
|
||||||
# (swarm-controller/src/main.rs::load_hives).
|
# (swarm-controller/src/main.rs::load_hives).
|
||||||
# The one blacklist of names an agent may not take, shared verbatim
|
# The one blacklist of names an agent may not take, shared verbatim
|
||||||
|
|
|
||||||
|
|
@ -114,12 +114,14 @@
|
||||||
networking.wireguard.interfaces =
|
networking.wireguard.interfaces =
|
||||||
let
|
let
|
||||||
wgCfg = config.services.hyperhive.deploy.wireguard;
|
wgCfg = config.services.hyperhive.deploy.wireguard;
|
||||||
# `peerHives` is `swarm.hives` minus this hive (../swarm.nix) —
|
# `swarm.hives` minus this host's own hive: a mesh that included
|
||||||
# a mesh that included our own entry would configure a tunnel to
|
# our own entry would configure a tunnel to ourselves.
|
||||||
# ourselves.
|
|
||||||
meshPeers = lib.filterAttrs (
|
meshPeers = lib.filterAttrs (
|
||||||
_: p: p.wireguardPublicKey != null && p.wireguardAddress != null
|
name: p:
|
||||||
) config.services.hyperhive.swarm.peerHives;
|
name != config.services.hyperhive.hiveName
|
||||||
|
&& p.wireguardPublicKey != null
|
||||||
|
&& p.wireguardAddress != null
|
||||||
|
) config.services.hyperhive.swarm.hives;
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
wg-hive = {
|
wg-hive = {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# The swarm's directory: one entry per hive, **including this one**,
|
# The swarm's directory: one entry per hive, **including this one**,
|
||||||
# identical on every host in the swarm. `services.hyperhive.hiveName`
|
# identical on every host in the swarm. `services.hyperhive.hiveName`
|
||||||
# says which entry is us, and `peerHives` below derives the rest.
|
# says which entry is us.
|
||||||
#
|
#
|
||||||
# Why a directory rather than a per-host peer list: every field here is
|
# Why a directory rather than a per-host peer list: every field here is
|
||||||
# intrinsic to the hive it describes — none of them says anything about
|
# intrinsic to the hive it describes — none of them says anything about
|
||||||
|
|
@ -78,41 +78,31 @@ let
|
||||||
# assertion encoded an intended shape, not a property of the code, and
|
# assertion encoded an intended shape, not a property of the code, and
|
||||||
# it rejected the supported migration path: a hive pinning its old
|
# it rejected the supported migration path: a hive pinning its old
|
||||||
# `forge.<hive domain>` while joining a swarm at a different apex.
|
# `forge.<hive domain>` while joining a swarm at a different apex.
|
||||||
|
#
|
||||||
|
# Every name is listed whether or not this host runs the service: the
|
||||||
|
# store's issuing role is built from this list on the store's host and
|
||||||
|
# has to admit a leaf for any gateway in the swarm. What one gateway's
|
||||||
|
# leaf carries is `localServiceDomains`.
|
||||||
|
#
|
||||||
|
# Membership is also what `gateway.lib.tlsFor` consults to pick the
|
||||||
|
# services leaf over the hive one, so a gateway-published swarm name
|
||||||
|
# missing here is served the hive certificate, which cannot cover a name
|
||||||
|
# under a different apex.
|
||||||
serviceDomains' = [
|
serviceDomains' = [
|
||||||
swarmCfg.forge.domain
|
swarmCfg.forge.domain
|
||||||
swarmCfg.matrix.gatewayHost
|
swarmCfg.matrix.gatewayHost
|
||||||
swarmCfg.authelia.domain
|
swarmCfg.authelia.domain
|
||||||
]
|
# The swarm UI's apex is a sibling of the other names, not a parent:
|
||||||
# The swarm UI's name is a SIBLING of the other three, not a parent of
|
# no CA in the hierarchy issues for it implicitly.
|
||||||
# them — the apex is as much a name needing a certificate as
|
swarmCfg.domain
|
||||||
# `forge.<apex>` is, and no CA in the hierarchy issues for it
|
swarmCfg.grafana.domain
|
||||||
# implicitly. Left out, its vhost falls back to the hive leaf and the
|
swarmCfg.victoriametrics.domain
|
||||||
# swarm's front page opens with a name mismatch.
|
swarmCfg.otel.domain
|
||||||
++ lib.optional (deployCfg.swarm-ui.enable && swarmCfg.domain != null) swarmCfg.domain
|
swarmCfg.victorialogs.domain
|
||||||
# Every swarm service that claims a gateway name belongs here, and
|
# The store's browser UI, not the store: `swarm.bao.domain` is mTLS and
|
||||||
# these three were missing it. Membership is what `gateway.lib.tlsFor`
|
# never gets a gateway certificate.
|
||||||
# consults to pick the services leaf over the hive one, so a name
|
swarmCfg.bao.ui.domain
|
||||||
# absent from this list is served the HIVE certificate — which cannot
|
];
|
||||||
# cover a name under a different apex however the sub-CA is set up.
|
|
||||||
#
|
|
||||||
# ⚠️ How that stayed invisible: a mismatch a browser shows as a
|
|
||||||
# click-through warning is one a machine client rejects outright. The
|
|
||||||
# metrics UI and store looked fine for as long as only people opened
|
|
||||||
# them; the collector's exporter — same defect, no human in the loop —
|
|
||||||
# failed every POST and dropped the samples.
|
|
||||||
++ lib.optional deployCfg.grafana.enable swarmCfg.grafana.domain
|
|
||||||
++ lib.optional deployCfg.victoriametrics.enable swarmCfg.victoriametrics.domain
|
|
||||||
++ lib.optional deployCfg.swarm-otel.enable swarmCfg.otel.domain
|
|
||||||
# VictoriaLogs' vhost is new (was previously unpublished entirely — see
|
|
||||||
# swarm-victorialogs.nix's file-top comment) and needs the same
|
|
||||||
# membership every gateway-published swarm service needs: absent from
|
|
||||||
# this list, `gateway.lib.tlsFor` falls back to the hive leaf, which
|
|
||||||
# cannot cover a name under a different apex — see the ⚠️ above this
|
|
||||||
# list for what that looked like the last time a name was missed here.
|
|
||||||
++ lib.optional deployCfg.victorialogs.enable swarmCfg.victorialogs.domain
|
|
||||||
# The store's browser UI, not the store: `swarm.bao.domain` is mTLS and
|
|
||||||
# never gets a gateway certificate.
|
|
||||||
++ lib.optional deployCfg.bao.enable swarmCfg.bao.ui.domain;
|
|
||||||
|
|
||||||
# Hives whose entry still carries the removed `certFingerprint`. Scanned
|
# Hives whose entry still carries the removed `certFingerprint`. Scanned
|
||||||
# here, at top level, because that is the only place an assertion about a
|
# here, at top level, because that is the only place an assertion about a
|
||||||
|
|
@ -274,28 +264,17 @@ in
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
options.services.hyperhive.swarm.peerHives = lib.mkOption {
|
|
||||||
type = lib.types.attrsOf (lib.types.attrsOf lib.types.unspecified);
|
|
||||||
readOnly = true;
|
|
||||||
internal = true;
|
|
||||||
description = ''
|
|
||||||
Read-only: `hives` minus this host's own entry. Derived once here
|
|
||||||
rather than in each consumer, because "everything that isn't me"
|
|
||||||
is a filter four different modules were re-implementing and only
|
|
||||||
one of them has to be wrong for a hive to peer with itself.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
options.services.hyperhive.swarm.serviceDomains = lib.mkOption {
|
options.services.hyperhive.swarm.serviceDomains = lib.mkOption {
|
||||||
type = lib.types.listOf lib.types.str;
|
type = lib.types.listOf lib.types.str;
|
||||||
readOnly = true;
|
readOnly = true;
|
||||||
internal = true;
|
internal = true;
|
||||||
description = ''
|
description = ''
|
||||||
Read-only: the public hostnames of the swarm's own services, in a
|
Read-only: the public hostnames of every swarm service, in a
|
||||||
stable sorted order. Second derived set alongside `peerHives`, and
|
stable sorted order, identical on every host whichever services it
|
||||||
here for the same reason — the CA that name-constrains these and
|
runs. Derived once here because the CA that name-constrains these,
|
||||||
the leaf that carries them as SANs must agree exactly, and two
|
the store role that issues for them and the leaf that carries them
|
||||||
modules each assembling the list is how they stop agreeing.
|
as SANs must agree exactly, and two modules each assembling the
|
||||||
|
list is how they stop agreeing.
|
||||||
|
|
||||||
Sorted and deduplicated deliberately: consumers compare this list
|
Sorted and deduplicated deliberately: consumers compare this list
|
||||||
against what they issued last time to decide whether to re-issue,
|
against what they issued last time to decide whether to re-issue,
|
||||||
|
|
@ -326,8 +305,6 @@ in
|
||||||
};
|
};
|
||||||
|
|
||||||
config = {
|
config = {
|
||||||
services.hyperhive.swarm.peerHives = lib.filterAttrs (name: _: name != cfg.hiveName) swarmCfg.hives;
|
|
||||||
|
|
||||||
services.hyperhive.swarm.serviceDomains = lib.sort (a: b: a < b) (
|
services.hyperhive.swarm.serviceDomains = lib.sort (a: b: a < b) (
|
||||||
lib.unique (lib.filter (d: d != null && d != "") serviceDomains')
|
lib.unique (lib.filter (d: d != null && d != "") serviceDomains')
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,22 @@ let
|
||||||
&& !(lib.hasInfix "auth.t.local" u)
|
&& !(lib.hasInfix "auth.t.local" u)
|
||||||
&& lib.elem "auth.t.local" s;
|
&& lib.elem "auth.t.local" s;
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
# The store's issuing role is built from this list on the store's host,
|
||||||
|
# so it cannot depend on which services the evaluating host runs. The
|
||||||
|
# last conjunct is the control: grafana runs on neither `bare` nor the
|
||||||
|
# forge host, and is still listed.
|
||||||
|
name = "the swarm's service names are the same on hosts running different services";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
s = h: h.services.hyperhive.swarm.serviceDomains;
|
||||||
|
in
|
||||||
|
s (hive {
|
||||||
|
deploy.forgejo.enable = true;
|
||||||
|
}) == s bare
|
||||||
|
&& s allLocal == s bare
|
||||||
|
&& lib.elem "grafana.t.local" (s bare);
|
||||||
|
}
|
||||||
{
|
{
|
||||||
# The narrowing's floor: no names left means no request, rather than a
|
# The narrowing's floor: no names left means no request, rather than a
|
||||||
# request for an empty SAN set that the store would refuse. Its own
|
# request for an empty SAN set that the store would refuse. Its own
|
||||||
|
|
|
||||||
|
|
@ -264,19 +264,33 @@ let
|
||||||
&& !(lib.hasInfix ''echo "users: {}"'' s);
|
&& !(lib.hasInfix ''echo "users: {}"'' s);
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
# The bridge refuses its operator list to every client but the one
|
# The bridge refuses every client but the one named here, so this has
|
||||||
# named here, so this has to be the client the controller authenticates
|
# to be the client the controller authenticates as. A drift is a
|
||||||
# as. A drift is a controller whose admin sync is refused every pass.
|
# controller whose every bridge call is refused.
|
||||||
name = "the authelia bridge answers its operator list to the controller's client only";
|
name = "the authelia bridge answers the controller's client only";
|
||||||
ok =
|
ok =
|
||||||
let
|
let
|
||||||
env =
|
env =
|
||||||
autheliaColocated.containers.swarm-authelia.config.systemd.services.swarm-authelia-bridge.environment;
|
autheliaColocated.containers.swarm-authelia.config.systemd.services.swarm-authelia-bridge.environment;
|
||||||
in
|
in
|
||||||
env.SWARM_AUTHELIA_BRIDGE_OPERATOR_READER_CLIENT_ID
|
env.SWARM_AUTHELIA_BRIDGE_CONTROLLER_CLIENT_ID
|
||||||
== autheliaColocated.services.hyperhive.swarm.controller.queueClientId
|
== autheliaColocated.services.hyperhive.swarm.controller.queueClientId
|
||||||
&& env.SWARM_AUTHELIA_BRIDGE_OPERATOR_GROUP == "admins";
|
&& env.SWARM_AUTHELIA_BRIDGE_OPERATOR_GROUP == "admins";
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
# One address from every host, and a location the IdP host serves: a
|
||||||
|
# controller off the authelia host has no other way to the bridge.
|
||||||
|
name = "the auth bridge address is the authelia vhost's /bridge/ on every host";
|
||||||
|
ok =
|
||||||
|
let
|
||||||
|
url = h: h.services.hyperhive.deploy.swarm-controller.authBridgeUrl;
|
||||||
|
authelia = autheliaColocated.services.hyperhive.swarm.authelia;
|
||||||
|
location = autheliaColocated.services.nginx.virtualHosts.${authelia.domain}.locations."/bridge/";
|
||||||
|
in
|
||||||
|
url autheliaNotColocated == "${autheliaNotColocated.services.hyperhive.swarm.authelia.url}/bridge"
|
||||||
|
&& url autheliaColocated == url autheliaNotColocated
|
||||||
|
&& location.proxyPass == "http://127.0.0.1:${toString authelia.bridgePort}/";
|
||||||
|
}
|
||||||
{
|
{
|
||||||
# Config NAMES the IdP; it never computes where the IdP is. Both arms
|
# Config NAMES the IdP; it never computes where the IdP is. Both arms
|
||||||
# matter together: the address is the swarm's name on a hive that runs
|
# matter together: the address is the swarm's name on a hive that runs
|
||||||
|
|
|
||||||
|
|
@ -25,18 +25,17 @@ simply owns the file it writes. No elevated privilege anywhere.
|
||||||
`ListOperators` (the operator group's usernames).
|
`ListOperators` (the operator group's usernames).
|
||||||
- Bearer-authenticated via authelia's own OIDC token introspection (RFC
|
- Bearer-authenticated via authelia's own OIDC token introspection (RFC
|
||||||
7662), against `swarm-controller`'s **existing** machine-client identity
|
7662), against `swarm-controller`'s **existing** machine-client identity
|
||||||
(already minted for the queue connection) — no new credential. The two
|
(already minted for the queue connection) — no new credential. ⚠️ Every
|
||||||
agent verbs admit any active token. ⚠️ `ListOperators` admits only the
|
request admits only the client named by
|
||||||
client named by `SWARM_AUTHELIA_BRIDGE_OPERATOR_READER_CLIENT_ID`
|
`SWARM_AUTHELIA_BRIDGE_CONTROLLER_CLIENT_ID` (`swarm-controller`'s) and
|
||||||
(`swarm-controller`'s) and answers every other active token `403`:
|
answers every other active token `403`: authelia issues machine tokens to
|
||||||
authelia issues machine tokens to every hive and its agents too.
|
every hive and its agents too.
|
||||||
- No restart of authelia after a write — relies on
|
- No restart of authelia after a write — relies on
|
||||||
`authentication_backend.file.watch`, confirmed working against the
|
`authentication_backend.file.watch`, confirmed working against the
|
||||||
pinned 4.39.20 build during this design work.
|
pinned 4.39.20 build during this design work.
|
||||||
- Network-facing rather than unix-socket-only: `swarm-authelia`'s container
|
- Listens on loopback. The authelia vhost proxies `/bridge/` to it with
|
||||||
shares the host netns, so the same listener serves both a co-located
|
the prefix stripped, so `swarm-controller` on any host calls
|
||||||
`swarm-controller` (loopback) and a split-host one (bind wider + firewall)
|
`https://<swarm.authelia.domain>/bridge/requests`.
|
||||||
with no separate transport.
|
|
||||||
|
|
||||||
## One store, two writers
|
## One store, two writers
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,10 @@
|
||||||
//! with root/`CAP_CHOWN`/a shared group (all examined and rejected — see
|
//! with root/`CAP_CHOWN`/a shared group (all examined and rejected — see
|
||||||
//! `swarmctl/README.md`'s own identical analysis of this same file).
|
//! `swarmctl/README.md`'s own identical analysis of this same file).
|
||||||
//!
|
//!
|
||||||
//! Network-facing, not unix-socket-only: `swarm-authelia`'s container
|
//! Listens on loopback; the authelia vhost proxies `/bridge/` to it, so a
|
||||||
//! shares the host netns (`privateNetwork = false`, same as `hive-forge`/
|
//! `swarm-controller` on any host reaches it at the same address.
|
||||||
//! `hive-matrix`), so one listener serves a co-located `swarm-controller`
|
|
||||||
//! (loopback) and a split-host one (bind wider, firewall it) with the
|
|
||||||
//! same code path — no separate transport for the cross-host case.
|
|
||||||
//! Bearer-authenticated via authelia's own OIDC token introspection (RFC
|
//! Bearer-authenticated via authelia's own OIDC token introspection (RFC
|
||||||
//! 7662) against `swarm-controller`'s **existing** machine-client
|
//! 7662), and admits only `swarm-controller`'s **existing** machine-client
|
||||||
//! identity (already minted for the queue connection) — no new
|
//! identity (already minted for the queue connection) — no new
|
||||||
//! credential to mint or deliver, and always local/fast to verify since
|
//! credential to mint or deliver, and always local/fast to verify since
|
||||||
//! this process runs right next to the authelia it asks.
|
//! this process runs right next to the authelia it asks.
|
||||||
|
|
@ -25,7 +22,8 @@
|
||||||
//! One endpoint, `POST /requests`, body =
|
//! One endpoint, `POST /requests`, body =
|
||||||
//! [`swarm_authelia_bridge_sock::BridgeRequest`] verbatim: ensure an agent
|
//! [`swarm_authelia_bridge_sock::BridgeRequest`] verbatim: ensure an agent
|
||||||
//! exists as an authelia subject, list the ones that do, and list the
|
//! exists as an authelia subject, list the ones that do, and list the
|
||||||
//! operator group (to `swarm-controller` only, see `permit`). Not a
|
//! operator group. Every request is answered to `swarm-controller` only,
|
||||||
|
//! see `classify`. Not a
|
||||||
//! wholesale-replace-the-file API — this process owns rendering
|
//! wholesale-replace-the-file API — this process owns rendering
|
||||||
//! `users.yml` internally; see `store` module.
|
//! `users.yml` internally; see `store` module.
|
||||||
|
|
||||||
|
|
@ -53,8 +51,8 @@ struct Config {
|
||||||
introspection_url: String,
|
introspection_url: String,
|
||||||
client_id: String,
|
client_id: String,
|
||||||
client_secret: String,
|
client_secret: String,
|
||||||
/// The one client [`BridgeRequest::ListOperators`] answers.
|
/// The one client this bridge answers: `swarm-controller`'s.
|
||||||
operator_reader_client_id: String,
|
controller_client_id: String,
|
||||||
/// The authelia group whose members are operators.
|
/// The authelia group whose members are operators.
|
||||||
operator_group: String,
|
operator_group: String,
|
||||||
}
|
}
|
||||||
|
|
@ -68,7 +66,7 @@ impl Config {
|
||||||
introspection_url: env_var("SWARM_AUTHELIA_BRIDGE_INTROSPECTION_URL")?,
|
introspection_url: env_var("SWARM_AUTHELIA_BRIDGE_INTROSPECTION_URL")?,
|
||||||
client_id: env_var("SWARM_AUTHELIA_BRIDGE_CLIENT_ID")?,
|
client_id: env_var("SWARM_AUTHELIA_BRIDGE_CLIENT_ID")?,
|
||||||
client_secret: read_secret_file(&env_var("SWARM_AUTHELIA_BRIDGE_CLIENT_SECRET_FILE")?)?,
|
client_secret: read_secret_file(&env_var("SWARM_AUTHELIA_BRIDGE_CLIENT_SECRET_FILE")?)?,
|
||||||
operator_reader_client_id: env_var("SWARM_AUTHELIA_BRIDGE_OPERATOR_READER_CLIENT_ID")?,
|
controller_client_id: env_var("SWARM_AUTHELIA_BRIDGE_CONTROLLER_CLIENT_ID")?,
|
||||||
operator_group: env_var("SWARM_AUTHELIA_BRIDGE_OPERATOR_GROUP")?,
|
operator_group: env_var("SWARM_AUTHELIA_BRIDGE_OPERATOR_GROUP")?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -146,28 +144,20 @@ async fn handle_request(
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Json(req): Json<BridgeRequest>,
|
Json(req): Json<BridgeRequest>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let caller = match authorize(&state, &headers).await {
|
if let Err(resp) = authorize(&state, &headers).await {
|
||||||
Ok(caller) => caller,
|
return resp;
|
||||||
Err(resp) => return resp,
|
}
|
||||||
};
|
respond(&state.config, &state.write_lock, req).await
|
||||||
respond(&state.config, &state.write_lock, caller.as_deref(), req).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything after authentication: the per-request [`permit`] check, then
|
/// Everything after authentication: the operation itself. Takes the config
|
||||||
/// the operation. Takes the config and the write lock rather than
|
/// and the write lock rather than [`AppState`], whose HTTP client only
|
||||||
/// [`AppState`], whose HTTP client only [`authorize`] uses.
|
/// [`authorize`] uses.
|
||||||
async fn respond(
|
async fn respond(
|
||||||
config: &Config,
|
config: &Config,
|
||||||
write_lock: &tokio::sync::Mutex<()>,
|
write_lock: &tokio::sync::Mutex<()>,
|
||||||
caller: Option<&str>,
|
|
||||||
req: BridgeRequest,
|
req: BridgeRequest,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
if let Err(refusal) = permit(&req, caller, &config.operator_reader_client_id) {
|
|
||||||
// The client id is not a credential; naming it is what lets an
|
|
||||||
// operator see which client asked for something it may not have.
|
|
||||||
tracing::warn!(client_id = ?caller, request = ?req, "refused a request scoped to another client");
|
|
||||||
return refuse(refusal);
|
|
||||||
}
|
|
||||||
let (op, result) = match req {
|
let (op, result) = match req {
|
||||||
BridgeRequest::EnsureAgentIdentity { name } => {
|
BridgeRequest::EnsureAgentIdentity { name } => {
|
||||||
("ensure_identity", handle(config, write_lock, name).await)
|
("ensure_identity", handle(config, write_lock, name).await)
|
||||||
|
|
@ -194,10 +184,10 @@ async fn respond(
|
||||||
/// "the op itself failed," a different case from "the caller was never let
|
/// "the op itself failed," a different case from "the caller was never let
|
||||||
/// in."
|
/// in."
|
||||||
///
|
///
|
||||||
/// Anything short of an active token is refused; **which status** depends
|
/// Anything short of an active token from the controller's client is
|
||||||
/// on whose credential failed, see [`Refusal`]. `Ok` carries the client the
|
/// refused; **which status** depends on whose credential failed, see
|
||||||
/// token was issued to, for [`permit`].
|
/// [`Refusal`].
|
||||||
async fn authorize(state: &AppState, headers: &HeaderMap) -> Result<Option<String>, Response> {
|
async fn authorize(state: &AppState, headers: &HeaderMap) -> Result<(), Response> {
|
||||||
let Some(auth) = headers.get(axum::http::header::AUTHORIZATION) else {
|
let Some(auth) = headers.get(axum::http::header::AUTHORIZATION) else {
|
||||||
return Err(refuse(Refusal::Caller));
|
return Err(refuse(Refusal::Caller));
|
||||||
};
|
};
|
||||||
|
|
@ -215,41 +205,37 @@ async fn authorize(state: &AppState, headers: &HeaderMap) -> Result<Option<Strin
|
||||||
token,
|
token,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
classify(verdict).map_err(refuse)
|
classify(verdict, &state.config.controller_client_id).map_err(refuse)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The admission decision, and the only place that makes it.
|
/// The admission decision, and the only place that makes it.
|
||||||
///
|
///
|
||||||
/// Fail-closed: `Ok(())` is reachable from exactly one variant. Split out
|
/// Fail-closed: `Ok(())` is reachable only from an active token issued to
|
||||||
/// of [`authorize`] so the rule is testable without an authelia — and it is
|
/// `controller`. authelia also issues machine tokens to every hive and every
|
||||||
/// the *real* rule, not a restatement of it, because this is the function
|
/// hive's agents, and those are as active as the controller's, so they are
|
||||||
/// the request path calls.
|
/// refused `403`; a token attributed to no client is refused too, rather
|
||||||
fn classify(verdict: introspect::Verdict) -> Result<Option<String>, Refusal> {
|
/// than compared as a blank. Split out of [`authorize`] so the rule is
|
||||||
|
/// testable without an authelia — and it is the *real* rule, not a
|
||||||
|
/// restatement of it, because this is the function the request path calls.
|
||||||
|
fn classify(verdict: introspect::Verdict, controller: &str) -> Result<(), Refusal> {
|
||||||
match verdict {
|
match verdict {
|
||||||
introspect::Verdict::Active { client_id } => Ok(client_id),
|
introspect::Verdict::Active { client_id } if client_id.as_deref() == Some(controller) => {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
introspect::Verdict::Active { client_id } => {
|
||||||
|
// The client id is not a credential; naming it is what lets an
|
||||||
|
// operator see which client was refused.
|
||||||
|
tracing::warn!(
|
||||||
|
?client_id,
|
||||||
|
"refused an active token issued to another client"
|
||||||
|
);
|
||||||
|
Err(Refusal::Forbidden)
|
||||||
|
}
|
||||||
introspect::Verdict::Inactive => Err(Refusal::Caller),
|
introspect::Verdict::Inactive => Err(Refusal::Caller),
|
||||||
introspect::Verdict::Unavailable => Err(Refusal::Bridge),
|
introspect::Verdict::Unavailable => Err(Refusal::Bridge),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether an admitted caller may make this particular request.
|
|
||||||
///
|
|
||||||
/// [`BridgeRequest::ListOperators`] answers only `operator_reader`, the
|
|
||||||
/// controller's client: authelia also issues machine tokens to every hive and
|
|
||||||
/// every hive's agents, and those are as active as the controller's. A caller
|
|
||||||
/// with no client id is refused it too, rather than compared as a blank.
|
|
||||||
fn permit(
|
|
||||||
req: &BridgeRequest,
|
|
||||||
client_id: Option<&str>,
|
|
||||||
operator_reader: &str,
|
|
||||||
) -> Result<(), Refusal> {
|
|
||||||
match req {
|
|
||||||
BridgeRequest::EnsureAgentIdentity { .. } | BridgeRequest::ListAgentIdentities => Ok(()),
|
|
||||||
BridgeRequest::ListOperators if client_id == Some(operator_reader) => Ok(()),
|
|
||||||
BridgeRequest::ListOperators => Err(Refusal::Forbidden),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whose credential was at fault when a request was refused.
|
/// Whose credential was at fault when a request was refused.
|
||||||
///
|
///
|
||||||
/// The distinction is the whole point: these used to be one 401, so a
|
/// The distinction is the whole point: these used to be one 401, so a
|
||||||
|
|
@ -262,13 +248,14 @@ enum Refusal {
|
||||||
/// This bridge could not get an answer out of authelia at all, so the
|
/// This bridge could not get an answer out of authelia at all, so the
|
||||||
/// request was never evaluated.
|
/// request was never evaluated.
|
||||||
Bridge,
|
Bridge,
|
||||||
/// The token is valid, but its client may not make this request.
|
/// The token is valid, but was issued to a client other than the
|
||||||
|
/// controller's.
|
||||||
Forbidden,
|
Forbidden,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Refusal {
|
impl Refusal {
|
||||||
/// `401` for the caller; `503` for us; `403` for a valid caller asking
|
/// `401` for the caller; `503` for us; `403` for a valid token issued
|
||||||
/// for something scoped to another client.
|
/// to a client other than the controller's.
|
||||||
///
|
///
|
||||||
/// 401 would be wrong for [`Refusal::Bridge`] under RFC 7235 — nothing
|
/// 401 would be wrong for [`Refusal::Bridge`] under RFC 7235 — nothing
|
||||||
/// about the caller's credential was determined — and it is also the
|
/// about the caller's credential was determined — and it is also the
|
||||||
|
|
@ -456,34 +443,58 @@ async fn generate_password(bin: &std::path::Path) -> Result<String> {
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{
|
use super::{
|
||||||
BridgeRequest, Config, Refusal, StatusCode, classify, introspect::Verdict, permit, respond,
|
BridgeRequest, Config, Refusal, StatusCode, classify, introspect::Verdict, respond,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CONTROLLER: &str = "swarm-controller";
|
||||||
|
|
||||||
|
fn active(client_id: Option<&str>) -> Verdict {
|
||||||
|
Verdict::Active {
|
||||||
|
client_id: client_id.map(str::to_owned),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Fail-closed, asserted per variant rather than as one negation:
|
/// Fail-closed, asserted per variant rather than as one negation:
|
||||||
/// splitting blame out of a single denial is exactly the change that
|
/// splitting blame out of a single denial is exactly the change that
|
||||||
/// could have widened admission, so each refusing variant is named on
|
/// could have widened admission, so each refusing variant is named on
|
||||||
/// its own line.
|
/// its own line.
|
||||||
#[test]
|
#[test]
|
||||||
fn only_an_active_token_is_admitted() {
|
fn only_an_active_token_is_admitted() {
|
||||||
|
assert_eq!(classify(active(Some(CONTROLLER)), CONTROLLER), Ok(()));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify(Verdict::Active {
|
classify(Verdict::Inactive, CONTROLLER),
|
||||||
client_id: Some("swarm-controller".to_owned())
|
|
||||||
}),
|
|
||||||
Ok(Some("swarm-controller".to_owned()))
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
classify(Verdict::Inactive),
|
|
||||||
Err(Refusal::Caller),
|
Err(Refusal::Caller),
|
||||||
"authelia evaluated the token and said no"
|
"authelia evaluated the token and said no"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
classify(Verdict::Unavailable),
|
classify(Verdict::Unavailable, CONTROLLER),
|
||||||
Err(Refusal::Bridge),
|
Err(Refusal::Bridge),
|
||||||
"a bridge that cannot ask must not admit — this variant reports a \
|
"a bridge that cannot ask must not admit — this variant reports a \
|
||||||
different fault, it is not a softer denial"
|
different fault, it is not a softer denial"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The trust boundary. Every caller below holds a token authelia calls
|
||||||
|
/// active — a hive and a hive's agents are issued machine tokens too —
|
||||||
|
/// and only the controller's client is admitted. The first assertion is
|
||||||
|
/// the control: the same verdict shape with the controller's id passes.
|
||||||
|
#[test]
|
||||||
|
fn only_the_controllers_client_is_admitted() {
|
||||||
|
assert_eq!(classify(active(Some(CONTROLLER)), CONTROLLER), Ok(()));
|
||||||
|
for other in ["hive-alpha", "hive-alpha-agent", "swarm-authelia-bridge"] {
|
||||||
|
assert_eq!(
|
||||||
|
classify(active(Some(other)), CONTROLLER),
|
||||||
|
Err(Refusal::Forbidden),
|
||||||
|
"{other} holds an active token but is not the controller"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
classify(active(None), CONTROLLER),
|
||||||
|
Err(Refusal::Forbidden),
|
||||||
|
"a token authelia attributed to no client is not the controller's"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The whole point of the split, asserted on the value rather than
|
/// The whole point of the split, asserted on the value rather than
|
||||||
/// on message text — the previous code picked its status by matching on
|
/// on message text — the previous code picked its status by matching on
|
||||||
/// a log message's wording, which is what let the two collapse.
|
/// a log message's wording, which is what let the two collapse.
|
||||||
|
|
@ -521,51 +532,8 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONTROLLER: &str = "swarm-controller";
|
|
||||||
|
|
||||||
/// The trust boundary on the operator list. Every caller below holds a
|
|
||||||
/// token authelia calls active — a hive and a hive's agents are issued
|
|
||||||
/// machine tokens too — and only the controller's client is answered.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn only_the_controller_may_list_operators() {
|
fn a_refused_client_is_403_not_401() {
|
||||||
let req = BridgeRequest::ListOperators;
|
|
||||||
assert_eq!(permit(&req, Some(CONTROLLER), CONTROLLER), Ok(()));
|
|
||||||
for other in ["hive-alpha", "hive-alpha-agent", "swarm-authelia-bridge"] {
|
|
||||||
assert_eq!(
|
|
||||||
permit(&req, Some(other), CONTROLLER),
|
|
||||||
Err(Refusal::Forbidden),
|
|
||||||
"{other} holds an active token but is not the controller"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
assert_eq!(
|
|
||||||
permit(&req, None, CONTROLLER),
|
|
||||||
Err(Refusal::Forbidden),
|
|
||||||
"a token authelia attributed to no client is not the controller's"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The control for the test above: the agent verbs stay open to any
|
|
||||||
/// admitted caller, so the refusal there comes from the scoping and not
|
|
||||||
/// from `permit` refusing everyone who is not the controller.
|
|
||||||
#[test]
|
|
||||||
fn the_agent_verbs_stay_open_to_any_active_caller() {
|
|
||||||
for req in [
|
|
||||||
BridgeRequest::EnsureAgentIdentity {
|
|
||||||
name: "atlas".to_owned(),
|
|
||||||
},
|
|
||||||
BridgeRequest::ListAgentIdentities,
|
|
||||||
] {
|
|
||||||
assert_eq!(
|
|
||||||
permit(&req, Some("hive-alpha"), CONTROLLER),
|
|
||||||
Ok(()),
|
|
||||||
"{req:?}"
|
|
||||||
);
|
|
||||||
assert_eq!(permit(&req, None, CONTROLLER), Ok(()), "{req:?}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn a_scoped_refusal_is_403_not_401() {
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
Refusal::Forbidden.status(),
|
Refusal::Forbidden.status(),
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
|
|
@ -598,29 +566,16 @@ mod tests {
|
||||||
introspection_url: String::new(),
|
introspection_url: String::new(),
|
||||||
client_id: String::new(),
|
client_id: String::new(),
|
||||||
client_secret: String::new(),
|
client_secret: String::new(),
|
||||||
operator_reader_client_id: CONTROLLER.to_owned(),
|
controller_client_id: CONTROLLER.to_owned(),
|
||||||
operator_group: "admins".to_owned(),
|
operator_group: "admins".to_owned(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The request path, not only [`permit`]: a dispatch that skipped the
|
|
||||||
/// check would answer the hive below with the operator list.
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn the_request_path_answers_the_operator_list_to_the_controller_only() {
|
async fn the_operator_list_is_the_operator_groups_members() {
|
||||||
let config = config_with_one_operator();
|
let config = config_with_one_operator();
|
||||||
let lock = tokio::sync::Mutex::new(());
|
let lock = tokio::sync::Mutex::new(());
|
||||||
for caller in [Some("hive-alpha"), None] {
|
let answered = respond(&config, &lock, BridgeRequest::ListOperators).await;
|
||||||
let refused = respond(&config, &lock, caller, BridgeRequest::ListOperators).await;
|
|
||||||
assert_eq!(refused.status(), StatusCode::FORBIDDEN, "{caller:?}");
|
|
||||||
}
|
|
||||||
|
|
||||||
let answered = respond(
|
|
||||||
&config,
|
|
||||||
&lock,
|
|
||||||
Some(CONTROLLER),
|
|
||||||
BridgeRequest::ListOperators,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
assert_eq!(answered.status(), StatusCode::OK);
|
assert_eq!(answered.status(), StatusCode::OK);
|
||||||
let body = axum::body::to_bytes(answered.into_body(), usize::MAX)
|
let body = axum::body::to_bytes(answered.into_body(), usize::MAX)
|
||||||
.await
|
.await
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,9 @@
|
||||||
//! connection attempt: no window in which this process holds a token
|
//! connection attempt: no window in which this process holds a token
|
||||||
//! that outlives its intended use.
|
//! that outlives its intended use.
|
||||||
//!
|
//!
|
||||||
//! `None` when this deployment did not wire a bridge up — the bridge only
|
//! `None` when this deployment set no bridge address; the default is the
|
||||||
//! exists on hosts that also run `swarm-authelia`, so a controller split
|
//! `/bridge/` location on the authelia vhost, from every host. Without one,
|
||||||
//! from it can neither create an identity nor read the roster.
|
//! this daemon can neither create an identity nor read the roster.
|
||||||
//!
|
//!
|
||||||
//! Each caller answers that absence in its own terms rather than this
|
//! Each caller answers that absence in its own terms rather than this
|
||||||
//! module inventing a shared one: `SwarmNodeKind::CreateIdentity` fails
|
//! module inventing a shared one: `SwarmNodeKind::CreateIdentity` fails
|
||||||
|
|
|
||||||
|
|
@ -741,8 +741,7 @@ struct AppState {
|
||||||
|
|
||||||
/// Env var the controller's NixOS module sets from
|
/// Env var the controller's NixOS module sets from
|
||||||
/// `services.hyperhive.swarm.hives`, JSON-encoded — the full directory
|
/// `services.hyperhive.swarm.hives`, JSON-encoded — the full directory
|
||||||
/// (this daemon has no "self" to exclude) rather than peers-minus-self
|
/// (this daemon has no "self" to exclude) rather than peers-minus-self.
|
||||||
/// (`services.hyperhive.swarm.peerHives`, which other consumers use).
|
|
||||||
const HIVES_ENV: &str = "SWARM_CONTROLLER_HIVES";
|
const HIVES_ENV: &str = "SWARM_CONTROLLER_HIVES";
|
||||||
|
|
||||||
/// Parses [`HIVES_ENV`] into the swarm's hive directory. Unset or
|
/// Parses [`HIVES_ENV`] into the swarm's hive directory. Unset or
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue