diff --git a/nix/host-modules/hive-forge/default.nix b/nix/host-modules/hive-forge/default.nix index 2d937ba4..63d2b173 100644 --- a/nix/host-modules/hive-forge/default.nix +++ b/nix/host-modules/hive-forge/default.nix @@ -512,9 +512,14 @@ in # follow it and parse an HTML page as metrics. A machine-facing # location lets the 401 reach the client unchanged. "= /__forge_metrics_authz" = { - proxyPass = "http://127.0.0.1:${toString autheliaCfg.port}/api/authz/auth-request"; + # By NAME, not `127.0.0.1`: this gateway and authelia are not + # required to share a host, so the loopback literal encoded a + # co-location nobody had agreed to. The name resolves here + # today and resolves off-host later without this line moving. + proxyPass = "https://${autheliaCfg.domain}/api/authz/auth-request"; extraConfig = '' internal; + ${gatewayCfg.lib.verifiedProxyTo autheliaCfg.domain} proxy_pass_request_body off; proxy_set_header Content-Length ""; proxy_set_header X-Original-Method $request_method; diff --git a/nix/host-modules/hive-gateway/default.nix b/nix/host-modules/hive-gateway/default.nix index e91d732c..2adbbb1e 100644 --- a/nix/host-modules/hive-gateway/default.nix +++ b/nix/host-modules/hive-gateway/default.nix @@ -90,6 +90,11 @@ let swarmServiceDomains errorPages ; + # Same path `lib/hive-ca-trust.nix` publishes as `caHostPath`. Named + # from the tls option here rather than importing that helper: this + # is the HOST's own bundle, and the helper's job is assembling a + # per-container copy. + caBundle = "${config.services.hyperhive.tls.stateDir}/trust-bundle.pem"; }; nginxTree = import ./vhosts.nix { diff --git a/nix/host-modules/hive-gateway/options.nix b/nix/host-modules/hive-gateway/options.nix index ae9c7b8b..f0199342 100644 --- a/nix/host-modules/hive-gateway/options.nix +++ b/nix/host-modules/hive-gateway/options.nix @@ -172,6 +172,29 @@ in ''; }; + verifiedProxyTo = lib.mkOption { + type = lib.types.functionTo lib.types.lines; + internal = true; + readOnly = true; + description = '' + Read-only: `name -> the proxy_ssl_* lines` for dialling + another service on this hive **by name over https**, with the + certificate actually verified against the hive CA bundle. + + Published rather than written per module because nginx + verifies nothing by default: `proxy_ssl_verify` is **off**, so + a `proxy_pass https://…` that omits these is encrypted and + unauthenticated — which is the half of "https and auth" that + is easy to believe you already have. + + Measured, not copied: verification is confirmed to check both + the chain and the **hostname**, and the CA *bundle* (root + + intermediate) is confirmed to be accepted — the bundle's own + doc warns off consumers that read only one certificate, and + nginx is not one of those. + ''; + }; + securityHeaders = lib.mkOption { type = lib.types.lines; internal = true; diff --git a/nix/host-modules/hive-gateway/vhost-lib.nix b/nix/host-modules/hive-gateway/vhost-lib.nix index 060c14c1..74212d4e 100644 --- a/nix/host-modules/hive-gateway/vhost-lib.nix +++ b/nix/host-modules/hive-gateway/vhost-lib.nix @@ -23,6 +23,7 @@ svcKey, swarmServiceDomains, # which names those are (../swarm.nix derives it) errorPages, # ./error-pages.nix: { notFound, unreachable, unauthorized, ssoUnavailable } + caBundle, # hive CA trust bundle on the host (root + intermediate) }: let # nixos `services.nginx.virtualHosts.` ssl attrs for a vhost @@ -85,6 +86,40 @@ in else vhostTls; + # Dial another service on this hive BY NAME over https, verified. + # + # One definition rather than a copy per module: nginx verifies + # nothing by default (`proxy_ssl_verify` is off), so a `proxy_pass + # https://…` without these lines is encrypted and unauthenticated. + # That failure is invisible — it works, and keeps working, against + # any certificate at all. + # + # Every line earns its place, each confirmed against a real nginx + # with the opposite arm run as a control: + # verify + depth — the chain is leaf -> intermediate -> root + # trusted_cert — the bundle; nginx reads ALL certs in the file, + # which the bundle's own doc warns is not true of + # every consumer + # ssl_name — checks the HOSTNAME too. Without it a chain-only + # check accepts any certificate this CA ever + # signed, and for an internal CA that is every + # service on the hive + # server_name on — sends SNI, or the far end cannot pick a cert + # + # ⚠️ `proxy_ssl_session_reuse` is left at its default (on) and that is + # deliberate: this is used on per-request auth subrequests, so the + # handshake it avoids is paid on every request. Worth knowing when + # testing though — the session cache is keyed by upstream address and + # NOT by trust config, so two locations pointing at one upstream with + # different trust do not verify independently. + verifiedProxyTo = name: '' + proxy_ssl_verify on; + proxy_ssl_verify_depth 3; + proxy_ssl_trusted_certificate ${caBundle}; + proxy_ssl_name ${name}; + proxy_ssl_server_name on; + ''; + # Security headers added at the server scope on every vhost. # nginx's add_header inheritance rule: a location that defines its # own add_header does NOT inherit the server-level ones. Any diff --git a/nix/host-modules/swarm-ui.nix b/nix/host-modules/swarm-ui.nix index 3968438b..7ac10f1c 100644 --- a/nix/host-modules/swarm-ui.nix +++ b/nix/host-modules/swarm-ui.nix @@ -272,8 +272,11 @@ in # `/__hive_authelia` below, just not `internal` since the # frontend calls this one directly. "= /api/whoami" = { - proxyPass = "http://127.0.0.1:${toString autheliaCfg.port}/api/user/info"; - extraConfig = swarmAuthRequest; + proxyPass = "https://${autheliaCfg.domain}/api/user/info"; + extraConfig = '' + ${swarmAuthRequest} + ${gatewayCfg.lib.verifiedProxyTo autheliaCfg.domain} + ''; }; "/api/docs/" = { alias = "${gatewayCfg.swaggerUiTheme}/"; @@ -292,9 +295,10 @@ in # `X-Forwarded-Uri` does not appear in it at all, so sending it # would look like configuration and be dead weight. "= /__hive_authelia" = { - proxyPass = "http://127.0.0.1:${toString autheliaCfg.port}/api/authz/auth-request"; + proxyPass = "https://${autheliaCfg.domain}/api/authz/auth-request"; extraConfig = '' internal; + ${gatewayCfg.lib.verifiedProxyTo autheliaCfg.domain} # A subrequest carries no body, and forwarding one here makes # authelia read a payload it will never use. proxy_pass_request_body off; diff --git a/nix/host-modules/swarm-victorialogs.nix b/nix/host-modules/swarm-victorialogs.nix index 01dc5377..39b1b44f 100644 --- a/nix/host-modules/swarm-victorialogs.nix +++ b/nix/host-modules/swarm-victorialogs.nix @@ -182,9 +182,10 @@ in # reasoning as `swarm-ui.nix`'s own copy (measured against the # pinned authelia binary, not copied from an example). "= /__hive_authelia" = { - proxyPass = "http://127.0.0.1:${toString autheliaCfg.port}/api/authz/auth-request"; + proxyPass = "https://${autheliaCfg.domain}/api/authz/auth-request"; extraConfig = '' internal; + ${gatewayCfg.lib.verifiedProxyTo autheliaCfg.domain} proxy_pass_request_body off; proxy_set_header Content-Length ""; proxy_set_header X-Original-Method $request_method;