feat(otel): the collector unit, off by default

Renders services.opentelemetry-collector on the host: OTLP/HTTP receiver
on the bridge address, otlphttp exporter to otel.endpoint, and the
upstream credential delivered as EnvironmentFile so the collector
interpolates it at runtime and nix never sees the value.

Three things worth knowing, each measured rather than assumed:

- network.exposeHostPorts already exists and is wired (it opens the port
  on the bridge interface only), so bridge reachability costs nothing.
- validateConfigFile defaults to isStorePath configFile, which is null on
  the settings path - so upstream's default is OFF for exactly the way
  this module configures it. Set true: it runs otelcol validate at build
  time. It parses, it does not prove delivery.
- headersCredential's file is already NAME=value, i.e. EnvironmentFile
  format, verified against a real settings.json rather than the doc.

An assertion refuses collector.enable with no headersCredential: the
collector exists to be the only holder of that token, and without one it
is indirection that reads as security.
This commit is contained in:
atlas 2026-08-15 01:19:30 +02:00 committed by mara
commit 9ad4db842a

View file

@ -113,6 +113,26 @@
'';
};
collector.upstreamHeaderName = lib.mkOption {
type = lib.types.str;
default = "Authorization";
description = ''
Name of the HTTP header the collector sends upstream, whose
*value* comes from `headersCredential`.
The name is here and the value is not, and that split is forced
rather than chosen: the collector models exporter headers as a
static map, so rendering them means nix reading the value the
one thing `headersCredential` being a path exists to prevent.
A name is public, a value is not.
exactly one header is expressible this way. A credential
carrying several (`a=1,b=2`) would be read as a single value,
which is why the shape is a named header rather than an opaque
blob: a second header has to be *declared*, not smuggled.
'';
};
collector.port = lib.mkOption {
type = lib.types.port;
default = 4318;
@ -146,12 +166,76 @@
};
};
config = lib.mkIf config.services.hyperhive.c0re.enable {
assertions = lib.optionals config.services.hyperhive.otel.enable [
{
assertion = config.services.hyperhive.otel.endpoint != "";
message = "services.hyperhive.otel.enable is true but services.hyperhive.otel.endpoint is empty.";
}
];
};
config = lib.mkMerge [
(lib.mkIf config.services.hyperhive.c0re.enable {
assertions = lib.optionals config.services.hyperhive.otel.enable [
{
assertion = config.services.hyperhive.otel.endpoint != "";
message = "services.hyperhive.otel.enable is true but services.hyperhive.otel.endpoint is empty.";
}
];
})
(lib.mkIf (config.services.hyperhive.otel.enable && config.services.hyperhive.otel.collector.enable)
(
let
otel = config.services.hyperhive.otel;
listen = "${config.services.hyperhive.network.bridgeIp}:${toString otel.collector.port}";
in
{
assertions = [
{
# The collector's whole purpose is to hold the credential so
# agents do not have to. With none configured it is pure
# indirection, and the operator has almost certainly not got
# the deployment they think they have.
assertion = otel.headersCredential != null;
message = ''
services.hyperhive.otel.collector.enable is true but
otel.headersCredential is null. The collector exists to be
the only holder of the upstream credential; with no
credential it just forwards, and every agent's exporter
would be unauthenticated end to end.
'';
}
];
# Reachable from agent containers and nowhere else: this opens
# the port on the bridge interface only.
services.hyperhive.network.exposeHostPorts = [ otel.collector.port ];
services.opentelemetry-collector = {
enable = true;
# `validateConfigFile` defaults to `isStorePath configFile`,
# and `configFile` is null on the `settings` path — so the
# upstream default is OFF for exactly the way this module
# configures it. Turning it on runs `otelcol validate` at
# build time, which is the collector checking its own config.
# ⚠️ It parses; it does not prove a sample arrives.
validateConfigFile = true;
settings = {
receivers.otlp.protocols.http.endpoint = listen;
exporters.otlphttp = {
endpoint = otel.endpoint;
# The value is interpolated by the collector at runtime
# from its environment, never by nix. `EnvironmentFile`
# below is what puts it there.
headers.${otel.collector.upstreamHeaderName} = "\${env:${otel.collector.upstreamHeaderName}}";
};
service.pipelines.metrics = {
receivers = [ "otlp" ];
exporters = [ "otlphttp" ];
};
};
};
# The credential file is already `NAME=value`, which is
# systemd's EnvironmentFile format — so the secret reaches the
# process as an environment variable without ever being read by
# nix, written to the store, or passed in argv.
systemd.services.opentelemetry-collector.serviceConfig.EnvironmentFile = otel.headersCredential;
}
)
)
];
}