diff --git a/nix/host-modules/otel.nix b/nix/host-modules/otel.nix index 79154cd0..66d89855 100644 --- a/nix/host-modules/otel.nix +++ b/nix/host-modules/otel.nix @@ -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; + } + ) + ) + ]; }