about summary refs log tree commit diff
diff options
context:
space:
mode:
authorFranck Cuny <franck@fcuny.net>2022-04-13 11:24:02 -0700
committerFranck Cuny <franck@fcuny.net>2022-04-13 11:24:02 -0700
commit1636706b98afceb1b073c25b52a904f267c61910 (patch)
tree30a8943fc2473c8e568c09bf3e098664023cb998
parentfish: only start sway when sway is installed (diff)
downloadworld-1636706b98afceb1b073c25b52a904f267c61910.tar.gz
nginx: add nginx as a reverse proxy
This will ultimately replace traefik.
-rw-r--r--hosts/tahoe/services.nix15
-rw-r--r--modules/services/default.nix1
-rw-r--r--modules/services/gitea/default.nix5
-rw-r--r--modules/services/nginx/default.nix326
-rw-r--r--modules/services/nginx/sso/default.nix80
5 files changed, 426 insertions, 1 deletions
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
+        (<link xlink:href="https://github.com/Luzifer/nginx-sso/wiki/Main-Configuration">documentation</link>)
+        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 = { };
+  };
+}