Compare commits
3 changed files with 36 additions and 465 deletions
|
|
@ -442,21 +442,6 @@ in
|
||||||
url = "https://${cfg.domain}/";
|
url = "https://${cfg.domain}/";
|
||||||
};
|
};
|
||||||
|
|
||||||
# The metrics endpoint, declared once: the collector both scrapes this
|
|
||||||
# URL and derives from it the audience its token is minted for. Same
|
|
||||||
# `behindGateway` guard, and for a stronger reason than the two above:
|
|
||||||
# with it off there is no `= /metrics` location and no `auth_request`
|
|
||||||
# in front of it, so the URL this names does not exist to be scraped
|
|
||||||
# or authorised.
|
|
||||||
#
|
|
||||||
# ⚠️ Written as the exact URL a collector requests, because that is
|
|
||||||
# what authelia compares against — this string agreeing with the
|
|
||||||
# `location` block above it is the whole mechanism. A near miss is a
|
|
||||||
# correctly minted token refused at the target.
|
|
||||||
services.hyperhive.swarm.otel.publishedScrapeTargets = lib.optionalAttrs cfg.behindGateway {
|
|
||||||
forgejo = "https://${cfg.domain}/metrics";
|
|
||||||
};
|
|
||||||
|
|
||||||
# `server_name = forge.domain`, proxies all `/` → forgejo. Tuned for
|
# `server_name = forge.domain`, proxies all `/` → forgejo. Tuned for
|
||||||
# git: `client_max_body_size 1G`, `proxy_read_timeout 1h` (multi-GB
|
# git: `client_max_body_size 1G`, `proxy_read_timeout 1h` (multi-GB
|
||||||
# clones). SSH stays direct on `forge.sshPort`. See
|
# clones). SSH stays direct on `forge.sshPort`. See
|
||||||
|
|
|
||||||
|
|
@ -57,47 +57,6 @@ let
|
||||||
# failing the check it was supposed to pass.
|
# failing the check it was supposed to pass.
|
||||||
operatorGroup = "admins";
|
operatorGroup = "admins";
|
||||||
|
|
||||||
# Whether the swarm collector's client is actually registered here.
|
|
||||||
#
|
|
||||||
# Asked of the client list rather than re-derived from the conditions
|
|
||||||
# that produce it (`otel.enable`, a non-empty audience set, authelia
|
|
||||||
# being co-located). A second copy of that predicate is a second thing
|
|
||||||
# to keep in step, and the two drifting is not a build failure: a rule
|
|
||||||
# naming an unregistered client is refused by authelia's *startup*
|
|
||||||
# validator, so the whole SSO service fails to restart.
|
|
||||||
#
|
|
||||||
# Reading the registration itself also makes the rule correct for a
|
|
||||||
# registrar this module has never heard of — an operator registering
|
|
||||||
# the collector by hand against a provider that is not co-located with
|
|
||||||
# it gets the same rule, from the same expression.
|
|
||||||
# ⚠️ `swarm.otel` named in full, not through a let binding: there are two
|
|
||||||
# otel options one word apart, and only this one is the swarm's collector.
|
|
||||||
collectorClientId = config.services.hyperhive.swarm.otel.clientId;
|
|
||||||
collectorRegistered = lib.any (c: c.id == collectorClientId) cfg.oidc.clients;
|
|
||||||
|
|
||||||
# The forge's metrics endpoint: deny until a collector exists to allow.
|
|
||||||
#
|
|
||||||
# `deny` is not a placeholder, it is the protection. The endpoint is
|
|
||||||
# always served (a swarm-integrated forge always has metrics) and
|
|
||||||
# `default_policy` is `one_factor`, which means *any* authenticated
|
|
||||||
# subject — every operator today, every agent once they hold authelia
|
|
||||||
# accounts. Nor does the audience stand between a browser session and
|
|
||||||
# this data: `authn_strategies` on the authz endpoint also accepts
|
|
||||||
# `CookieSession`, and a cookie carries no audience at all.
|
|
||||||
metricsRule = {
|
|
||||||
domain = forgeCfg.domain;
|
|
||||||
resources = [ "^/metrics$" ];
|
|
||||||
}
|
|
||||||
// (
|
|
||||||
if collectorRegistered then
|
|
||||||
{
|
|
||||||
policy = "one_factor";
|
|
||||||
subject = [ "oauth2:client:${collectorClientId}" ];
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{ policy = "deny"; }
|
|
||||||
);
|
|
||||||
|
|
||||||
# Upstream's `services.authelia.instances.<name>` derives the unit,
|
# Upstream's `services.authelia.instances.<name>` derives the unit,
|
||||||
# user, group and StateDirectory from the instance name
|
# user, group and StateDirectory from the instance name
|
||||||
# (`authelia` + `-<name>`). Naming them here rather than repeating the
|
# (`authelia` + `-<name>`). Naming them here rather than repeating the
|
||||||
|
|
@ -258,7 +217,7 @@ let
|
||||||
if c.kind == "machine" then
|
if c.kind == "machine" then
|
||||||
''
|
''
|
||||||
printf -- ' grant_types: ["client_credentials"]\n'
|
printf -- ' grant_types: ["client_credentials"]\n'
|
||||||
printf -- ' scopes: [${lib.optionalString c.bearerAuthz "authelia.bearer.authz"}]\n'
|
printf -- ' scopes: []\n'
|
||||||
''
|
''
|
||||||
else
|
else
|
||||||
''
|
''
|
||||||
|
|
@ -587,15 +546,6 @@ in
|
||||||
a successful consent** — the login looks like it worked
|
a successful consent** — the login looks like it worked
|
||||||
right up to the last hop, and neither the redirect nor
|
right up to the last hop, and neither the redirect nor
|
||||||
the secret is at fault.
|
the secret is at fault.
|
||||||
|
|
||||||
⚠️ `null` is NOT accepted on a client with `bearerAuthz`.
|
|
||||||
Measured against authelia 4.39.20: under that scope the
|
|
||||||
method must be *stated*: omitting it is refused with
|
|
||||||
`must be configured as 'client_secret_basic', … but it's
|
|
||||||
configured as` an empty string. The sentence above is
|
|
||||||
true of an ordinary client and false of that one, which
|
|
||||||
is exactly how a reviewer reads this option and concludes
|
|
||||||
the assertion below is wrong.
|
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -625,34 +575,6 @@ in
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
bearerAuthz = lib.mkOption {
|
|
||||||
type = lib.types.bool;
|
|
||||||
default = false;
|
|
||||||
example = true;
|
|
||||||
description = ''
|
|
||||||
Grant this client the `authelia.bearer.authz` scope, so it
|
|
||||||
may present its access token to authelia's authz endpoint
|
|
||||||
and be authorised by an `access_control` rule — how a
|
|
||||||
scraper reaches a service published behind the gateway.
|
|
||||||
|
|
||||||
A named capability rather than a free-form `scopes` list,
|
|
||||||
for the same reason `kind` derives the rest: authelia
|
|
||||||
refuses some scope/grant combinations outright (`openid`
|
|
||||||
with `client_credentials` among them), and a list would
|
|
||||||
make those combinations expressible again. This admits the
|
|
||||||
one value that is legal here and nothing else.
|
|
||||||
|
|
||||||
::: {.note}
|
|
||||||
Setting this obliges two other options, and the assertions
|
|
||||||
below enforce it. Authelia checks the same thing, but only
|
|
||||||
in its `preStart` validator — which means a violation
|
|
||||||
builds and deploys cleanly and then fails to restart,
|
|
||||||
taking swarm SSO down. The assertions move that to
|
|
||||||
evaluation, where a wrong value costs nothing.
|
|
||||||
:::
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
accessTokenSignedResponseAlg = lib.mkOption {
|
accessTokenSignedResponseAlg = lib.mkOption {
|
||||||
type = lib.types.nullOr (
|
type = lib.types.nullOr (
|
||||||
lib.types.enum [
|
lib.types.enum [
|
||||||
|
|
@ -898,77 +820,6 @@ in
|
||||||
+ "services.hyperhive.swarm.hives; rename the hive or the "
|
+ "services.hyperhive.swarm.hives; rename the hive or the "
|
||||||
+ "colliding client.";
|
+ "colliding client.";
|
||||||
}
|
}
|
||||||
# The two obligations `authelia.bearer.authz` carries. Authelia
|
|
||||||
# enforces both itself — but in its `preStart` validator, so a
|
|
||||||
# violation produces a green `nixos-rebuild switch` and an authelia
|
|
||||||
# that then refuses to come back up, taking swarm SSO with it.
|
|
||||||
# Asserting here moves the same failure to evaluation, where a
|
|
||||||
# wrong value costs a build and nothing else.
|
|
||||||
{
|
|
||||||
assertion = lib.all (c: !c.bearerAuthz || c.audience != [ ]) cfg.oidc.clients;
|
|
||||||
message =
|
|
||||||
"services.hyperhive.swarm.authelia.oidc.clients: "
|
|
||||||
+ lib.concatStringsSep ", " (
|
|
||||||
map (c: "client '${c.id}'") (lib.filter (c: c.bearerAuthz && c.audience == [ ]) cfg.oidc.clients)
|
|
||||||
)
|
|
||||||
+ " sets bearerAuthz but declares no audience. The audience is "
|
|
||||||
+ "what authorises a bearer token at a given URL, so without "
|
|
||||||
+ "one the scope grants access to nothing and authelia refuses "
|
|
||||||
+ "the configuration outright.";
|
|
||||||
}
|
|
||||||
{
|
|
||||||
assertion = lib.all (
|
|
||||||
c:
|
|
||||||
!c.bearerAuthz
|
|
||||||
|| lib.elem c.tokenEndpointAuthMethod [
|
|
||||||
"client_secret_basic"
|
|
||||||
"client_secret_jwt"
|
|
||||||
"private_key_jwt"
|
|
||||||
]
|
|
||||||
) cfg.oidc.clients;
|
|
||||||
message =
|
|
||||||
"services.hyperhive.swarm.authelia.oidc.clients: "
|
|
||||||
+ lib.concatStringsSep ", " (
|
|
||||||
map (c: "client '${c.id}' (tokenEndpointAuthMethod = ${toString c.tokenEndpointAuthMethod})") (
|
|
||||||
lib.filter (
|
|
||||||
c:
|
|
||||||
c.bearerAuthz
|
|
||||||
&& !lib.elem c.tokenEndpointAuthMethod [
|
|
||||||
"client_secret_basic"
|
|
||||||
"client_secret_jwt"
|
|
||||||
"private_key_jwt"
|
|
||||||
]
|
|
||||||
) cfg.oidc.clients
|
|
||||||
)
|
|
||||||
)
|
|
||||||
+ ". A confidential client carrying authelia.bearer.authz must "
|
|
||||||
+ "authenticate with client_secret_basic, client_secret_jwt or "
|
|
||||||
+ "private_key_jwt. Notably client_secret_post is refused, and "
|
|
||||||
+ "it is what an OAuth2 client library may reach for first. "
|
|
||||||
+ "Leaving it null is refused too: authelia requires the method "
|
|
||||||
+ "to be STATED under this scope rather than defaulted.";
|
|
||||||
}
|
|
||||||
# Nothing else stops `bearerAuthz` on an interactive client, and it
|
|
||||||
# would silently do nothing: `renderClient` reads the flag only in
|
|
||||||
# the `machine` branch, so the scope is simply never emitted and the
|
|
||||||
# client authenticates fine while being authorised for nothing.
|
|
||||||
#
|
|
||||||
# That is this module's own failure mode one level up — a green
|
|
||||||
# build and a grant that does not exist — and `kind` defaults to
|
|
||||||
# `interactive`, so it is reached by FORGETTING a field rather than
|
|
||||||
# by writing a wrong one.
|
|
||||||
{
|
|
||||||
assertion = lib.all (c: !c.bearerAuthz || c.kind == "machine") cfg.oidc.clients;
|
|
||||||
message =
|
|
||||||
"services.hyperhive.swarm.authelia.oidc.clients: "
|
|
||||||
+ lib.concatStringsSep ", " (
|
|
||||||
map (c: "client '${c.id}'") (lib.filter (c: c.bearerAuthz && c.kind != "machine") cfg.oidc.clients)
|
|
||||||
)
|
|
||||||
+ " sets bearerAuthz with kind != \"machine\". The scope is only "
|
|
||||||
+ "emitted for machine clients, so this grants nothing while "
|
|
||||||
+ "evaluating and deploying cleanly. A browser client has a user "
|
|
||||||
+ "to authorise and does not need it.";
|
|
||||||
}
|
|
||||||
];
|
];
|
||||||
|
|
||||||
# Authelia's own gateway surface: the vhost that fronts it and the
|
# Authelia's own gateway surface: the vhost that fronts it and the
|
||||||
|
|
@ -1354,10 +1205,32 @@ in
|
||||||
# The metrics rule is listed first so it cannot be shadowed
|
# The metrics rule is listed first so it cannot be shadowed
|
||||||
# by a broader domain rule added later.
|
# by a broader domain rule added later.
|
||||||
rules =
|
rules =
|
||||||
# Denied or client-scoped depending on whether a
|
# The forge's metrics endpoint. `deny` is deliberate and
|
||||||
# collector is registered — see `metricsRule` above,
|
# is the whole protection right now: the endpoint is
|
||||||
# which is where the reasoning for both halves lives.
|
# always served (a swarm-integrated forge always has
|
||||||
lib.optional forgeCfg.behindGateway metricsRule
|
# metrics), and `default_policy` is `one_factor`, which
|
||||||
|
# means *any* authenticated subject — every operator
|
||||||
|
# today, every agent once they hold authelia accounts.
|
||||||
|
#
|
||||||
|
# Being reachable by a Bearer token is not sufficient on
|
||||||
|
# its own: `authn_strategies` on this endpoint also
|
||||||
|
# accepts `CookieSession`, and a cookie carries no
|
||||||
|
# audience, so the audience is not what stands between a
|
||||||
|
# browser session and this data.
|
||||||
|
#
|
||||||
|
# The collector gets in by REPLACING this with a
|
||||||
|
# client-scoped allow (`subject = ["oauth2:client:<id>"]`)
|
||||||
|
# once such a client is registered. Denying until then is
|
||||||
|
# what makes publishing the endpoint safe on its own —
|
||||||
|
# authelia refuses a subject naming a client that is not
|
||||||
|
# registered, and it does so in a `preStart` validator,
|
||||||
|
# so naming one early takes the whole SSO service down on
|
||||||
|
# the next restart rather than failing the build.
|
||||||
|
lib.optional forgeCfg.behindGateway {
|
||||||
|
domain = forgeCfg.domain;
|
||||||
|
resources = [ "^/metrics$" ];
|
||||||
|
policy = "deny";
|
||||||
|
}
|
||||||
++ lib.optional uiCfg.enable {
|
++ lib.optional uiCfg.enable {
|
||||||
domain = uiCfg.domain;
|
domain = uiCfg.domain;
|
||||||
subject = [ "group:${operatorGroup}" ];
|
subject = [ "group:${operatorGroup}" ];
|
||||||
|
|
|
||||||
|
|
@ -58,43 +58,6 @@ let
|
||||||
# swarm-tier pipeline would be added here and inherit the check for free.
|
# swarm-tier pipeline would be added here and inherit the check for free.
|
||||||
reservedOwners = [ swarmTierName ];
|
reservedOwners = [ swarmTierName ];
|
||||||
|
|
||||||
# A published target is declared as ONE url, because that url is also the
|
|
||||||
# audience its token is minted for — but prometheus wants the same fact in
|
|
||||||
# three fields. Split it here rather than asking a service to state it
|
|
||||||
# twice: two spellings of one address is a mismatch waiting to happen, and
|
|
||||||
# the failure is a valid token refused at the target.
|
|
||||||
#
|
|
||||||
# `null` when the shape is wrong, which the assertion below reports by name.
|
|
||||||
# ⚠️ The pattern requires `https`. A published endpoint reached over plain
|
|
||||||
# http would ship a bearer token in clear text, and that is worth an eval
|
|
||||||
# error rather than a warning nobody reads.
|
|
||||||
parsePublished = url: builtins.match "https://([^/]+)(/.*)" url;
|
|
||||||
|
|
||||||
# The client secret takes three names, and the reason is `DynamicUser`.
|
|
||||||
#
|
|
||||||
# Upstream's collector unit runs with `DynamicUser = true`, and the
|
|
||||||
# prometheus receiver opens `client_secret_file` ITSELF, at runtime, as that
|
|
||||||
# user — so there is no stable uid to hand a file to, and the root-owned
|
|
||||||
# 0400 shape `hive-matrix-oidc-secret` delivers to would be unreadable.
|
|
||||||
# (Matrix gets away with it because `LoadCredential` reads the file as root
|
|
||||||
# before the sandbox exists, and tuwunel never opens that path itself.)
|
|
||||||
#
|
|
||||||
# `LoadCredential` solves both halves: systemd reads the file as root and
|
|
||||||
# re-exposes it to the dynamic user under a path that does not depend on
|
|
||||||
# which uid it turned out to be.
|
|
||||||
#
|
|
||||||
# At rest in the container's tree — written by the host oneshot below.
|
|
||||||
# Under /var/lib and not /run because the collector may start before the
|
|
||||||
# delivery unit on a later boot, and a secret that evaporates on reboot
|
|
||||||
# turns a working scrape into an intermittent one.
|
|
||||||
collectorSecretInContainer = "/var/lib/swarm-otel-oidc/${cfg.clientId}.secret";
|
|
||||||
collectorCredentialId = "oidc-client-secret";
|
|
||||||
# What the scrape config points at. ⚠️ This path and the `LoadCredential`
|
|
||||||
# id below are one fact spelled twice by systemd's design — both derive from
|
|
||||||
# `collectorCredentialId` so they cannot drift; a mismatch is a file the
|
|
||||||
# collector cannot open, discovered at runtime and nowhere else.
|
|
||||||
collectorSecretPath = "/run/credentials/opentelemetry-collector.service/${collectorCredentialId}";
|
|
||||||
|
|
||||||
# `attrNames` is sorted, so this is a function of the hive SET and not of
|
# `attrNames` is sorted, so this is a function of the hive SET and not of
|
||||||
# the order anyone wrote it in.
|
# the order anyone wrote it in.
|
||||||
#
|
#
|
||||||
|
|
@ -260,56 +223,6 @@ in
|
||||||
scrape is the inert configuration this option exists to avoid.
|
scrape is the inert configuration this option exists to avoid.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
publishedScrapeTargets = lib.mkOption {
|
|
||||||
type = lib.types.attrsOf lib.types.str;
|
|
||||||
default = { };
|
|
||||||
example = lib.literalExpression ''{ forge = "https://forge.example.com/metrics"; }'';
|
|
||||||
description = ''
|
|
||||||
Prometheus endpoints this collector scrapes **by name, with a
|
|
||||||
credential**, as `<job name> = "<url>"`.
|
|
||||||
|
|
||||||
A service module declares its own entry, from its own module, the
|
|
||||||
same way it does for `scrapeTargets` — and the collector's OAuth2
|
|
||||||
client derives its permitted audiences from these URLs, so a
|
|
||||||
target and the authorisation to reach it are **one declaration**.
|
|
||||||
Two lists that must agree would be a drift to maintain, and its
|
|
||||||
failure mode is the bad one: a target whose audience was forgotten
|
|
||||||
authenticates against nothing and reads as a scrape failure rather
|
|
||||||
than a config mistake.
|
|
||||||
|
|
||||||
⚠️ A **full URL**, not `host:port`. Authelia validates a bearer
|
|
||||||
token against the address being requested, so the string here is
|
|
||||||
also the audience the token is minted for; a near miss (a trailing
|
|
||||||
slash, `http` for `https`) presents as a valid token rejected at
|
|
||||||
the target, several layers from its cause.
|
|
||||||
|
|
||||||
⚠️ Deliberately separate from `scrapeTargets`. An entry there is
|
|
||||||
trusted because the scraper and the target share a host — that
|
|
||||||
option is loopback-and-unauthenticated by contract. An entry here
|
|
||||||
is trusted because it presents a credential. One shape for both
|
|
||||||
would leave a reader unable to tell which of those a given target
|
|
||||||
relies on.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
clientId = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
readOnly = true;
|
|
||||||
default = "swarm-collector";
|
|
||||||
description = ''
|
|
||||||
OAuth2 client id this collector authenticates as. Published so
|
|
||||||
authelia's `access_control` rules can name it without carrying a
|
|
||||||
second copy of the string, exactly as
|
|
||||||
`services.hyperhive.swarm.authelia.hiveClientPrefix` is published
|
|
||||||
for the queue's responder.
|
|
||||||
|
|
||||||
Two spellings drifting apart is not a build failure: authelia
|
|
||||||
refuses a rule naming an unregistered client in its startup
|
|
||||||
validator, so the swarm's SSO service fails to *restart* — long
|
|
||||||
after the change that caused it evaluated cleanly.
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
config = lib.mkIf (config.services.hyperhive.enable && cfg.enable) {
|
config = lib.mkIf (config.services.hyperhive.enable && cfg.enable) {
|
||||||
|
|
@ -354,106 +267,6 @@ in
|
||||||
# telemetry.
|
# telemetry.
|
||||||
services.hyperhive.swarm.authelia.oidc.hiveIdentities = true;
|
services.hyperhive.swarm.authelia.oidc.hiveIdentities = true;
|
||||||
|
|
||||||
# The collector's own identity, for the other direction: the hive
|
|
||||||
# identities above are how this collector authenticates its *callers*,
|
|
||||||
# this is how it authenticates *itself* to a service published behind
|
|
||||||
# the gateway.
|
|
||||||
#
|
|
||||||
# Only where authelia is co-located. A client is a row in this host's
|
|
||||||
# provider config, so declaring one against a remote provider would
|
|
||||||
# render nothing while reading as done; a swarm whose authelia lives
|
|
||||||
# elsewhere registers it there.
|
|
||||||
#
|
|
||||||
# ⚠️ Also conditional on there being a published target, and that is
|
|
||||||
# the shipped case rather than an edge — nothing declares one until
|
|
||||||
# some service publishes behind the gateway. Authelia refuses a
|
|
||||||
# bearer-authz client with no audience, so an unconditional
|
|
||||||
# declaration would break every hive that runs a collector and
|
|
||||||
# publishes nothing.
|
|
||||||
services.hyperhive.swarm.authelia.oidc.clients =
|
|
||||||
lib.mkIf (autheliaCfg.enable && cfg.publishedScrapeTargets != { })
|
|
||||||
[
|
|
||||||
{
|
|
||||||
id = cfg.clientId;
|
|
||||||
description = "HyperHive swarm collector";
|
|
||||||
kind = "machine";
|
|
||||||
# Grants `authelia.bearer.authz`, without which the authz
|
|
||||||
# endpoint refuses an otherwise valid token and blames the
|
|
||||||
# token rather than the missing grant.
|
|
||||||
bearerAuthz = true;
|
|
||||||
# DERIVED from the targets rather than contributed alongside
|
|
||||||
# them. A service declares a URL once and this is the
|
|
||||||
# permission to reach it; two lists that had to agree would be
|
|
||||||
# a drift to maintain, and the failure mode is the quiet one —
|
|
||||||
# a target whose audience was forgotten authenticates against
|
|
||||||
# nothing and looks like a broken scrape.
|
|
||||||
audience = lib.attrValues cfg.publishedScrapeTargets;
|
|
||||||
# Stated rather than left on authelia's default, because the
|
|
||||||
# two agreeing today is not the same as this being the
|
|
||||||
# required value: authelia permits only basic / JWT methods
|
|
||||||
# for a confidential client holding that scope, and enforces
|
|
||||||
# it in the startup validator.
|
|
||||||
tokenEndpointAuthMethod = "client_secret_basic";
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
# Deliver the collector's client secret from authelia's container into
|
|
||||||
# this one. On the HOST because that is the only place both container
|
|
||||||
# trees are addressable: they share this host's network namespace, which
|
|
||||||
# makes them feel co-located, but their filesystem roots are separate.
|
|
||||||
#
|
|
||||||
# ⚠️ Deliberately a copy and not a `bindMounts` entry. nixos-container
|
|
||||||
# refuses to start when a bind source is missing, and this secret does not
|
|
||||||
# exist until authelia's first boot has minted it — so binding it would
|
|
||||||
# make the collector wait on a file that waits on a container that starts
|
|
||||||
# after it. On a fresh swarm that is a permanent stall presenting as
|
|
||||||
# "metrics are broken", several layers from its cause.
|
|
||||||
systemd.services.swarm-otel-oidc-secret =
|
|
||||||
lib.mkIf (autheliaCfg.enable && cfg.publishedScrapeTargets != { })
|
|
||||||
{
|
|
||||||
description = "deliver the swarm collector's OIDC client secret from authelia";
|
|
||||||
after = [ "container@${autheliaCfg.machine}.service" ];
|
|
||||||
requires = [ "container@${autheliaCfg.machine}.service" ];
|
|
||||||
before = [ "container@${cfg.machine}.service" ];
|
|
||||||
wantedBy = [ "container@${cfg.machine}.service" ];
|
|
||||||
serviceConfig = {
|
|
||||||
Type = "oneshot";
|
|
||||||
RemainAfterExit = true;
|
|
||||||
SyslogIdentifier = "swarm-otel-oidc-secret";
|
|
||||||
# Longer than the bounded wait below, and that is the point:
|
|
||||||
# `DefaultTimeoutStartSec` is 90s, so without this systemd kills
|
|
||||||
# the unit before it can emit the message naming the file it was
|
|
||||||
# waiting for — the failure then reads as a timeout with no cause.
|
|
||||||
TimeoutStartSec = "180s";
|
|
||||||
};
|
|
||||||
path = [ pkgs.coreutils ];
|
|
||||||
script = ''
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
src=${lib.escapeShellArg "${autheliaCfg.hostClientSecretDir}/${cfg.clientId}.secret"}
|
|
||||||
dst=${lib.escapeShellArg "/var/lib/nixos-containers/${cfg.machine}${collectorSecretInContainer}"}
|
|
||||||
|
|
||||||
# authelia's container is up, but its first-boot generator may
|
|
||||||
# still be minting. Bounded wait, then fail: skipping silently
|
|
||||||
# produces a collector whose scrape gets a 401 forever, which is
|
|
||||||
# the failure this whole design exists to make impossible.
|
|
||||||
for _ in $(seq 1 60); do
|
|
||||||
[ -s "$src" ] && break
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
if [ ! -s "$src" ]; then
|
|
||||||
echo "authelia has not minted $src after 120s" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# root-owned 0400. The collector runs under `DynamicUser`, so
|
|
||||||
# there is no uid to give it to — `LoadCredential` reads this as
|
|
||||||
# root before the sandbox exists and re-exposes it to whichever
|
|
||||||
# uid the unit got.
|
|
||||||
install -D -m 0400 -o root -g root "$src" "$dst"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
|
|
||||||
# The CA bind source is written at runtime by a host unit, so the
|
# The CA bind source is written at runtime by a host unit, so the
|
||||||
# container has to start after it — otherwise nspawn sets up a mount
|
# container has to start after it — otherwise nspawn sets up a mount
|
||||||
# over a file that does not exist yet.
|
# over a file that does not exist yet.
|
||||||
|
|
@ -515,59 +328,6 @@ in
|
||||||
Rename the hive. Reserved: ${lib.concatMapStringsSep ", " (n: "'${n}'") reservedOwners}.
|
Rename the hive. Reserved: ${lib.concatMapStringsSep ", " (n: "'${n}'") reservedOwners}.
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
{
|
|
||||||
# A published target that is not an `https://host/path` url. Without
|
|
||||||
# this the split returns null and the failure surfaces as
|
|
||||||
# `attempt to call elemAt on null` from inside the renderer, naming
|
|
||||||
# neither the option nor the offending value.
|
|
||||||
#
|
|
||||||
# It also enforces the scheme: a published endpoint scraped over plain
|
|
||||||
# http would put a bearer token on the wire in clear text.
|
|
||||||
assertion = lib.all (u: parsePublished u != null) (lib.attrValues cfg.publishedScrapeTargets);
|
|
||||||
message = ''
|
|
||||||
services.hyperhive.swarm.otel.publishedScrapeTargets has ${
|
|
||||||
lib.concatMapStringsSep ", " (kv: "${kv.name} = \"${kv.value}\"") (
|
|
||||||
lib.filter (kv: parsePublished kv.value == null) (lib.attrsToList cfg.publishedScrapeTargets)
|
|
||||||
)
|
|
||||||
}, which is not of the form https://<host>/<path>.
|
|
||||||
|
|
||||||
The value is both the address scraped and the audience the
|
|
||||||
collector's token is minted for, so it has to be the exact url a
|
|
||||||
request goes to. https is required: this hop carries a bearer
|
|
||||||
token, and plain http would put it on the wire in clear text.
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
{
|
|
||||||
# A job name used by BOTH scrape options. Within one option this
|
|
||||||
# cannot happen — the module system refuses two definitions of the
|
|
||||||
# same key with different values, measured — but the two options
|
|
||||||
# are separate, so nothing arbitrates between them, and they
|
|
||||||
# render into a single `scrape_configs` LIST where nothing
|
|
||||||
# overwrites anything: both entries ship under one `job_name`.
|
|
||||||
#
|
|
||||||
# The message names which side each collision came from, which is
|
|
||||||
# the part a rendered-config error could not tell an operator.
|
|
||||||
assertion =
|
|
||||||
lib.intersectLists (lib.attrNames cfg.scrapeTargets) (lib.attrNames cfg.publishedScrapeTargets)
|
|
||||||
== [ ];
|
|
||||||
message = ''
|
|
||||||
services.hyperhive.swarm.otel: ${
|
|
||||||
lib.concatMapStringsSep ", " (j: "'${j}'") (
|
|
||||||
lib.intersectLists (lib.attrNames cfg.scrapeTargets) (lib.attrNames cfg.publishedScrapeTargets)
|
|
||||||
)
|
|
||||||
} is declared as both a loopback
|
|
||||||
scrapeTargets job and a publishedScrapeTargets job.
|
|
||||||
|
|
||||||
They render into one prometheus scrape_configs list, so both
|
|
||||||
entries would ship under the same job_name — nothing overwrites
|
|
||||||
anything, and the two are scraped by different rules with
|
|
||||||
different trust.
|
|
||||||
|
|
||||||
Rename one. A target is either reachable on loopback because it
|
|
||||||
shares this host, or published and reached with a credential; it
|
|
||||||
should not be described as both.
|
|
||||||
'';
|
|
||||||
}
|
|
||||||
{
|
{
|
||||||
# A hive proves who it is with a token this provider mints, so
|
# A hive proves who it is with a token this provider mints, so
|
||||||
# there is no version of this collector that runs without one.
|
# there is no version of this collector that runs without one.
|
||||||
|
|
@ -716,48 +476,11 @@ in
|
||||||
# target: a `prometheus` receiver with nothing to scrape is
|
# target: a `prometheus` receiver with nothing to scrape is
|
||||||
# the shape this whole issue is about, a config that renders
|
# the shape this whole issue is about, a config that renders
|
||||||
# and deploys perfectly while adding no data.
|
# and deploys perfectly while adding no data.
|
||||||
// lib.optionalAttrs (cfg.scrapeTargets != { } || cfg.publishedScrapeTargets != { }) {
|
// lib.optionalAttrs (cfg.scrapeTargets != { }) {
|
||||||
prometheus.config.scrape_configs =
|
prometheus.config.scrape_configs = lib.mapAttrsToList (job: target: {
|
||||||
lib.mapAttrsToList (job: target: {
|
job_name = job;
|
||||||
job_name = job;
|
static_configs = [ { targets = [ target ]; } ];
|
||||||
static_configs = [ { targets = [ target ]; } ];
|
}) cfg.scrapeTargets;
|
||||||
}) cfg.scrapeTargets
|
|
||||||
# ⚠️ `++`, so the two kinds of target land in ONE list —
|
|
||||||
# which is exactly why a job name may not appear in both
|
|
||||||
# options. A list concatenation does not resolve a
|
|
||||||
# collision the way an attrset would: both entries ship
|
|
||||||
# under the same `job_name`. The assertion above is what
|
|
||||||
# stands between that and a deploy.
|
|
||||||
++ lib.mapAttrsToList (
|
|
||||||
job: url:
|
|
||||||
let
|
|
||||||
parts = parsePublished url;
|
|
||||||
in
|
|
||||||
{
|
|
||||||
job_name = job;
|
|
||||||
# Split from the declared url — see `parsePublished`.
|
|
||||||
scheme = "https";
|
|
||||||
static_configs = [ { targets = [ (lib.elemAt parts 0) ]; } ];
|
|
||||||
metrics_path = lib.elemAt parts 1;
|
|
||||||
# Prometheus-native oauth2, not the collector's
|
|
||||||
# `oauth2client` extension: the prometheus receiver
|
|
||||||
# takes upstream's scrape config verbatim and does not
|
|
||||||
# accept an `auth` block naming a collector extension.
|
|
||||||
oauth2 = {
|
|
||||||
client_id = cfg.clientId;
|
|
||||||
# A path, never a value — nothing here may read the
|
|
||||||
# secret, or it lands in the store world-readable.
|
|
||||||
client_secret_file = collectorSecretPath;
|
|
||||||
token_url = "${autheliaCfg.url}/api/oidc/token";
|
|
||||||
# The audience is the target's own url, and authelia
|
|
||||||
# checks it against the address being requested.
|
|
||||||
# Registered ≠ requested: a client that does not ASK
|
|
||||||
# for an audience gets a token with `aud: []` however
|
|
||||||
# complete its registration looks.
|
|
||||||
endpoint_params.audience = url;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
) cfg.publishedScrapeTargets;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
exporters =
|
exporters =
|
||||||
|
|
@ -939,20 +662,10 @@ in
|
||||||
# environment variable without being read by nix, written to the
|
# environment variable without being read by nix, written to the
|
||||||
# store, or passed in argv.
|
# store, or passed in argv.
|
||||||
systemd.services.opentelemetry-collector.serviceConfig =
|
systemd.services.opentelemetry-collector.serviceConfig =
|
||||||
lib.optionalAttrs (otelCfg.headersCredential != null) {
|
lib.optionalAttrs (otelCfg.headersCredential != null)
|
||||||
EnvironmentFile = otelCfg.headersCredential;
|
{
|
||||||
}
|
EnvironmentFile = otelCfg.headersCredential;
|
||||||
# The other credential, and the other direction: the one above
|
};
|
||||||
# authenticates this collector's export onward, this one
|
|
||||||
# authenticates it to a service it scrapes.
|
|
||||||
#
|
|
||||||
# ⚠️ Only where a published target exists. `LoadCredential` on a
|
|
||||||
# missing source is a unit that refuses to start, so declaring it
|
|
||||||
# unconditionally would take the collector down on every hive that
|
|
||||||
# scrapes nothing published — the empty case is the shipped one.
|
|
||||||
// lib.optionalAttrs (cfg.publishedScrapeTargets != { }) {
|
|
||||||
LoadCredential = [ "${collectorCredentialId}:${collectorSecretInContainer}" ];
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue