gateway: per-vhost public flag, private listen addresses, and IPv6 listeners
This commit is contained in:
parent
74754d1584
commit
fbbdf26a19
6 changed files with 364 additions and 25 deletions
|
|
@ -1,7 +1,7 @@
|
|||
# Renders `services.hyperhive.gateway.vhosts` into
|
||||
# `services.nginx.virtualHosts`: each declared vhost keeps what its module
|
||||
# wrote and gets the gateway's `listen`, the TLS attributes for its name and
|
||||
# the security headers.
|
||||
# wrote and gets the gateway's `listen` (the private one when it sets
|
||||
# `public = false`), the TLS attributes for its name and the security headers.
|
||||
#
|
||||
# Its own module rather than part of ./default.nix because the
|
||||
# "declared while the gateway is off" assertion has to hold when
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
{ lib, config, ... }:
|
||||
let
|
||||
cfg = config.services.hyperhive.gateway;
|
||||
listen = import ./listen.nix { inherit lib; };
|
||||
|
||||
# What the gateway owns on a vhost. A declared vhost naming one of these
|
||||
# fails the eval.
|
||||
|
|
@ -26,12 +27,16 @@ let
|
|||
name: map (attr: "${name}.${attr}") (lib.filter (a: cfg.vhosts.${name} ? ${a}) gatewayOwned)
|
||||
) (lib.attrNames cfg.vhosts);
|
||||
|
||||
privateOnly = lib.filter (name: !cfg.vhosts.${name}.public) (lib.attrNames cfg.vhosts);
|
||||
wildcardPrivate = lib.filter listen.isWildcard cfg.privateListenAddresses;
|
||||
|
||||
render =
|
||||
name: declared:
|
||||
let
|
||||
own = removeAttrs declared [
|
||||
"_module"
|
||||
"certificate"
|
||||
"public"
|
||||
"securityHeaders"
|
||||
];
|
||||
base =
|
||||
|
|
@ -48,7 +53,7 @@ let
|
|||
tls
|
||||
// own
|
||||
// {
|
||||
listen = cfg.lib.listen;
|
||||
listen = if declared.public then cfg.lib.listen else cfg.lib.privateListen;
|
||||
extraConfig =
|
||||
lib.optionalString declared.securityHeaders cfg.lib.securityHeaders + (own.extraConfig or "");
|
||||
}
|
||||
|
|
@ -83,6 +88,26 @@ in
|
|||
`certificate` on the vhost.
|
||||
'';
|
||||
}
|
||||
{
|
||||
assertion = privateOnly == [ ] || cfg.privateListenAddresses != [ ];
|
||||
message = ''
|
||||
services.hyperhive.gateway.vhosts sets `public = false` on
|
||||
${lib.concatStringsSep ", " privateOnly}
|
||||
while services.hyperhive.gateway.privateListenAddresses is empty,
|
||||
so these vhosts would listen nowhere. Set the addresses they
|
||||
should answer on, or drop `public = false`.
|
||||
'';
|
||||
}
|
||||
{
|
||||
assertion = wildcardPrivate == [ ];
|
||||
message = ''
|
||||
services.hyperhive.gateway.privateListenAddresses holds
|
||||
${lib.concatStringsSep ", " wildcardPrivate}, which is every address
|
||||
of the host, not a private one: the vhosts with `public = false`
|
||||
would answer on the public address too. List the host's private
|
||||
addresses (a WireGuard interface's, say).
|
||||
'';
|
||||
}
|
||||
];
|
||||
}
|
||||
(lib.mkIf cfg.enable {
|
||||
|
|
|
|||
52
nix/host-modules/hive-gateway/listen.nix
Normal file
52
nix/host-modules/hive-gateway/listen.nix
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# The gateway's listen sets, as pure functions of `lib` and the gateway's
|
||||
# port and address settings.
|
||||
#
|
||||
# nginx picks the server set for a connection by the address the connection
|
||||
# arrived on: a server that lists only `10.0.0.1:443` never sees a connection
|
||||
# to another address, and a server that lists only the wildcard never sees one
|
||||
# to `10.0.0.1:443` once some server lists that address. So a vhost reachable
|
||||
# on a private address must list it explicitly, and so must every vhost that
|
||||
# should keep answering there (including the `default_server`, or an unknown
|
||||
# `Host` on the private address lands on an arbitrary private vhost).
|
||||
{ lib }:
|
||||
rec {
|
||||
# nginx wants an IPv6 literal in brackets (`[fd00::1]:443`). Accept the bare
|
||||
# spelling too.
|
||||
bracket = addr: if lib.hasInfix ":" addr && !lib.hasPrefix "[" addr then "[${addr}]" else addr;
|
||||
|
||||
# The spellings of "every address". Listing one as a private address would
|
||||
# open the private vhosts on the public one.
|
||||
wildcards = [
|
||||
"0.0.0.0"
|
||||
"[::]"
|
||||
"[::0]"
|
||||
"*"
|
||||
];
|
||||
isWildcard = addr: lib.elem (bracket addr) wildcards;
|
||||
|
||||
# One address, both ports: plain http on `port`, TLS on `httpsPort`.
|
||||
pair = cfg: addr: [
|
||||
{
|
||||
inherit addr;
|
||||
port = cfg.port;
|
||||
}
|
||||
{
|
||||
inherit addr;
|
||||
port = cfg.httpsPort;
|
||||
ssl = true;
|
||||
}
|
||||
];
|
||||
|
||||
# `privateListenAddresses`, bracketed and deduplicated: nginx refuses a
|
||||
# duplicate `listen`.
|
||||
privateAddrs = cfg: lib.unique (map bracket cfg.privateListenAddresses);
|
||||
|
||||
# The listens of a `public = false` vhost.
|
||||
private = cfg: lib.concatMap (pair cfg) (privateAddrs cfg);
|
||||
|
||||
# The listens of a `public = true` vhost: the wildcard (IPv6 as well when
|
||||
# `listenIPv6`), plus the private addresses, which each have a server set of
|
||||
# their own.
|
||||
public =
|
||||
cfg: lib.concatMap (pair cfg) ([ "0.0.0.0" ] ++ lib.optional cfg.listenIPv6 "[::]") ++ private cfg;
|
||||
}
|
||||
|
|
@ -145,6 +145,16 @@ in
|
|||
request with a response, such as the catch-all.
|
||||
'';
|
||||
};
|
||||
options.public = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
Whether this vhost answers on every address the gateway listens
|
||||
on. `false` binds it to `privateListenAddresses` only, so the
|
||||
public address never serves it (see that option). Needs a
|
||||
non-empty `privateListenAddresses`.
|
||||
'';
|
||||
};
|
||||
options.certificate = lib.mkOption {
|
||||
type = lib.types.nullOr (
|
||||
lib.types.submodule {
|
||||
|
|
@ -177,7 +187,9 @@ in
|
|||
config — and the gateway renders it into
|
||||
`services.nginx.virtualHosts.<name>` with the parts it owns:
|
||||
|
||||
- `listen`, the gateway's port pair;
|
||||
- `listen`, the gateway's port pair on its addresses
|
||||
(`gateway.lib.listen`, or `gateway.lib.privateListen` for a vhost
|
||||
that sets `public = false`);
|
||||
- the TLS attributes for that name (`gateway.lib.tlsFor`, or
|
||||
`certificate` below), with `addSSL` dropped when the vhost sets
|
||||
`forceSSL`;
|
||||
|
|
@ -203,12 +215,23 @@ in
|
|||
internal = true;
|
||||
readOnly = true;
|
||||
description = ''
|
||||
Read-only: the `listen` set every vhost in front of this
|
||||
gateway shares (plain http on `port`, TLS on `httpsPort`).
|
||||
Applied to every vhost declared under `vhosts`, and read by
|
||||
the gateway's own vhosts, so no vhost binds a different
|
||||
set — one that did would be reachable on a port the gateway
|
||||
does not consider its own.
|
||||
Read-only: the `listen` set of a vhost that answers on every
|
||||
address (plain http on `port`, TLS on `httpsPort`; on `0.0.0.0`,
|
||||
on `[::]` when `listenIPv6` is set, and on each of
|
||||
`privateListenAddresses`). Applied to every vhost declared under
|
||||
`vhosts` that keeps `public = true`, and read by the gateway's own
|
||||
vhosts, so no vhost binds a different set — one that did would be
|
||||
reachable on a port the gateway does not consider its own.
|
||||
'';
|
||||
};
|
||||
|
||||
privateListen = lib.mkOption {
|
||||
type = lib.types.listOf (lib.types.attrsOf lib.types.raw);
|
||||
internal = true;
|
||||
readOnly = true;
|
||||
description = ''
|
||||
Read-only: the `listen` set of a vhost with `public = false`: the
|
||||
same port pair, on `privateListenAddresses` only.
|
||||
'';
|
||||
};
|
||||
|
||||
|
|
@ -319,6 +342,45 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
privateListenAddresses = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
example = [
|
||||
"10.100.0.1"
|
||||
"fd00:100::1"
|
||||
];
|
||||
description = ''
|
||||
Addresses of this host, IPv4 or IPv6 (bare `fd00::1` or bracketed),
|
||||
that the vhosts with `public = false` listen on, on `port` and
|
||||
`httpsPort`. Not the wildcard: `0.0.0.0` and `::` are refused,
|
||||
since they would open those vhosts on the public address too.
|
||||
|
||||
The vhosts that stay public listen on these addresses as well as on
|
||||
the wildcard, so a client that reaches the host by a private address
|
||||
still gets them; and so does the catch-all, so an unknown `Host` on a
|
||||
private address closes the connection rather than being served by
|
||||
a private vhost.
|
||||
|
||||
The addresses must exist on the host when nginx starts (a
|
||||
WireGuard interface's address, say), or nginx fails to bind. Empty
|
||||
by default: every vhost is public, as before.
|
||||
'';
|
||||
};
|
||||
|
||||
listenIPv6 = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = config.networking.enableIPv6;
|
||||
defaultText = lib.literalExpression "config.networking.enableIPv6";
|
||||
description = ''
|
||||
Listen on `[::]` as well as `0.0.0.0`, on `port` and `httpsPort`.
|
||||
The IPv6 listener takes IPv6 connections only; the `0.0.0.0` one
|
||||
keeps the IPv4 ones. Follows `networking.enableIPv6`; set it to
|
||||
`false` on a host whose kernel cannot open an IPv6 socket.
|
||||
|
||||
`openFirewall` opens the ports for both address families.
|
||||
'';
|
||||
};
|
||||
|
||||
statusPort = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 8085;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@
|
|||
#
|
||||
# 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.
|
||||
# knowledge — which addresses and port pair to bind, which issuer covers a
|
||||
# name.
|
||||
{
|
||||
lib,
|
||||
cfg, # services.hyperhive.gateway
|
||||
|
|
@ -24,6 +25,8 @@
|
|||
caBundle, # hive TLS trust bundle on the host
|
||||
}:
|
||||
let
|
||||
listenSets = import ./listen.nix { inherit lib; };
|
||||
|
||||
# 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
|
||||
|
|
@ -52,18 +55,13 @@ in
|
|||
# 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/4-implementation/networking/gateway.md`
|
||||
# ("TLS modes").
|
||||
listen = [
|
||||
{
|
||||
addr = "0.0.0.0";
|
||||
port = cfg.port;
|
||||
}
|
||||
{
|
||||
addr = "0.0.0.0";
|
||||
port = cfg.httpsPort;
|
||||
ssl = true;
|
||||
}
|
||||
];
|
||||
# ("TLS modes", "Listen addresses").
|
||||
#
|
||||
# `listen` is for a vhost that answers everywhere: the wildcard plus the
|
||||
# private addresses. `privateListen` is for one that answers only on the
|
||||
# private addresses.
|
||||
listen = listenSets.public cfg;
|
||||
privateListen = listenSets.private cfg;
|
||||
|
||||
# TLS attrs for one vhost, by name. A swarm service's name may sit
|
||||
# outside this hive's domain — and then the hive leaf's role
|
||||
|
|
|
|||
|
|
@ -75,11 +75,43 @@ let
|
|||
};
|
||||
|
||||
# The controller behind a gateway on a non-default TLS port, with the
|
||||
# firewall opened. Its collector URL and its listener are read off it.
|
||||
# firewall opened, a private IPv4 and IPv6 address, and one vhost kept off the
|
||||
# public address. Its collector URL and its listeners are read off it.
|
||||
controllerAltPort = hive {
|
||||
deploy.swarm-controller.enable = true;
|
||||
gateway.httpsPort = 8443;
|
||||
gateway.openFirewall = true;
|
||||
gateway.privateListenAddresses = [
|
||||
"10.100.0.1"
|
||||
"fd00:100::1"
|
||||
];
|
||||
gateway.vhosts."internal.t.local".public = false;
|
||||
};
|
||||
|
||||
# A private address that is the wildcard, beside a vhost that asks to be
|
||||
# private: the listen set is well-formed, the privacy it promises is not.
|
||||
privateWildcard = hive {
|
||||
gateway.privateListenAddresses = [ "0.0.0.0" ];
|
||||
gateway.vhosts."internal.t.local".public = false;
|
||||
};
|
||||
|
||||
# Only its `networking.enableIPv6` default is read.
|
||||
noIPv6 = hiveWith [ { networking.enableIPv6 = false; } ] { };
|
||||
|
||||
gatewayListen = import ../host-modules/hive-gateway/listen.nix { inherit lib; };
|
||||
# nginx fills defaults into each `listen` entry; compare the fields the
|
||||
# gateway sets.
|
||||
listenFields = map (l: {
|
||||
inherit (l) addr port;
|
||||
ssl = l.ssl or false;
|
||||
});
|
||||
httpAt = addr: port: {
|
||||
inherit addr port;
|
||||
ssl = false;
|
||||
};
|
||||
httpsAt = addr: port: {
|
||||
inherit addr port;
|
||||
ssl = true;
|
||||
};
|
||||
|
||||
# A container the operator runs that hyperhive doesn't own, next to one it
|
||||
|
|
@ -2176,6 +2208,120 @@ let
|
|||
&& lib.length (checks "swarm-bao") == 1
|
||||
&& checks "operator-box" == [ ];
|
||||
}
|
||||
{
|
||||
# Presence controls: the IPv6 arm is on in this fixture, and the private
|
||||
# list is empty, so what is absent below is absent by configuration.
|
||||
name = "a gateway with no private addresses listens on both ports of 0.0.0.0 and [::] and nowhere else";
|
||||
ok =
|
||||
let
|
||||
gw = allLocal.services.hyperhive.gateway;
|
||||
l = allLocal.services.nginx.virtualHosts."_".listen;
|
||||
in
|
||||
gw.listenIPv6
|
||||
&& gw.privateListenAddresses == [ ]
|
||||
&&
|
||||
listenFields l == [
|
||||
(httpAt "0.0.0.0" 80)
|
||||
(httpsAt "0.0.0.0" 443)
|
||||
(httpAt "[::]" 80)
|
||||
(httpsAt "[::]" 443)
|
||||
]
|
||||
&& listenFields gw.lib.listen == listenFields l
|
||||
&& gw.lib.privateListen == [ ];
|
||||
}
|
||||
{
|
||||
# `listenIPv6` is the escape for a host whose kernel cannot open an IPv6
|
||||
# socket, and follows `networking.enableIPv6` unless set.
|
||||
name = "the IPv6 listener can be turned off and follows networking.enableIPv6";
|
||||
ok =
|
||||
let
|
||||
off = {
|
||||
port = 80;
|
||||
httpsPort = 443;
|
||||
listenIPv6 = false;
|
||||
privateListenAddresses = [ ];
|
||||
};
|
||||
in
|
||||
listenFields (gatewayListen.public off) == [
|
||||
(httpAt "0.0.0.0" 80)
|
||||
(httpsAt "0.0.0.0" 443)
|
||||
]
|
||||
&& !noIPv6.services.hyperhive.gateway.listenIPv6
|
||||
&& allLocal.services.hyperhive.gateway.listenIPv6;
|
||||
}
|
||||
{
|
||||
# An IPv6 literal is bracketed whichever way the operator wrote it, and
|
||||
# listed once: nginx refuses a duplicate `listen`. The wildcard test is
|
||||
# paired with addresses that are not wildcards.
|
||||
name = "private addresses render IPv6 in brackets, once, and the wildcards are recognised";
|
||||
ok =
|
||||
let
|
||||
cfg = {
|
||||
port = 80;
|
||||
httpsPort = 443;
|
||||
listenIPv6 = true;
|
||||
privateListenAddresses = [
|
||||
"10.100.0.1"
|
||||
"fd00:100::1"
|
||||
"[fd00:100::1]"
|
||||
];
|
||||
};
|
||||
in
|
||||
listenFields (gatewayListen.private cfg) == [
|
||||
(httpAt "10.100.0.1" 80)
|
||||
(httpsAt "10.100.0.1" 443)
|
||||
(httpAt "[fd00:100::1]" 80)
|
||||
(httpsAt "[fd00:100::1]" 443)
|
||||
]
|
||||
&& lib.all gatewayListen.isWildcard [
|
||||
"0.0.0.0"
|
||||
"::"
|
||||
"[::]"
|
||||
"*"
|
||||
]
|
||||
&& !(lib.any gatewayListen.isWildcard cfg.privateListenAddresses);
|
||||
}
|
||||
{
|
||||
# The private vhost binds the private addresses only; the catch-all and
|
||||
# every vhost that stays public bind the wildcards AND the private
|
||||
# addresses, because nginx gives a private address a server set of its
|
||||
# own. The catch-all arm is the presence control for the wildcard check.
|
||||
name = "a vhost with public = false listens on the private addresses only, the catch-all on those and the wildcards";
|
||||
ok =
|
||||
let
|
||||
vh = controllerAltPort.services.nginx.virtualHosts;
|
||||
private = [
|
||||
(httpAt "10.100.0.1" 80)
|
||||
(httpsAt "10.100.0.1" 8443)
|
||||
(httpAt "[fd00:100::1]" 80)
|
||||
(httpsAt "[fd00:100::1]" 8443)
|
||||
];
|
||||
in
|
||||
listenFields vh."internal.t.local".listen == private
|
||||
&& !(lib.any (l: l.addr == "0.0.0.0" || l.addr == "[::]") vh."internal.t.local".listen)
|
||||
&&
|
||||
listenFields vh."_".listen == [
|
||||
(httpAt "0.0.0.0" 80)
|
||||
(httpsAt "0.0.0.0" 8443)
|
||||
(httpAt "[::]" 80)
|
||||
(httpsAt "[::]" 8443)
|
||||
]
|
||||
++ private
|
||||
&& vh."_".default;
|
||||
}
|
||||
{
|
||||
# Both arms: the wildcard refusal fires on the wildcard, and the
|
||||
# "needs an address" refusal stays quiet there because the list is not
|
||||
# empty.
|
||||
name = "a wildcard private address is refused";
|
||||
ok =
|
||||
let
|
||||
fired = lib.filter (a: !a.assertion) privateWildcard.assertions;
|
||||
mentions = s: lib.any (a: lib.hasInfix s a.message) fired;
|
||||
in
|
||||
mentions "privateListenAddresses holds"
|
||||
&& !(mentions "while services.hyperhive.gateway.privateListenAddresses is empty");
|
||||
}
|
||||
]
|
||||
# Starting a unit that stays `active (exited)` is a no-op, so a timer aimed
|
||||
# at one fires and fetches nothing.
|
||||
|
|
|
|||
|
|
@ -91,6 +91,8 @@ let
|
|||
swarm.authelia.url = "https://auth.example.invalid";
|
||||
# Declares a vhost that binds its own `listen`, which the gateway refuses.
|
||||
gateway.vhosts."own-listen.t.local".listen = [ ];
|
||||
# Asks to be private on a gateway with no private address to be private on.
|
||||
gateway.vhosts."internal.t.local".public = false;
|
||||
};
|
||||
|
||||
# Did ./host-modules/swarm-grafana.nix refuse this host, and for which of its
|
||||
|
|
@ -1081,6 +1083,16 @@ let
|
|||
&& lib.hasInfix "own-listen.t.local.listen" a.message
|
||||
) grafanaNoIdentity.assertions;
|
||||
}
|
||||
{
|
||||
# A private vhost with nowhere to listen would be silently unserved.
|
||||
name = "a vhost with public = false is refused while there is no private address";
|
||||
ok = lib.any (
|
||||
a:
|
||||
!a.assertion
|
||||
&& lib.hasInfix "privateListenAddresses is empty" a.message
|
||||
&& lib.hasInfix "internal.t.local" a.message
|
||||
) grafanaNoIdentity.assertions;
|
||||
}
|
||||
];
|
||||
in
|
||||
runGroup "grafana" cases
|
||||
|
|
|
|||
Loading…
Reference in a new issue