From 609e562becd6e92d595fb2de1e52e3b16eeb0c36 Mon Sep 17 00:00:00 2001 From: oddlama Date: Wed, 21 Jun 2023 23:56:12 +0200 Subject: [PATCH] feat: add oauth2 proxy module and simple nginx reverse proxy module --- modules/extra.nix | 122 ++++++++++++++++++++--------- modules/microvms.nix | 2 +- modules/oauth2-proxy.nix | 161 +++++++++++++++++++++++++++++++++++++++ modules/promtail.nix | 2 +- 4 files changed, 249 insertions(+), 38 deletions(-) create mode 100644 modules/oauth2-proxy.nix diff --git a/modules/extra.nix b/modules/extra.nix index c9169ab..8464d02 100644 --- a/modules/extra.nix +++ b/modules/extra.nix @@ -1,32 +1,70 @@ { config, lib, + nodePath, ... }: let inherit (lib) assertMsg filter + flip + genAttrs hasInfix head + mapAttrs + mapAttrs' mdDoc mkIf mkOption + nameValuePair optionals removeSuffix types ; in { - options.extra.acme.wildcardDomains = mkOption { - default = []; - example = ["example.org"]; - type = types.listOf types.str; - description = mdDoc '' - All domains for which a wildcard certificate will be generated. - This will define the given `security.acme.certs` and set `extraDomainNames` correctly, - but does not fill any options such as credentials or dnsProvider. These have to be set - individually for each cert by the user or via `security.acme.defaults`. - ''; + options.extra = { + acme.wildcardDomains = mkOption { + default = []; + example = ["example.org"]; + type = types.listOf types.str; + description = mdDoc '' + All domains for which a wildcard certificate will be generated. + This will define the given `security.acme.certs` and set `extraDomainNames` correctly, + but does not fill any options such as credentials or dnsProvider. These have to be set + individually for each cert by the user or via `security.acme.defaults`. + ''; + }; + + nginx.proxiedDomains = mkOption { + default = {}; + description = mdDoc "Simplified reverse proxy setup."; + type = types.attrsOf (types.submodule (submod: { + options = { + domain = mkOption { + type = types.str; + description = mdDoc "The public domain for the virtual host."; + }; + + upstream = mkOption { + type = types.str; + description = mdDoc "The upstream server to which requests are forwarded."; + }; + + scheme = mkOption { + type = types.str; + default = "http"; + description = mdDoc "The scheme to use when connecting to upstream."; + }; + + useACMEHost = mkOption { + type = types.str; + default = config.lib.extra.matchingWildcardCert submod.config.domain; + description = mdDoc "The acme host certificate to use for the virtual host."; + }; + }; + })); + }; }; config = { @@ -44,31 +82,17 @@ in { head matchingCerts; }; - security.acme.certs = lib.genAttrs config.extra.acme.wildcardDomains (domain: { + security.acme.certs = genAttrs config.extra.acme.wildcardDomains (domain: { extraDomainNames = ["*.${domain}"]; }); - # Sensible defaults for caddy - services.caddy = mkIf config.services.caddy.enable { - extraConfig = '' - (common) { - encode zstd gzip - - header { - # Enable HTTP Strict Transport Security (HSTS) - Strict-Transport-Security "max-age=63072000; includeSubdomains; preload"; - - X-XSS-Protection "1; mode=block" - X-Frame-Options "DENY" - X-Content-Type-Options "nosniff" - - # Remove unnecessary information and remove Last-Modified in favor of ETag - -Server - -X-Powered-By - -Last-Modified - } - } - ''; + age.secrets = mkIf config.services.nginx.enable { + "dhparams.pem" = { + rekeyFile = nodePath + "/secrets/dhparams.pem.age"; + generator = "dhparams"; + mode = "440"; + group = "nginx"; + }; }; # Sensible defaults for nginx @@ -86,12 +110,38 @@ in { error_log syslog:server=unix:/dev/log; access_log syslog:server=unix:/dev/log; ssl_ecdh_curve secp384r1; + + # Enable HTTP Strict Transport Security (HSTS) + add_header Strict-Transport-Security "max-age=63072000; includeSubdomains; preload"; + + # Minimize information leaked to other domains + add_header Referrer-Policy "origin-when-cross-origin"; + + add_header X-XSS-Protection "1; mode=block"; + add_header X-Frame-Options "DENY"; + add_header X-Content-Type-Options "nosniff"; ''; + + upstreams = + flip mapAttrs config.extra.nginx.proxiedDomains + (name: cfg: { + servers."${cfg.upstream}" = {}; + extraConfig = '' + zone ${name} 64k; + keepalive 2; + ''; + }); + + virtualHosts = + flip mapAttrs' config.extra.nginx.proxiedDomains + (name: cfg: + nameValuePair cfg.domain { + forceSSL = true; + inherit (cfg) useACMEHost; + locations."/".proxyPass = "${cfg.scheme}://${name}"; + }); }; - networking.firewall.allowedTCPPorts = - optionals - (config.services.caddy.enable || config.services.nginx.enable) - [80 443]; + networking.firewall.allowedTCPPorts = optionals config.services.nginx.enable [80 443]; }; } diff --git a/modules/microvms.nix b/modules/microvms.nix index d616543..7b73696 100644 --- a/modules/microvms.nix +++ b/modules/microvms.nix @@ -318,7 +318,7 @@ in { }; zfs = { - enable = mkEnableOption (mdDoc "Enable persistent data on separate zfs dataset"); + enable = mkEnableOption (mdDoc "persistent data on separate zfs dataset"); pool = mkOption { type = types.str; diff --git a/modules/oauth2-proxy.nix b/modules/oauth2-proxy.nix new file mode 100644 index 0000000..c4dbd1e --- /dev/null +++ b/modules/oauth2-proxy.nix @@ -0,0 +1,161 @@ +{ + lib, + config, + pkgs, + ... +}: let + inherit + (lib) + concatStringsSep + flip + mapAttrs + mdDoc + mkEnableOption + mkIf + mkOption + optionalString + types + ; + + cfg = config.extra.oauth2_proxy; +in { + options.extra.oauth2_proxy = { + enable = mkEnableOption (mdDoc "oauth2 proxy"); + + cookieDomain = mkOption { + type = types.str; + description = mdDoc "The domain under which to store the credetial cookie."; + }; + + authProxyDomain = mkOption { + type = types.str; + description = mdDoc '' + The domain under which to expose the oauth2 proxy. + This must be a subdomain at or below the level of `cookieDomain`. + ''; + }; + + nginx.virtualHosts = mkOption { + default = {}; + description = mdDoc '' + Access to all virtualHostst that have an entry here will be put behind + the oauth2 proxy, requiring authentication before users can access the resource. + Also set `allowedGroups` for granuar access control. + ''; + type = types.attrsOf (types.submodule { + options.allowedGroups = mkOption { + type = types.listOf types.str; + default = []; + description = mdDoc '' + A list of groups that are allowed to access this resource, or the + empty list to allow any authenticated client. + ''; + }; + }); + }; + }; + + config = mkIf cfg.enable { + services.oauth2_proxy = { + enable = true; + cookie.secure = true; + cookie.httpOnly = false; + reverseProxy = true; + httpAddress = "unix:///run/oauth2_proxy/oauth2_proxy.sock"; + + # Share the cookie with all subpages + extraConfig.whitelist-domain = ".${cfg.cookieDomain}"; + setXauthrequest = true; + }; + + systemd.services.oauth2_proxy.serviceConfig = { + RuntimeDirectory = "oauth2_proxy"; + RuntimeDirectoryMode = "0750"; + }; + + users.groups.oauth2_proxy.members = ["nginx"]; + + services.nginx = { + upstreams.oauth2_proxy = { + servers."unix:/run/oauth2_proxy/oauth2_proxy.sock" = {}; + extraConfig = '' + zone oauth2_proxy 64k; + keepalive 2; + ''; + }; + + virtualHosts = + { + ${cfg.authProxyDomain} = { + forceSSL = true; + useACMEHost = config.lib.extra.matchingWildcardCert cfg.authProxyDomain; + + locations."/".extraConfig = '' + deny all; + return 404; + ''; + + locations."/oauth2/" = { + proxyPass = "http://oauth2_proxy"; + extraConfig = '' + proxy_set_header X-Scheme $scheme; + proxy_set_header X-Auth-Request-Redirect $scheme://$host$request_uri; + ''; + }; + + locations."= /oauth2/auth" = { + proxyPass = "http://oauth2_proxy"; + extraConfig = '' + proxy_set_header X-Scheme $scheme; + # nginx auth_request includes headers but not body + proxy_set_header Content-Length ""; + proxy_pass_request_body off; + ''; + }; + }; + } + // flip mapAttrs cfg.nginx.virtualHosts + (vhost: vhostCfg: let + authQuery = + optionalString (vhostCfg.allowedGroups != []) + "?allowed_groups=${concatStringsSep "," vhostCfg.allowedGroups}"; + in { + locations."/".extraConfig = '' + auth_request /oauth2/auth; + error_page 401 = /oauth2/sign_in; + + # pass information via X-User and X-Email headers to backend, + # requires running with --set-xauthrequest flag + auth_request_set $user $upstream_http_x_auth_request_user; + auth_request_set $email $upstream_http_x_auth_request_email; + proxy_set_header X-User $user; + proxy_set_header X-Email $email; + + # if you enabled --cookie-refresh, this is needed for it to work with auth_request + auth_request_set $auth_cookie $upstream_http_set_cookie; + add_header Set-Cookie $auth_cookie; + ''; + + locations."/oauth2/" = { + proxyPass = "https://${cfg.authProxyDomain}"; + extraConfig = '' + proxy_set_header X-Scheme $scheme; + proxy_set_header X-Auth-Request-Redirect $scheme://$host$request_uri; + ''; + }; + + locations."= /oauth2/auth" = { + proxyPass = "https://${cfg.authProxyDomain}${authQuery}"; + extraConfig = '' + internal; + + proxy_set_header X-Scheme $scheme; + # nginx auth_request includes headers but not body + proxy_set_header Content-Length ""; + proxy_pass_request_body off; + ''; + }; + }); + }; + }; +} diff --git a/modules/promtail.nix b/modules/promtail.nix index 415a141..4a6845d 100644 --- a/modules/promtail.nix +++ b/modules/promtail.nix @@ -18,7 +18,7 @@ cfg = config.extra.promtail; in { options.extra.promtail = { - enable = mkEnableOption (mdDoc "Enable promtail to push logs to a loki instance."); + enable = mkEnableOption (mdDoc "promtail to push logs to a loki instance."); proxy = mkOption { type = types.str; description = mdDoc "The node name of the proxy server which provides the https loki api endpoint.";