diff --git a/flake.nix b/flake.nix index e1c2a5d..4740f82 100644 --- a/flake.nix +++ b/flake.nix @@ -117,6 +117,92 @@ ${lib.optionalString (!serviceIdentityTestsPass) "exit 1"} touch $out ''; + + reverse-proxy-basic-route = + let + testConfig = + (lib.nixosSystem { + system = "x86_64-linux"; + specialArgs.my = self.lib; + 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"; + specialArgs.my = self.lib; + 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/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/lib/default.nix b/lib/default.nix index 8e3f1ba..22be36d 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -14,6 +14,8 @@ let ; serviceIdentityRegistry = import ./service-identities.nix { inherit lib; }; + reverseProxyRoutes = import ./reverse-proxy-routes.nix { inherit lib; }; + collectNixFiles = dir: flatten ( @@ -44,6 +46,7 @@ let self specialArgs bridgeName + reverseProxyRoutes ; }; @@ -81,6 +84,7 @@ in mkHost mkHosts bridgeName + reverseProxyRoutes ; inherit (guests) deriveMac guest; diff --git a/lib/guests.nix b/lib/guests.nix index e9a0555..a4c7a51 100644 --- a/lib/guests.nix +++ b/lib/guests.nix @@ -3,6 +3,7 @@ self, specialArgs, bridgeName, + reverseProxyRoutes, }: let inherit (lib) escapeShellArg mapAttrsToList; @@ -130,6 +131,10 @@ let guest takes its address by DHCP, keeping IP management at the router. ''; }; + reverseProxy = reverseProxyRoutes.mkRouteOptions { + subject = "guest route"; + enableDefault = false; + }; mounts = lib.mkOption { type = lib.types.attrsOf ( lib.types.submodule { @@ -273,6 +278,10 @@ let config = lib.mkIf cfg.enable { sops.secrets = lib.genAttrs cfg.secrets (_: { }); + modules.reverse-proxy.routes.${machineName} = lib.mkIf cfg.reverseProxy.enable ( + reverseProxyRoutes.routeFields cfg.reverseProxy + ); + assertions = [ { assertion = mountCollisions == [ ]; diff --git a/lib/reverse-proxy-routes.nix b/lib/reverse-proxy-routes.nix new file mode 100644 index 0000000..920f85c --- /dev/null +++ b/lib/reverse-proxy-routes.nix @@ -0,0 +1,77 @@ +{ 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.str; + description = "Full HTTP backend URL that receives proxied traffic."; + }; + + 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 + tls + websockets + ; + }; +in +{ + inherit mkRouteOptions routeFields; + + routeType = lib.types.submodule ( + { name, ... }: + { + options = mkRouteOptions { inherit name; }; + } + ); +} diff --git a/modules/reverse-proxy.nix b/modules/reverse-proxy.nix new file mode 100644 index 0000000..799a181 --- /dev/null +++ b/modules/reverse-proxy.nix @@ -0,0 +1,82 @@ +{ config, lib, my, ... }: +let + cfg = config.modules.reverse-proxy; + inherit (my) reverseProxyRoutes; + + 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 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."; + }) 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 {