diff --git a/modules/services/default.nix b/modules/services/default.nix index d444303..8b105cf 100644 --- a/modules/services/default.nix +++ b/modules/services/default.nix @@ -4,6 +4,7 @@ ./homer ./jellyfin ./mumble-server + ./nginx ./rss-bridge ./ssh-server ]; diff --git a/modules/services/nginx/default.nix b/modules/services/nginx/default.nix new file mode 100644 index 0000000..173bd97 --- /dev/null +++ b/modules/services/nginx/default.nix @@ -0,0 +1,407 @@ +# A simple abstraction layer for almost all of my services' needs +{ config, lib, pkgs, ... }: +let + cfg = config.my.services.nginx; + virtualHostOption = with lib; types.submodule { + options = { + subdomain = mkOption { + type = types.str; + example = "dev"; + description = '' + Which subdomain, under config.networking.domain, to use + for this virtual host. + ''; + }; + port = mkOption { + type = with types; nullOr port; + default = null; + example = 8080; + description = '' + Which port to proxy to, through 127.0.0.1, for this virtual host. + This option is incompatible with `root`. + ''; + }; + root = mkOption { + type = with types; nullOr path; + default = null; + example = "/var/www/blog"; + description = '' + The root folder for this virtual host. This option is incompatible + with `port`. + ''; + }; + sso = { + enable = mkEnableOption "SSO authentication"; + }; + extraConfig = mkOption { + type = types.attrs; # FIXME: forward type of virtualHosts + example = litteralExample '' + { + locations."/socket" = { + proxyPass = "http://127.0.0.1:8096/"; + proxyWebsockets = true; + }; + } + ''; + default = { }; + description = '' + Any extra configuration that should be applied to this virtual host. + ''; + }; + }; + }; +in +{ + imports = [ + ./sso + ]; + options.my.services.nginx = with lib; { + enable = mkEnableOption "Nginx"; + acme = { + credentialsFile = mkOption { + type = types.str; + example = "/var/lib/acme/creds.env"; + description = '' + INWX API key file as an 'EnvironmentFile' (see `systemd.exec(5)`) + ''; + }; + }; + virtualHosts = mkOption { + type = types.listOf virtualHostOption; + default = [ ]; + example = litteralExample '' + [ + { + subdomain = "gitea"; + port = 8080; + } + { + subdomain = "dev"; + root = "/var/www/dev"; + } + { + subdomain = "jellyfin"; + port = 8096; + extraConfig = { + locations."/socket" = { + proxyPass = "http://127.0.0.1:8096/"; + proxyWebsockets = true; + }; + }; + } + ] + ''; + description = '' + List of virtual hosts to set-up using default settings. + ''; + }; + sso = { + authKeyFile = mkOption { + type = types.str; + example = "/var/lib/nginx-sso/auth-key.txt"; + description = '' + Path to the auth key. + ''; + }; + subdomain = mkOption { + type = types.str; + default = "login"; + example = "auth"; + description = "Which subdomain, to use for SSO."; + }; + port = mkOption { + type = types.port; + default = 8082; + example = 8080; + description = "Port to use for internal webui."; + }; + users = mkOption { + type = types.attrsOf (types.submodule { + options = { + passwordHashFile = mkOption { + type = types.str; + example = "/var/lib/nginx-sso/alice/password-hash.txt"; + description = "Path to file containing the user's password hash."; + }; + totpSecretFile = mkOption { + type = types.str; + example = "/var/lib/nginx-sso/alice/totp-secret.txt"; + description = "Path to file containing the user's TOTP secret."; + }; + }; + }); + example = litteralExample '' + { + alice = { + passwordHashFile = "/var/lib/nginx-sso/alice/password-hash.txt"; + totpSecretFile = "/var/lib/nginx-sso/alice/totp-secret.txt"; + }; + } + ''; + description = "Definition of users"; + }; + groups = mkOption { + type = with types; attrsOf (listOf str); + example = litteralExample '' + { + root = [ "alice" ]; + users = [ "alice" "bob" ]; + } + ''; + description = "Groups of users"; + }; + }; + }; + config = lib.mkIf cfg.enable { + assertions = [ ] + ++ (lib.flip builtins.map cfg.virtualHosts ({ subdomain, ... } @ args: + let + conflicts = [ "port" "root" ]; + optionsNotNull = builtins.map (v: args.${v} != null) conflicts; + optionsSet = lib.filter lib.id optionsNotNull; + in + { + assertion = builtins.length optionsSet == 1; + message = '' + Subdomain '${subdomain}' must have exactly one of ${ + lib.concatStringsSep ", " (builtins.map (v: "'${v}'") conflicts) + } configured. + ''; + })) + # ++ ( + # let + # ports = lib.my.mapFilter + # (v: v != null) + # ({ port, ... }: port) + # cfg.virtualHosts; + # lib.unique ports; + # lib.compareLists ports + # portCounts = lib.my.countValues ports; + # nonUniquesCounts = lib.filterAttrs (_: v: v != 1) portCounts; + # nonUniques = builtins.attrNames nonUniquesCounts; + # mkAssertion = port: { + # assertion = false; + # message = "Port ${port} cannot appear in multiple virtual hosts."; + # }; + # in + # map mkAssertion nonUniques + # ) ++ ( + # let + # subs = map ({ subdomain, ... }: subdomain) cfg.virtualHosts; + # subsCounts = lib.my.countValues subs; + # nonUniquesCounts = lib.filterAttrs (_: v: v != 1) subsCounts; + # nonUniques = builtins.attrNames nonUniquesCounts; + # mkAssertion = v: { + # assertion = false; + # message = '' + # Subdomain '${v}' cannot appear in multiple virtual hosts. + # ''; + # }; + # in + # map mkAssertion nonUniques + # ) + ; + services.nginx = { + enable = true; + + recommendedGzipSettings = true; + recommendedOptimisation = true; + recommendedTlsSettings = true; + recommendedProxySettings = true; + + # Only allow PFS-enabled ciphers with AES256 + sslCiphers = "AES256+EECDH:AES256+EDH:!aNULL"; + + commonHttpConfig = '' + # Add HSTS header with preloading to HTTPS requests. + # Adding this header to HTTP requests is discouraged + map $scheme $hsts_header { + https "max-age=31536000; includeSubdomains; preload"; + } + add_header Strict-Transport-Security $hsts_header; + + # CORS header + # some applications set it to wildcard, therefore this overrides it + proxy_hide_header Access-Control-Allow-Origin; + add_header Access-Control-Allow-Origin https://${config.networking.domain}; + + # Minimize information leaked to other domains + add_header 'Referrer-Policy' 'strict-origin-when-cross-origin'; + + # Disable embedding as a frame + add_header X-Frame-Options DENY; + + # Prevent injection of code in other mime types (XSS Attacks) + add_header X-Content-Type-Options nosniff; + + # Enable XSS protection of the browser. + # May be unnecessary when CSP is configured properly (see above) + add_header X-XSS-Protection "1; mode=block"; + + # This might create errors + proxy_cookie_path / "/; secure; HttpOnly; SameSite=strict"; + + # Enable CSP for your services. + #add_header Content-Security-Policy "script-src 'self'; object-src 'none'; base-uri 'none';" always; + ''; + + virtualHosts = + let + genAttrs' = values: f: lib.listToAttrs (map f values); + domain = config.networking.domain; + mkVHost = ({ subdomain, ... } @ args: lib.nameValuePair + "${subdomain}.${domain}" + (lib.foldl lib.recursiveUpdate { } [ + # Base configuration + { + forceSSL = true; + useACMEHost = domain; + } + # Proxy to port + (lib.optionalAttrs (args.port != null) { + locations."/".proxyPass = + "http://127.0.0.1:${toString args.port}"; + }) + # Serve filesystem content + (lib.optionalAttrs (args.root != null) { + inherit (args) root; + }) + # VHost specific configuration + args.extraConfig + # SSO configuration + (lib.optionalAttrs args.sso.enable { + extraConfig = (args.extraConfig.extraConfig or "") + '' + error_page 401 = @error401; + ''; + locations."@error401".return = '' + 302 https://${cfg.sso.subdomain}.${config.networking.domain}/login?go=$scheme://$http_host$request_uri + ''; + locations."/" = { + extraConfig = + (args.extraConfig.locations."/".extraConfig or "") + '' + # Use SSO + auth_request /sso-auth; + # Set username through header + auth_request_set $username $upstream_http_x_username; + proxy_set_header X-User $username; + # Renew SSO cookie on request + auth_request_set $cookie $upstream_http_set_cookie; + add_header Set-Cookie $cookie; + ''; + }; + locations."/sso-auth" = { + proxyPass = "http://localhost:${toString cfg.sso.port}/auth"; + extraConfig = '' + # Do not allow requests from outside + internal; + # Do not forward the request body + proxy_pass_request_body off; + proxy_set_header Content-Length ""; + # Set X-Application according to subdomain for matching + proxy_set_header X-Application "${subdomain}"; + # Set origin URI for matching + proxy_set_header X-Origin-URI $request_uri; + ''; + }; + }) + ]) + ); + in + genAttrs' cfg.virtualHosts mkVHost; + sso = { + enable = true; + configuration = { + listen = { + addr = "127.0.0.1"; + inherit (cfg.sso) port; + }; + audit_log = { + target = [ + "fd://stdout" + ]; + events = [ + "access_denied" + "login_success" + "login_failure" + "logout" + "validate" + ]; + headers = [ + "x-origin-uri" + "x-application" + ]; + }; + cookie = { + domain = ".${config.networking.domain}"; + secure = true; + authentication_key = { + _secret = cfg.sso.authKeyFile; + }; + }; + login = { + title = "Bühlers's SSO"; + default_method = "simple"; + hide_mfa_field = false; + names = { + simple = "Username / Password"; + }; + }; + providers = { + simple = + let + applyUsers = lib.flip lib.mapAttrs cfg.sso.users; + in + { + users = applyUsers (_: v: { _secret = v.passwordHashFile; }); + mfa = applyUsers (_: v: [{ + provider = "totp"; + attributes = { + secret = { + _secret = v.totpSecretFile; + }; + }; + }]); + inherit (cfg.sso) groups; + }; + }; + acl = { + rule_sets = [ + { + rules = [{ field = "x-application"; present = true; }]; + allow = [ "@root" ]; + } + ]; + }; + }; + }; + }; + my.services.nginx.virtualHosts = [ + { + subdomain = "login"; + inherit (cfg.sso) port; + } + ]; + networking.firewall.allowedTCPPorts = [ 80 443 ]; + # Nginx needs to be able to read the certificates + users.users.nginx.extraGroups = [ "acme" ]; + security.acme = { + defaults.email = "server@buehler.rocks"; + # this is specially needed for inwx and does not work without it + defaults.dnsResolver = "ns.inwx.de"; + acceptTerms = true; + # Use DNS wildcard certificate + certs = + let + domain = config.networking.domain; + in + with pkgs; + { + "${domain}" = { + extraDomainNames = [ "*.${domain}" ]; + dnsProvider = "inwx"; + inherit (cfg.acme) credentialsFile; + }; + }; + }; + }; +} diff --git a/modules/services/nginx/sso/default.nix b/modules/services/nginx/sso/default.nix new file mode 100644 index 0000000..13292ec --- /dev/null +++ b/modules/services/nginx/sso/default.nix @@ -0,0 +1,90 @@ +# I must override the module to allow having runtime secrets +{ config, lib, pkgs, utils, ... }: +let + cfg = config.services.nginx.sso; + pkg = lib.getBin cfg.package; + confPath = "/var/lib/nginx-sso/config.json"; +in +{ + disabledModules = [ "services/security/nginx-sso.nix" ]; + + + options.services.nginx.sso = with lib; { + enable = mkEnableOption "nginx-sso service"; + + package = mkOption { + type = types.package; + default = pkgs.nginx-sso; + defaultText = "pkgs.nginx-sso"; + description = '' + The nginx-sso package that should be used. + ''; + }; + + configuration = mkOption { + type = types.attrsOf types.unspecified; + default = { }; + example = literalExample '' + { + listen = { addr = "127.0.0.1"; port = 8080; }; + + providers.token.tokens = { + myuser = "MyToken"; + }; + + acl = { + rule_sets = [ + { + rules = [ { field = "x-application"; equals = "MyApp"; } ]; + allow = [ "myuser" ]; + } + ]; + }; + } + ''; + description = '' + nginx-sso configuration + (documentation) + as a Nix attribute set. + ''; + }; + }; + + config = lib.mkIf cfg.enable { + systemd.services.nginx-sso = { + description = "Nginx SSO Backend"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + StateDirectory = "nginx-sso"; + WorkingDirectory = "/var/lib/nginx-sso"; + # The files to be merged might not have the correct permissions + ExecStartPre = ''+${pkgs.writeScript "merge-nginx-sso-config" '' + #!${pkgs.bash}/bin/bash + rm -f '${confPath}' + ${utils.genJqSecretsReplacementSnippet cfg.configuration confPath} + + # Fix permissions + chown nginx-sso:nginx-sso ${confPath} + chmod 0600 ${confPath} + '' + }''; + ExecStart = lib.mkForce '' + ${pkg}/bin/nginx-sso \ + --config ${confPath} \ + --frontend-dir ${pkg}/share/frontend + ''; + Restart = "always"; + User = "nginx-sso"; + Group = "nginx-sso"; + }; + }; + + users.users.nginx-sso = { + isSystemUser = true; + group = "nginx-sso"; + }; + + users.groups.nginx-sso = { }; + }; +}