hyperhive/nix/host-modules/hive-gateway/vhost-lib.nix
atlas 43ae164d8b gateway: dial swarm services by name over verified TLS
Consumers reached authelia at `127.0.0.1:<port>`, which encoded a
co-location nobody agreed to: the gateway and authelia are not required
to share a host, so the literal is a requirement stated only by being
unwriteable any other way. Moving them to the name is the point of the
issue.

But a name over https is only half of "https and auth". nginx's
`proxy_ssl_verify` is OFF by default and there was no `proxy_ssl_*`
anywhere in the tree, so the obvious repoint would have produced an
encrypted, unauthenticated hop -- which works, and keeps working,
against any certificate at all.

Adds `gateway.lib.verifiedProxyTo <name>` next to the rest of the vhost
kit, so the convention has one definition rather than a copy in each
consuming module, and repoints the four call sites through it.

Each directive was checked against a real nginx with the opposite arm
run as a control:

  - the CA *bundle* (root + intermediate) is accepted -- worth checking,
    since `hive-ca-trust.nix` warns off consumers that read only one
    certificate, and nginx is not one of those
  - verification checks the chain: an unrelated CA fails
  - and the HOSTNAME: a wrong `proxy_ssl_name` fails even with a good
    chain. Chain-only would accept any cert this CA ever signed, which
    for an internal CA is every service on the hive
  - with verify off, the wrong CA passes -- so the failures above come
    from verification, not from the connection

Bind addresses are untouched. This changes what consumers dial, not what
anything listens on.
2026-08-27 16:36:03 +02:00

144 lines
5.8 KiB
Nix

# The gateway's vhost construction kit: the listen set, the per-name TLS
# attrs, and the security headers every vhost in front of this gateway
# needs.
#
# Split out of ./vhosts.nix so it has one home and two consumers. Today
# only ./vhosts.nix reads it; it is published as
# `services.hyperhive.gateway.lib` (see ./options.nix) so a service
# module can declare its OWN vhost without the gateway having to know
# that service by name. `vhosts.nix` reads the published value rather
# than calling this file directly — one source, not a copy that agrees
# by inspection.
#
# Pure function of the gateway's config + resolved cert paths; returns
# an attrset, evaluates no options itself. Everything here is *gateway*
# knowledge — which port pair to bind, which issuer covers a name — and
# stays here even once the service vhosts move out to their own modules.
{
lib,
cfg, # services.hyperhive.gateway
tlsCert,
tlsKey,
svcCert, # swarm-services leaf, for names the hive CA cannot sign
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.<name>` ssl attrs for a vhost
# covered by the hive's own cert. For ACME mode: `enableACME` +
# `addSSL` — NixOS's ACME integration manages the cert lifecycle and
# sets ssl_certificate automatically. For self-signed / certDir:
# explicit cert paths.
vhostTls =
if cfg.tls.acme.enable then
{
addSSL = true;
enableACME = true;
}
else
{
addSSL = true;
sslCertificate = tlsCert;
sslCertificateKey = tlsKey;
};
hstsDirectives = lib.concatStringsSep "; " (
[ "max-age=${toString cfg.hsts.maxAge}" ]
++ lib.optional cfg.hsts.includeSubDomains "includeSubDomains"
);
in
{
# The gateway always terminates TLS: self-signed is the implicit
# floor when neither `tls.certDir` nor ACME is set, so there is no
# http-only mode. Listen addresses every vhost shares — plain http
# on `cfg.port` plus TLS on `cfg.httpsPort`. See `docs/gateway.md`
# ("TLS modes").
listen = [
{
addr = "0.0.0.0";
port = cfg.port;
}
{
addr = "0.0.0.0";
port = cfg.httpsPort;
ssl = true;
}
];
# TLS attrs for one vhost, by name. A swarm service's name may sit
# outside this hive's domain — and then the hive CA is
# name-constrained out of it, so its vhost must serve the
# swarm-services leaf instead. Everything else keeps the hive leaf.
#
# Only in self-signed mode: with ACME or an operator cert there is a
# single issuer that already covers every name, and a second pair
# would be a cert nobody asked for.
tlsFor =
host:
if !cfg.tls.acme.enable && cfg.tls.certDir == null && builtins.elem host swarmServiceDomains then
{
addSSL = true;
sslCertificate = svcCert;
sslCertificateKey = svcKey;
}
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
# location with its own add_header (e.g. CORS on /.well-known or
# /_matrix/) must repeat the security headers explicitly — see those
# locations in ./vhosts.nix. HTML-serving and proxy locations that
# carry no add_header of their own pick these up from the server
# scope automatically.
securityHeaders = ''
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
${lib.optionalString cfg.hsts.enable ''add_header Strict-Transport-Security "${hstsDirectives}" always;''}
'';
# The gateway's styled error pages, re-exported so a service module
# can point an `error_page` at one. Republished rather than imported
# per module for the same reason as everything else in this kit: these
# carry the hive's branding, and a service rendering its own would
# drift from the rest of the gateway the first time the theme changes.
inherit errorPages;
}