From 1636706b98afceb1b073c25b52a904f267c61910 Mon Sep 17 00:00:00 2001 From: Franck Cuny Date: Wed, 13 Apr 2022 11:24:02 -0700 Subject: nginx: add nginx as a reverse proxy This will ultimately replace traefik. --- hosts/tahoe/services.nix | 15 +- modules/services/default.nix | 1 + modules/services/gitea/default.nix | 5 + modules/services/nginx/default.nix | 326 +++++++++++++++++++++++++++++++++ modules/services/nginx/sso/default.nix | 80 ++++++++ 5 files changed, 426 insertions(+), 1 deletion(-) create mode 100644 modules/services/nginx/default.nix create mode 100644 modules/services/nginx/sso/default.nix diff --git a/hosts/tahoe/services.nix b/hosts/tahoe/services.nix index 75a6ee2..8846eae 100644 --- a/hosts/tahoe/services.nix +++ b/hosts/tahoe/services.nix @@ -18,7 +18,20 @@ in { stateDir = "/var/lib/gitea"; }; rclone = { enable = true; }; - traefik = { enable = true; }; + traefik = { enable = false; }; + nginx = { + enable = true; + acme = { credentialsFile = secrets."acme/dns-key".path; }; + sso = { + authKeyFile = secrets."sso/auth-key".path; + users = { + fcuny = { + passwordHashFile = secrets."sso/fcuny/password-hash".path; + }; + }; + groups = { root = [ "fcuny" ]; }; + }; + }; transmission = { enable = true; }; metrics-exporter = { enable = true; }; backup = { diff --git a/modules/services/default.nix b/modules/services/default.nix index 24602cc..a5d6654 100644 --- a/modules/services/default.nix +++ b/modules/services/default.nix @@ -10,6 +10,7 @@ ./grafana ./metrics-exporter ./navidrome + ./nginx ./prometheus ./rclone ./samba diff --git a/modules/services/gitea/default.nix b/modules/services/gitea/default.nix index d232001..6828439 100644 --- a/modules/services/gitea/default.nix +++ b/modules/services/gitea/default.nix @@ -37,6 +37,11 @@ in { }; }; + my.services.nginx.virtualHosts = [{ + subdomain = "gitea"; + port = 8002; + }]; + my.services.backup = { paths = [ cfg.stateDir ]; }; }; } diff --git a/modules/services/nginx/default.nix b/modules/services/nginx/default.nix new file mode 100644 index 0000000..0020111 --- /dev/null +++ b/modules/services/nginx/default.nix @@ -0,0 +1,326 @@ +# 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"; + monitoring = { + enable = my.mkDisableOption "monitoring through grafana and prometheus"; + }; + 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."; + }; + }; + }); + example = litteralExample '' + { + alice = { + passwordHashFile = "/var/lib/nginx-sso/alice/password-hash.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; + 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; + statusPage = true; # For monitoring scraping. + recommendedGzipSettings = true; + recommendedOptimisation = true; + recommendedTlsSettings = true; + recommendedProxySettings = true; + virtualHosts = let + domain = "fcuny.net"; + mkVHost = ({ subdomain, ... }@args: + lib.nameValuePair "${subdomain}.${domain}" (lib.my.recursiveMerge [ + # 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}.fcuny.net/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 lib.my.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 = ".fcuny.net"; + secure = true; + authentication_key = { _secret = cfg.sso.authKeyFile; }; + }; + login = { + title = "fcuny.net'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; }); + 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 = "franck@fcuny.net"; + acceptTerms = true; + }; + + services.grafana.provision.dashboards = lib.mkIf cfg.monitoring.enable [{ + name = "NGINX"; + options.path = pkgs.nur.repos.alarsyo.grafanaDashboards.nginx; + disableDeletion = true; + }]; + + services.prometheus = lib.mkIf cfg.monitoring.enable { + exporters.nginx = { + enable = true; + listenAddress = "127.0.0.1"; + }; + scrapeConfigs = [{ + job_name = "nginx"; + static_configs = [{ + targets = [ + "127.0.0.1:${ + toString config.services.prometheus.exporters.nginx.port + }" + ]; + labels = { instance = config.networking.hostName; }; + }]; + }]; + }; + }; +} diff --git a/modules/services/nginx/sso/default.nix b/modules/services/nginx/sso/default.nix new file mode 100644 index 0000000..27ed7d6 --- /dev/null +++ b/modules/services/nginx/sso/default.nix @@ -0,0 +1,80 @@ +# 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 = { }; + }; +} -- cgit 1.4.1