From 5c0e689f3f6987d08ff163ba1a064fb680ae9743 Mon Sep 17 00:00:00 2001 From: alexion Date: Sun, 16 Aug 2026 22:34:45 -0400 Subject: [PATCH] feat(reverse-proxy): add Pikachu reverse proxy guest --- flake.nix | 84 +++++++++++++++++++ guests/actualbudget.nix | 84 +++++++++++++++---- guests/reverse-proxy.nix | 51 ++++++++++++ hosts/pikachu/default.nix | 11 +++ modules/reverse-proxy.nix | 130 ++++++++++++++++++++++++++++++ modules/services/actualbudget.nix | 1 + 6 files changed, 345 insertions(+), 16 deletions(-) create mode 100644 guests/reverse-proxy.nix create mode 100644 modules/reverse-proxy.nix diff --git a/flake.nix b/flake.nix index e1c2a5d..a2d9459 100644 --- a/flake.nix +++ b/flake.nix @@ -117,6 +117,90 @@ ${lib.optionalString (!serviceIdentityTestsPass) "exit 1"} touch $out ''; + + reverse-proxy-basic-route = + let + testConfig = + (lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + ./modules/reverse-proxy.nix + { + modules.reverse-proxy = { + enable = true; + routes."budget.alexion.dev" = { + path = "/"; + backend = "http://10.23.20.42:5006"; + }; + }; + } + ]; + }).config; + vhost = testConfig.services.nginx.virtualHosts."budget.alexion.dev"; + in + pkgs.runCommand "reverse-proxy-basic-route-check" { } '' + ${ + lib.optionalString ( + !(vhost.forceSSL && vhost.enableACME && vhost.locations."/".proxyPass == "http://10.23.20.42:5006") + ) "exit 1" + } + touch $out + ''; + + reverse-proxy-actualbudget-guest-route = + let + testConfig = + (self.nixosConfigurations.pikachu.extendModules { + modules = [ + { + guests.actualbudget.reverseProxy = { + enable = true; + host = "budget.alexion.dev"; + backend = "http://10.23.20.42:5006"; + }; + } + ]; + }).config; + route = testConfig.modules.reverse-proxy.routes.actualbudget; + in + pkgs.runCommand "reverse-proxy-actualbudget-guest-route-check" { } '' + ${ + lib.optionalString ( + !(route.host == "budget.alexion.dev" && route.backend == "http://10.23.20.42:5006") + ) "exit 1" + } + touch $out + ''; + + reverse-proxy-secondary-path-requires-root = + let + testConfig = + (lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + ./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"; + }; + }; + } + ]; + }).config; + hasFailedRootAssertion = lib.any ( + assertion: + assertion.assertion == false + && assertion.message == "modules.reverse-proxy.routes for files.alexion.dev must include a primary / route." + ) testConfig.assertions; + in + pkgs.runCommand "reverse-proxy-secondary-path-requires-root-check" { } '' + ${lib.optionalString (!hasFailedRootAssertion) "exit 1"} + touch $out + ''; }; }; } diff --git a/guests/actualbudget.nix b/guests/actualbudget.nix index f22edd2..6c32445 100644 --- a/guests/actualbudget.nix +++ b/guests/actualbudget.nix @@ -22,6 +22,44 @@ in ]; options.guests.actualbudget = { + reverseProxy = { + enable = lib.mkEnableOption "the public reverse-proxy route for the Actual Budget guest"; + + host = lib.mkOption { + type = lib.types.str; + description = "Primary hostname served by the Actual Budget route."; + }; + + aliases = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = "Additional hostnames served by the Actual Budget route."; + }; + + path = lib.mkOption { + type = lib.types.str; + default = "/"; + description = "URL path prefix proxied to Actual Budget."; + }; + + backend = lib.mkOption { + type = lib.types.str; + description = "Full HTTP backend URL for the Actual Budget service."; + }; + + tls = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether to serve Actual Budget with ACME-backed TLS."; + }; + + websockets = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Whether to enable websocket proxying for Actual Budget."; + }; + }; + dataPath = lib.mkOption { type = lib.types.nullOr lib.types.str; default = null; @@ -30,21 +68,35 @@ in }; }; - config = lib.mkIf cfg.enable { - assertions = [ - { - assertion = cfg.dataPath != null; - message = "guests.actualbudget.dataPath must point at the host directory that stores Actual Budget data."; - } - ]; - - guests.actualbudget = { - mounts.${dataDir}.hostPath = cfg.dataPath; - limits = { - memory = lib.mkDefault "2G"; - cpu = lib.mkDefault "200%"; - tasksMax = lib.mkDefault 512; + config = lib.mkMerge [ + (lib.mkIf (cfg.enable && cfg.reverseProxy.enable) { + modules.reverse-proxy.routes.actualbudget = { + inherit (cfg.reverseProxy) + host + aliases + path + backend + tls + websockets + ; }; - }; - }; + }) + (lib.mkIf cfg.enable { + assertions = [ + { + assertion = cfg.dataPath != null; + message = "guests.actualbudget.dataPath must point at the host directory that stores Actual Budget data."; + } + ]; + + guests.actualbudget = { + mounts.${dataDir}.hostPath = cfg.dataPath; + limits = { + memory = lib.mkDefault "2G"; + cpu = lib.mkDefault "200%"; + tasksMax = lib.mkDefault 512; + }; + }; + }) + ]; } diff --git a/guests/reverse-proxy.nix b/guests/reverse-proxy.nix new file mode 100644 index 0000000..4370a94 --- /dev/null +++ b/guests/reverse-proxy.nix @@ -0,0 +1,51 @@ +{ + config, + lib, + my, + ... +}: +let + legacyBackend = "http://10.23.20.32"; + legacyHosts = [ + "games.alexion.dev" + "git.alexion.dev" + "music.alexion.dev" + "photos.alexion.dev" + "plex.alexion.dev" + "servers.alexion.dev" + "todo.alexion.dev" + "wings.alexion.dev" + ]; + legacyRoutes = lib.listToAttrs ( + map (host: { + name = "legacy-${lib.replaceStrings [ "." ] [ "-" ] host}"; + value = { + inherit host; + backend = legacyBackend; + }; + }) legacyHosts + ); +in +{ + imports = [ + (my.guest { + name = "reverse-proxy"; + interior = { + modules.reverse-proxy = { + enable = true; + routes = config.modules.reverse-proxy.routes // legacyRoutes; + }; + }; + }) + ]; + + config = lib.mkIf config.guests.reverse-proxy.enable { + guests.reverse-proxy = { + limits = { + memory = lib.mkDefault "1G"; + cpu = lib.mkDefault "100%"; + tasksMax = lib.mkDefault 256; + }; + }; + }; +} diff --git a/hosts/pikachu/default.nix b/hosts/pikachu/default.nix index cf9624b..3d22b44 100644 --- a/hosts/pikachu/default.nix +++ b/hosts/pikachu/default.nix @@ -79,6 +79,17 @@ vlan = 20; mac = "BC:24:11:C1:CD:28"; dataPath = "/pikachu/data/actualbudget"; + reverseProxy = { + enable = true; + host = "budget.alexion.dev"; + backend = "http://10.23.20.42:5006"; + }; + }; + + guests.reverse-proxy = { + enable = true; + vlan = 20; + mac = "02:98:64:5a:07:4f"; }; guests.copyparty = { diff --git a/modules/reverse-proxy.nix b/modules/reverse-proxy.nix new file mode 100644 index 0000000..1593970 --- /dev/null +++ b/modules/reverse-proxy.nix @@ -0,0 +1,130 @@ +{ config, lib, ... }: +let + cfg = config.modules.reverse-proxy; + + routeType = lib.types.submodule ( + { name, ... }: + { + options = { + enable = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether this reverse-proxy route is active."; + }; + + host = lib.mkOption { + type = lib.types.str; + default = name; + defaultText = lib.literalMD "the route attribute name"; + description = "Primary hostname served by this route."; + }; + + aliases = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = "Additional hostnames served by the primary route."; + }; + + path = lib.mkOption { + type = lib.types.str; + default = "/"; + description = "URL path prefix proxied by this route."; + }; + + backend = lib.mkOption { + type = lib.types.str; + description = "Full HTTP backend URL that receives proxied traffic."; + }; + + tls = lib.mkOption { + type = lib.types.bool; + default = true; + description = "Whether to serve this route with ACME-backed TLS."; + }; + + websockets = lib.mkOption { + type = lib.types.bool; + default = false; + description = "Whether to enable websocket proxy headers for this route."; + }; + }; + } + ); + + enabledRoutes = lib.filterAttrs (_: route: route.enable) cfg.routes; + routesByHost = lib.groupBy (route: route.host) (lib.attrValues enabledRoutes); + + rootRoutesByHost = lib.mapAttrs ( + host: routes: + let + roots = builtins.filter (route: route.path == "/") routes; + in + if roots == [ ] then null else builtins.head roots + ) routesByHost; + + hasDuplicatePaths = routes: + let + paths = map (route: route.path) routes; + in + lib.length paths != lib.length (lib.unique paths); + + mkLocation = route: { + proxyPass = route.backend; + proxyWebsockets = route.websockets; + }; + + mkVirtualHost = host: routes: + let + rootRoute = rootRoutesByHost.${host}; + in + lib.mkIf (rootRoute != null) { + forceSSL = rootRoute.tls; + enableACME = rootRoute.tls; + serverAliases = rootRoute.aliases; + locations = lib.listToAttrs ( + map (route: lib.nameValuePair route.path (mkLocation route)) routes + ); + }; +in +{ + options.modules.reverse-proxy = { + enable = lib.mkEnableOption "the Nginx reverse proxy route realizer"; + + routes = lib.mkOption { + type = lib.types.attrsOf 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."; + }) rootRoutesByHost + ++ lib.mapAttrsToList (host: routes: { + assertion = !hasDuplicatePaths routes; + message = "modules.reverse-proxy.routes for ${host} must not declare the same path more than once."; + }) routesByHost; + + networking.firewall.allowedTCPPorts = [ + 80 + 443 + ]; + + security.acme = { + acceptTerms = true; + defaults.email = "contact@alexion.dev"; + }; + + services.nginx = { + enable = true; + recommendedGzipSettings = true; + recommendedOptimisation = true; + recommendedProxySettings = true; + recommendedTlsSettings = true; + virtualHosts = lib.mapAttrs mkVirtualHost routesByHost; + }; + }; +} diff --git a/modules/services/actualbudget.nix b/modules/services/actualbudget.nix index 68683bd..45a1d18 100644 --- a/modules/services/actualbudget.nix +++ b/modules/services/actualbudget.nix @@ -40,6 +40,7 @@ in default = "storage"; description = "Group account that owns Actual Budget's writable state."; }; + }; config = lib.mkIf cfg.enable {