From 7f6d3b295e79430cbc25e6e1ce6ae6f64a87d3a8 Mon Sep 17 00:00:00 2001 From: alexion Date: Sun, 23 Aug 2026 23:44:22 -0400 Subject: [PATCH] refactor(reverse-proxy): use route registry --- AGENTS.md | 3 + flake.nix | 102 ++++++++++++--------------- guests/reverse-proxy.nix | 17 +++-- lib/default.nix | 18 ++--- lib/guests.nix | 38 +++++++---- lib/registry.nix | 5 ++ lib/reverse-proxy-routes.nix | 129 ----------------------------------- modules/reverse-proxy.nix | 44 ++++++++---- registry/route.nix | 65 ++++++++++++++++++ 9 files changed, 186 insertions(+), 235 deletions(-) delete mode 100644 lib/reverse-proxy-routes.nix create mode 100644 registry/route.nix diff --git a/AGENTS.md b/AGENTS.md index 1bd1f83..04a29cc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,3 +103,6 @@ The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overla Keep `git.alexion.dev:2022` working by proxying that TCP stream through the reverse-proxy guest to Gitea. - Remote deploy helpers that run target-side `sudo` over SSH need a TTY. Use `ssh -t` unless the target command is guaranteed not to prompt for sudo credentials. +- A NixOS module function argument default such as `registry ? config.registry` is not a safe fallback for an unprovided module argument. + The module system still queries `_module.args.registry` and fails when it is absent. + Bind optional special arguments through `@args` and use `args.registry or config.registry` inside the module body instead. diff --git a/flake.nix b/flake.nix index b3ddcf1..cac8121 100644 --- a/flake.nix +++ b/flake.nix @@ -198,14 +198,13 @@ system = "x86_64-linux"; specialArgs.my = self.lib; modules = [ + self.lib.registry.module ./modules/reverse-proxy.nix { - modules.reverse-proxy = { - enable = true; - routes."budget.alexion.dev" = { - path = "/"; - backend = "http://10.23.20.42:5006"; - }; + modules.reverse-proxy.enable = true; + registry.route.test."budget.alexion.dev" = { + path = "/"; + backend = "http://10.23.20.42:5006"; }; } ]; @@ -224,7 +223,7 @@ reverse-proxy-actualbudget-guest-route = let testConfig = self.nixosConfigurations.pikachu.config; - route = testConfig.modules.reverse-proxy.routes.actualbudget; + route = testConfig.registry.route.pikachu.actualbudget; vhost = testConfig.containers.reverse-proxy.config.services.nginx.virtualHosts."budget.alexion.dev"; in pkgs.runCommand "reverse-proxy-actualbudget-guest-route-check" { } '' @@ -233,7 +232,6 @@ !( route.host == "budget.alexion.dev" && route.backend == null - && route.endpoint == "pikachu-actualbudget" && route.port == 5006 && route.tls && route.aliases == [ ] @@ -361,25 +359,25 @@ system = "x86_64-linux"; specialArgs.my = self.lib; modules = [ - ./modules/reverse-proxy.nix + self.lib.registry.module { - modules.reverse-proxy.routes.app = { + registry.route.routeHost.app = { host = "worker.alexion.dev"; backend = "http://10.23.20.126:3923"; }; + system.stateVersion = "26.11"; } ]; }; - endpoints = self.lib.networkEndpoints.collectFleetEndpoints { - pikachu = self.nixosConfigurations.pikachu; + registry = self.lib.registry.collect { + hosts = { + inherit routeHost; + pikachu = self.nixosConfigurations.pikachu; + }; }; - fleetRoutes = self.lib.reverseProxyRoutes.collectFleetRoutes { - inherit routeHost; - pikachu = self.nixosConfigurations.pikachu; - } endpoints; testConfig = (self.nixosConfigurations.pikachu.extendModules { - specialArgs.reverseProxyFleetRoutes = fleetRoutes; + specialArgs.registry = registry; }).config; vhosts = testConfig.containers.reverse-proxy.config.services.nginx.virtualHosts; in @@ -387,8 +385,9 @@ ${ lib.optionalString ( !( - fleetRoutes ? routeHost-app - && fleetRoutes ? pikachu-actualbudget + registry.route ? routeHost + && registry.route.routeHost ? app + && registry.route.pikachu ? actualbudget && vhosts."worker.alexion.dev".locations."/".proxyPass == "http://10.23.20.126:3923" && vhosts."budget.alexion.dev".locations."/".proxyPass == "http://10.23.20.42:5006" && vhosts."files.alexion.dev".locations."/".proxyPass == "http://10.23.20.126:3923" @@ -398,9 +397,9 @@ touch $out ''; - reverse-proxy-endpoint-backends = + reverse-proxy-address-backends = let - endpointHost = + workerHost = lib.nixosSystem { system = "x86_64-linux"; specialArgs.my = self.lib; @@ -408,22 +407,11 @@ self.lib.registry.module { registry.address.worker.self = "10.23.50.146"; - system.stateVersion = "26.11"; - } - ]; - }; - routeHost = - lib.nixosSystem { - system = "x86_64-linux"; - specialArgs.my = self.lib; - modules = [ - ./modules/reverse-proxy.nix - { - modules.reverse-proxy.routes.app = { + registry.route.worker.self = { host = "worker.alexion.dev"; - endpoint = "worker"; port = 8080; }; + system.stateVersion = "26.11"; } ]; }; @@ -454,36 +442,37 @@ } ]; }; - endpoints = self.lib.networkEndpoints.collectFleetEndpoints { - worker = endpointHost; - pikachu = guestRouteHost; + registry = self.lib.registry.collect { + hosts = { + worker = workerHost; + pikachu = guestRouteHost; + }; }; - fleetRoutes = self.lib.reverseProxyRoutes.collectFleetRoutes { - inherit routeHost; - pikachu = guestRouteHost; - } endpoints; testConfig = (lib.nixosSystem { system = "x86_64-linux"; - specialArgs.my = self.lib; + specialArgs = { + my = self.lib; + inherit registry; + }; modules = [ ./modules/reverse-proxy.nix { - modules.reverse-proxy = { - enable = true; - routes = fleetRoutes; - }; + modules.reverse-proxy.enable = true; } ]; }).config; vhosts = testConfig.services.nginx.virtualHosts; + workerFacts = self.lib.registry.lookup registry "worker" "self"; in - pkgs.runCommand "reverse-proxy-endpoint-backends-check" { } '' + pkgs.runCommand "reverse-proxy-address-backends-check" { } '' ${ lib.optionalString ( !( - vhosts."worker.alexion.dev".locations."/".proxyPass == "http://10.23.50.146:8080" - && fleetRoutes."pikachu-files".endpoint == "pikachu-files" + workerFacts.address == "10.23.50.146" + && workerFacts.route.host == "worker.alexion.dev" + && vhosts."worker.alexion.dev".locations."/".proxyPass == "http://10.23.50.146:8080" + && registry.route.pikachu.files.port == 3923 && vhosts."files.alexion.dev".locations."/".proxyPass == "http://10.23.20.126:3923" ) ) "exit 1" @@ -498,15 +487,14 @@ system = "x86_64-linux"; specialArgs.my = self.lib; modules = [ + self.lib.registry.module ./modules/reverse-proxy.nix { - modules.reverse-proxy = { - enable = true; - routes.raichu-files = { - host = "files.alexion.dev"; - path = "/raichu"; - backend = "http://10.23.20.10:3923"; - }; + modules.reverse-proxy.enable = true; + registry.route.raichu.files = { + host = "files.alexion.dev"; + path = "/raichu"; + backend = "http://10.23.20.10:3923"; }; } ]; @@ -514,7 +502,7 @@ hasFailedRootAssertion = lib.any ( assertion: assertion.assertion == false - && assertion.message == "modules.reverse-proxy.routes for files.alexion.dev must include a primary / route." + && assertion.message == "registry.route entries for files.alexion.dev must include a primary / route." ) testConfig.assertions; in pkgs.runCommand "reverse-proxy-secondary-path-requires-root-check" { } '' diff --git a/guests/reverse-proxy.nix b/guests/reverse-proxy.nix index 03d7cca..1bda34b 100644 --- a/guests/reverse-proxy.nix +++ b/guests/reverse-proxy.nix @@ -2,7 +2,6 @@ config, lib, my, - reverseProxyFleetRoutes ? config.modules.reverse-proxy.routes, ... }: let @@ -18,12 +17,13 @@ let "todo.alexion.dev" "wings.alexion.dev" ]; - legacyRoutes = lib.listToAttrs ( + legacyVirtualHosts = lib.listToAttrs ( map (host: { - name = "legacy-${lib.replaceStrings [ "." ] [ "-" ] host}"; + name = host; value = { - inherit host; - backend = legacyBackend; + forceSSL = true; + enableACME = true; + locations."/".proxyPass = legacyBackend; }; }) legacyHosts ); @@ -33,10 +33,9 @@ in (my.guest { name = "reverse-proxy"; interior = { - modules.reverse-proxy = { - enable = true; - routes = reverseProxyFleetRoutes // legacyRoutes; - }; + modules.reverse-proxy.enable = true; + + services.nginx.virtualHosts = legacyVirtualHosts; networking.firewall.allowedTCPPorts = [ 2022 ]; diff --git a/lib/default.nix b/lib/default.nix index ba3a9f5..48bff10 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -16,7 +16,6 @@ let serviceIdentityRegistry = import ./service-identities.nix { inherit lib; }; registry = import ./registry.nix { inherit lib self; }; networkEndpoints = import ./network-endpoints.nix { inherit lib; }; - reverseProxyRoutes = import ./reverse-proxy-routes.nix { inherit lib; }; collectNixFiles = dir: @@ -35,16 +34,11 @@ let ) (builtins.readDir dir) ); - specialArgs = - let - networkFleetEndpoints = self.lib.networkEndpoints.collectFleetEndpoints self.nixosConfigurations; - in - { - inherit inputs networkFleetEndpoints self; - my = self.lib; - registry = self.lib.registry.collect { hosts = self.nixosConfigurations; }; - reverseProxyFleetRoutes = self.lib.reverseProxyRoutes.collectFleetRoutes self.nixosConfigurations networkFleetEndpoints; - }; + specialArgs = { + inherit inputs self; + my = self.lib; + registry = self.lib.registry.collect { hosts = self.nixosConfigurations; }; + }; bridgeName = id: "br-vlan${toString id}"; @@ -54,7 +48,6 @@ let self specialArgs bridgeName - reverseProxyRoutes ; }; @@ -95,7 +88,6 @@ in bridgeName registry networkEndpoints - reverseProxyRoutes ; inherit (guests) deriveMac guest; diff --git a/lib/guests.nix b/lib/guests.nix index 1750756..527fc22 100644 --- a/lib/guests.nix +++ b/lib/guests.nix @@ -3,7 +3,6 @@ self, specialArgs, bridgeName, - reverseProxyRoutes, }: let inherit (lib) escapeShellArg mapAttrsToList; @@ -21,7 +20,7 @@ let name, interior ? { }, }: - { config, lib, ... }: + { config, lib, ... }@args: let optionPath = [ "guests" ] ++ lib.splitString "." name; cfg = lib.getAttrFromPath optionPath config; @@ -130,9 +129,19 @@ let Left null, this guest contributes no address to the address registry. ''; }; - reverseProxy = reverseProxyRoutes.mkRouteOptions { - subject = "guest route"; - enableDefault = false; + reverseProxy = { + enable = lib.mkEnableOption "a reverse-proxy route for this guest"; + + host = lib.mkOption { + type = lib.types.str; + description = "Primary hostname served by this guest's reverse-proxy route."; + }; + + port = lib.mkOption { + type = lib.types.ints.between 1 65535; + example = 3923; + description = "Backend TCP port served by this guest."; + }; }; mounts = lib.mkOption { type = lib.types.attrsOf ( @@ -279,14 +288,15 @@ let registry.address.${config.networking.hostName}.${machineName} = lib.mkIf (cfg.address != null) cfg.address; - modules.reverse-proxy.routes.${machineName} = lib.mkIf cfg.reverseProxy.enable ( - reverseProxyRoutes.routeFields ( - cfg.reverseProxy - // lib.optionalAttrs (cfg.reverseProxy.backend == null && cfg.reverseProxy.endpoint == null) { - endpoint = "${config.networking.hostName}-${machineName}"; - } - ) - ); + registry.route.${config.networking.hostName}.${machineName} = lib.mkIf cfg.reverseProxy.enable { + inherit (cfg.reverseProxy) host port; + aliases = [ ]; + path = "/"; + backend = null; + scheme = "http"; + tls = true; + websockets = false; + }; assertions = [ { @@ -346,7 +356,7 @@ let bindMounts = userMounts // secretMounts; - inherit specialArgs; + specialArgs = specialArgs // lib.optionalAttrs (args ? registry) { inherit (args) registry; }; config = { imports = [ diff --git a/lib/registry.nix b/lib/registry.nix index d43d6f4..6ddd156 100644 --- a/lib/registry.nix +++ b/lib/registry.nix @@ -32,6 +32,11 @@ in { inherit definitions; + lookup = registry: hostName: entryName: + filterAttrs (_name: value: value != null) ( + mapAttrs (_name: hostEntriesByName: (hostEntriesByName.${hostName} or { }).${entryName} or null) registry + ); + module = { options.registry = mapAttrs (_name: registry: registryOption registry) definitions; }; diff --git a/lib/reverse-proxy-routes.nix b/lib/reverse-proxy-routes.nix deleted file mode 100644 index 4a40a7d..0000000 --- a/lib/reverse-proxy-routes.nix +++ /dev/null @@ -1,129 +0,0 @@ -{ lib }: -let - mkRouteOptions = - { - name ? null, - subject ? "route", - enableDefault ? true, - }: - { - enable = lib.mkOption { - type = lib.types.bool; - default = enableDefault; - description = "Whether this reverse-proxy ${subject} is active."; - }; - - host = lib.mkOption ( - { - type = lib.types.str; - description = "Primary hostname served by this reverse-proxy ${subject}."; - } - // lib.optionalAttrs (name != null) { - default = name; - defaultText = lib.literalMD "the route attribute name"; - } - ); - - aliases = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ ]; - description = "Additional hostnames served by this reverse-proxy ${subject}."; - }; - - path = lib.mkOption { - type = lib.types.str; - default = "/"; - description = "URL path prefix proxied by this reverse-proxy ${subject}."; - }; - - backend = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Full HTTP backend URL that receives proxied traffic."; - }; - - endpoint = lib.mkOption { - type = lib.types.nullOr lib.types.str; - default = null; - description = "Fleet network endpoint key used to derive the backend URL."; - }; - - port = lib.mkOption { - type = lib.types.nullOr (lib.types.ints.between 1 65535); - default = null; - example = 3923; - description = "Backend TCP port used with the fleet network endpoint."; - }; - - scheme = lib.mkOption { - type = lib.types.str; - default = "http"; - description = "Backend URL scheme used with the fleet network endpoint."; - }; - - tls = lib.mkOption { - type = lib.types.bool; - default = true; - description = "Whether to serve this reverse-proxy ${subject} with ACME-backed TLS."; - }; - - websockets = lib.mkOption { - type = lib.types.bool; - default = false; - description = "Whether to enable websocket proxy headers for this reverse-proxy ${subject}."; - }; - }; - - routeFields = route: { - inherit (route) - host - aliases - path - backend - endpoint - port - scheme - tls - websockets - ; - }; - - endpointBackend = endpoints: route: - let - endpoint = endpoints.${route.endpoint} or ( - throw "Unknown reverse-proxy endpoint `${route.endpoint}`. Known endpoints: ${lib.concatStringsSep ", " (lib.attrNames endpoints)}" - ); - in - "${route.scheme}://${endpoint.address}:${toString route.port}"; - - resolvedRouteFields = endpoints: route: - let - routeWithBackend = - if route.backend != null then - route - else if route.endpoint != null && route.port != null then - route // { backend = endpointBackend endpoints route; } - else - throw "Reverse-proxy route `${route.host}${route.path}` needs either backend or endpoint plus port."; - in - routeFields routeWithBackend; - - collectFleetRoutes = - hosts: endpoints: - lib.concatMapAttrs ( - hostName: host: - lib.mapAttrs' ( - routeName: route: lib.nameValuePair "${hostName}-${routeName}" (resolvedRouteFields endpoints route) - ) host.config.modules.reverse-proxy.routes - ) hosts; -in -{ - inherit collectFleetRoutes mkRouteOptions resolvedRouteFields routeFields; - - routeType = lib.types.submodule ( - { name, ... }: - { - options = mkRouteOptions { inherit name; }; - } - ); -} diff --git a/modules/reverse-proxy.nix b/modules/reverse-proxy.nix index aaae1d9..00565b4 100644 --- a/modules/reverse-proxy.nix +++ b/modules/reverse-proxy.nix @@ -1,10 +1,33 @@ -{ config, lib, my, ... }: +{ + config, + lib, + my, + ... +}@args: let cfg = config.modules.reverse-proxy; - inherit (my) reverseProxyRoutes; + registry = args.registry or config.registry; - enabledRoutes = lib.filterAttrs (_: route: route.enable) cfg.routes; - routesByHost = lib.groupBy (route: route.host) (lib.attrValues enabledRoutes); + routeWithBackend = hostName: routeName: route: + let + facts = my.registry.lookup registry hostName routeName; + in + if route.backend != null then + route + else if facts ? address && route.port != null then + route // { backend = "${route.scheme}://${facts.address}:${toString route.port}"; } + else + route; + + allRoutes = lib.concatLists ( + lib.mapAttrsToList ( + hostName: routes: + lib.mapAttrsToList (routeName: route: routeWithBackend hostName routeName route) routes + ) registry.route + ); + + enabledRoutes = builtins.filter (route: route.enable) allRoutes; + routesByHost = lib.groupBy (route: route.host) enabledRoutes; rootRoutesByHost = lib.mapAttrs ( host: routes: @@ -46,27 +69,22 @@ in options.modules.reverse-proxy = { enable = lib.mkEnableOption "the Nginx reverse proxy route realizer"; - routes = lib.mkOption { - type = lib.types.attrsOf reverseProxyRoutes.routeType; - default = { }; - description = "Reverse-proxy route declarations keyed by stable route name."; - }; }; config = lib.mkIf cfg.enable { assertions = lib.mapAttrsToList (host: rootRoute: { assertion = rootRoute != null; - message = "modules.reverse-proxy.routes for ${host} must include a primary / route."; + message = "registry.route entries for ${host} must include a primary / route."; }) rootRoutesByHost ++ lib.mapAttrsToList (host: routes: { assertion = !hasDuplicatePaths routes; - message = "modules.reverse-proxy.routes for ${host} must not declare the same path more than once."; + message = "registry.route entries for ${host} must not declare the same path more than once."; }) routesByHost ++ map (route: { assertion = !hasUnresolvedBackend route; - message = "modules.reverse-proxy.routes for ${route.host}${route.path} must resolve to a backend URL before actualization."; - }) (builtins.filter hasUnresolvedBackend (lib.attrValues enabledRoutes)); + message = "registry.route entry for ${route.host}${route.path} must resolve to a backend URL before actualization."; + }) (builtins.filter hasUnresolvedBackend enabledRoutes); networking.firewall.allowedTCPPorts = [ 80 diff --git a/registry/route.nix b/registry/route.nix new file mode 100644 index 0000000..ec3e71e --- /dev/null +++ b/registry/route.nix @@ -0,0 +1,65 @@ +{ lib }: +{ + type = lib.types.submodule ( + { name, ... }: + { + options = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether this reverse-proxy route entry is active."; + }; + + host = lib.mkOption { + type = lib.types.str; + default = name; + defaultText = lib.literalMD "the route attribute name"; + description = "Primary hostname served by this reverse-proxy route entry."; + }; + + aliases = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = "Additional hostnames served by this reverse-proxy route entry."; + }; + + path = lib.mkOption { + type = lib.types.str; + default = "/"; + description = "URL path prefix proxied by this reverse-proxy route entry."; + }; + + backend = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + description = "Full HTTP backend URL that receives proxied traffic."; + }; + + port = lib.mkOption { + type = lib.types.nullOr (lib.types.ints.between 1 65535); + default = null; + example = 3923; + description = "Backend TCP port used with the matching address registry entry."; + }; + + scheme = lib.mkOption { + type = lib.types.str; + default = "http"; + description = "Backend URL scheme used with the matching address registry entry."; + }; + + tls = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether to serve this reverse-proxy route entry with ACME-backed TLS."; + }; + + websockets = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Whether to enable websocket proxy headers for this reverse-proxy route entry."; + }; + }; + } + ); +}