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:
parent
b8af1525ff
commit
9ad4db842a
1 changed files with 92 additions and 8 deletions
|
|
@ -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;
|
||||
}
|
||||
)
|
||||
)
|
||||
];
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue