From adc09acc497253835f3d4f2147e8109a820df176 Mon Sep 17 00:00:00 2001 From: ibizaman Date: Thu, 11 Jan 2024 23:22:46 -0800 Subject: [PATCH] use contract for ssl block --- docs/blocks.md | 4 + docs/default.nix | 23 +- flake.nix | 3 + modules/blocks/authelia.nix | 14 +- modules/blocks/ldap.nix | 14 +- modules/blocks/monitoring.nix | 15 +- modules/blocks/nginx.nix | 14 +- modules/blocks/ssl.nix | 402 ++++++++++++++++++++------ modules/blocks/ssl/docs/default.md | 159 ++++++++++ modules/contracts/default.nix | 4 + modules/contracts/ssl.nix | 58 ++++ modules/services/arr.nix | 10 +- modules/services/deluge.nix | 10 +- modules/services/hledger.nix | 10 +- modules/services/home-assistant.nix | 16 +- modules/services/jellyfin.nix | 16 +- modules/services/nextcloud-server.nix | 36 ++- modules/services/vaultwarden.nix | 10 +- test/modules/arr.nix | 2 + test/modules/nginx.nix | 38 ++- test/vm/ssl.nix | 76 +++++ 21 files changed, 790 insertions(+), 144 deletions(-) create mode 100644 modules/blocks/ssl/docs/default.md create mode 100644 modules/contracts/default.nix create mode 100644 modules/contracts/ssl.nix create mode 100644 test/vm/ssl.nix diff --git a/docs/blocks.md b/docs/blocks.md index 1314d1d..8558dc4 100644 --- a/docs/blocks.md +++ b/docs/blocks.md @@ -26,6 +26,10 @@ Self Host Blocks provides at least one implementation for each block and allows implementation if you want to, as long as it passes the tests. You can then use blocks to improve services you already have deployed. +```{=include=} chapters html:into-file=//blocks-ssl.html +modules/blocks/ssl/docs/default.md +``` + ```{=include=} chapters html:into-file=//blocks-backup.html modules/blocks/backup/docs/default.md ``` diff --git a/docs/default.nix b/docs/default.nix index 6f39680..c2df40d 100644 --- a/docs/default.nix +++ b/docs/default.nix @@ -68,21 +68,15 @@ let optionsDocs = buildOptionsDocs { modules = allModules ++ [ scrubbedModule ]; - variablelistId = "selfhostblocks-block-backup-options"; - includeModuleSystemOptions = false; - }; - - backupOptionsDocs = buildOptionsDocs { - modules = [ ../modules/blocks/backup.nix scrubbedModule ]; variablelistId = "selfhostblocks-options"; includeModuleSystemOptions = false; }; - nextcloudOptionsDocs = buildOptionsDocs { - modules = [ ../modules/services/nextcloud-server.nix scrubbedModule ]; + individualModuleOptionsDocs = path: (buildOptionsDocs { + modules = [ path scrubbedModule ]; variablelistId = "selfhostblocks-options"; includeModuleSystemOptions = false; - }; + }).optionsJSON; nmd = import nmdsrc { inherit lib; @@ -135,22 +129,27 @@ in stdenv.mkDerivation { '@OPTIONS_JSON@' \ ${optionsDocs.optionsJSON}/share/doc/nixos/options.json + substituteInPlace ./modules/blocks/ssl/docs/default.md \ + --replace \ + '@OPTIONS_JSON@' \ + ${individualModuleOptionsDocs ../modules/blocks/ssl.nix}/share/doc/nixos/options.json + substituteInPlace ./modules/blocks/backup/docs/default.md \ --replace \ '@OPTIONS_JSON@' \ - ${backupOptionsDocs.optionsJSON}/share/doc/nixos/options.json + ${individualModuleOptionsDocs ../modules/blocks/backup.nix}/share/doc/nixos/options.json substituteInPlace ./modules/services/nextcloud-server/docs/default.md \ --replace \ '@OPTIONS_JSON@' \ - ${nextcloudOptionsDocs.optionsJSON}/share/doc/nixos/options.json + ${individualModuleOptionsDocs ../modules/services/nextcloud-server.nix}/share/doc/nixos/options.json find . -name "*.md" -print0 | \ while IFS= read -r -d ''' f; do substituteInPlace "''${f}" \ --replace \ '@REPO@' \ - "${ghRoot}" + "${ghRoot}" 2>/dev/null done nixos-render-docs manual html \ diff --git a/flake.nix b/flake.nix index 37607ed..2191f9a 100644 --- a/flake.nix +++ b/flake.nix @@ -58,6 +58,8 @@ release = "0.0.1"; }; + lib.contracts = pkgs.callPackage ./modules/contracts {}; + checks = let importFiles = files: @@ -96,6 +98,7 @@ // (vm_test "postgresql" ./test/vm/postgresql.nix) // (vm_test "monitoring" ./test/vm/monitoring.nix) // (vm_test "nextcloud" ./test/vm/nextcloud.nix) + // (vm_test "ssl" ./test/vm/ssl.nix) ); } ); diff --git a/modules/blocks/authelia.nix b/modules/blocks/authelia.nix index 27ba9fb..fca1d47 100644 --- a/modules/blocks/authelia.nix +++ b/modules/blocks/authelia.nix @@ -3,6 +3,8 @@ let cfg = config.shb.authelia; + contracts = pkgs.callPackage ../contracts {}; + fqdn = "${cfg.subdomain}.${cfg.domain}"; autheliaCfg = config.services.authelia.instances.${fqdn}; @@ -35,6 +37,12 @@ in example = "mydomain.com"; }; + ssl = lib.mkOption { + description = "Path to SSL files"; + type = lib.types.nullOr contracts.ssl.certs; + default = null; + }; + ldapEndpoint = lib.mkOption { type = lib.types.str; description = "Endpoint for LDAP authentication backend."; @@ -293,9 +301,9 @@ in lib.mkBefore (lib.concatStringsSep "\n" (map mkCfg cfg.oidcClients)); services.nginx.virtualHosts.${fqdn} = { - forceSSL = lib.mkIf config.shb.ssl.enable true; - sslCertificate = lib.mkIf config.shb.ssl.enable "/var/lib/acme/${cfg.domain}/cert.pem"; - sslCertificateKey = lib.mkIf config.shb.ssl.enable "/var/lib/acme/${cfg.domain}/key.pem"; + forceSSL = !(isNull cfg.ssl); + sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert; + sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key; # Taken from https://github.com/authelia/authelia/issues/178 # TODO: merge with config from https://matwick.ca/authelia-nginx-sso/ locations."/".extraConfig = '' diff --git a/modules/blocks/ldap.nix b/modules/blocks/ldap.nix index 5d817f4..d9ee75b 100644 --- a/modules/blocks/ldap.nix +++ b/modules/blocks/ldap.nix @@ -3,6 +3,8 @@ let cfg = config.shb.ldap; + contracts = pkgs.callPackage ../contracts {}; + fqdn = "${cfg.subdomain}.${cfg.domain}"; in { @@ -33,6 +35,12 @@ in default = 3890; }; + ssl = lib.mkOption { + description = "Path to SSL files"; + type = lib.types.nullOr contracts.ssl.certs; + default = null; + }; + webUIListenPort = lib.mkOption { type = lib.types.port; description = "Port on which the web UI is exposed."; @@ -69,9 +77,9 @@ in enable = true; virtualHosts.${fqdn} = { - forceSSL = lib.mkIf config.shb.ssl.enable true; - sslCertificate = lib.mkIf config.shb.ssl.enable "/var/lib/acme/${cfg.domain}/cert.pem"; - sslCertificateKey = lib.mkIf config.shb.ssl.enable "/var/lib/acme/${cfg.domain}/key.pem"; + forceSSL = !(isNull cfg.ssl); + sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert; + sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key; locations."/" = { extraConfig = '' proxy_set_header Host $host; diff --git a/modules/blocks/monitoring.nix b/modules/blocks/monitoring.nix index 0959e23..cda1afc 100644 --- a/modules/blocks/monitoring.nix +++ b/modules/blocks/monitoring.nix @@ -3,6 +3,8 @@ let cfg = config.shb.monitoring; + contracts = pkgs.callPackage ../contracts {}; + fqdn = "${cfg.subdomain}.${cfg.domain}"; in { @@ -21,6 +23,12 @@ in example = "mydomain.com"; }; + ssl = lib.mkOption { + description = "Path to SSL files"; + type = lib.types.nullOr contracts.ssl.certs; + default = null; + }; + grafanaPort = lib.mkOption { type = lib.types.port; description = "Port where Grafana listens to HTTP requests."; @@ -362,9 +370,10 @@ in enable = true; virtualHosts.${fqdn} = { - forceSSL = lib.mkIf config.shb.ssl.enable true; - sslCertificate = lib.mkIf config.shb.ssl.enable "/var/lib/acme/${cfg.domain}/cert.pem"; - sslCertificateKey = lib.mkIf config.shb.ssl.enable "/var/lib/acme/${cfg.domain}/key.pem"; + forceSSL = !(isNull cfg.ssl); + sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert; + sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key; + locations."/" = { proxyPass = "http://${toString config.services.grafana.settings.server.http_addr}:${toString config.services.grafana.settings.server.http_port}"; proxyWebsockets = true; diff --git a/modules/blocks/nginx.nix b/modules/blocks/nginx.nix index 9052b14..fb8f076 100644 --- a/modules/blocks/nginx.nix +++ b/modules/blocks/nginx.nix @@ -3,6 +3,8 @@ let cfg = config.shb.nginx; + contracts = pkgs.callPackage ../contracts {}; + fqdn = c: "${c.subdomain}.${c.domain}"; autheliaConfig = lib.types.submodule { @@ -19,6 +21,12 @@ let example = "mydomain.com"; }; + ssl = lib.mkOption { + description = "Path to SSL files"; + type = lib.types.nullOr contracts.ssl.certs; + default = null; + }; + authEndpoint = lib.mkOption { type = lib.types.str; description = "Auth endpoint for SSO."; @@ -102,9 +110,9 @@ in let vhostCfg = c: { ${fqdn c} = { - forceSSL = lib.mkIf config.shb.ssl.enable true; - sslCertificate = lib.mkIf config.shb.ssl.enable "/var/lib/acme/${c.domain}/cert.pem"; - sslCertificateKey = lib.mkIf config.shb.ssl.enable "/var/lib/acme/${c.domain}/key.pem"; + forceSSL = !(isNull c.ssl); + sslCertificate = lib.mkIf (!(isNull c.ssl)) c.ssl.paths.cert; + sslCertificateKey = lib.mkIf (!(isNull c.ssl)) c.ssl.paths.key; # Taken from https://github.com/authelia/authelia/issues/178 locations."/".extraConfig = '' diff --git a/modules/blocks/ssl.nix b/modules/blocks/ssl.nix index d0ad4ef..5a27571 100644 --- a/modules/blocks/ssl.nix +++ b/modules/blocks/ssl.nix @@ -1,98 +1,338 @@ { config, pkgs, lib, ... }: let - cfg = config.shb.ssl; + cfg = config.shb.certs; + + contracts = pkgs.callPackage ../contracts {}; in { - options.shb.ssl = { - enable = lib.mkEnableOption "selfhostblocks.ssl"; - - domain = lib.mkOption { - description = "Domain to ask a wildcard certificate for."; - type = lib.types.str; - example = "domain.com"; - }; - - dnsProvider = lib.mkOption { - description = "DNS provider to use. See https://go-acme.github.io/lego/dns/ for the list of supported providers."; - type = lib.types.str; - example = "linode"; - }; - - credentialsFile = lib.mkOption { - type = lib.types.path; + options.shb.certs = { + systemdService = lib.mkOption { description = '' - Credentials file location for the chosen DNS provider. - - The content of this file must expose environment variables as written in the - [documentation](https://go-acme.github.io/lego/dns/) of each DNS provider. - - For example, if the documentation says the credential must be located in the environment - variable DNSPROVIDER_TOKEN, then the file content must be: - - DNSPROVIDER_TOKEN=xyz - - You can put non-secret environment variables here too or use shb.ssl.additionalcfg instead. + Systemd oneshot service used to generate the Certificate Authority bundle. ''; - example = "/run/secrets/ssl"; - }; - - additionalCfg = lib.mkOption { - type = lib.types.attrsOf lib.types.str; - description = ''Additional environment variables used to configure the DNS provider. - - For secrets, use shb.ssl.credentialsFile instead. - - See the chose provider's [documentation](https://go-acme.github.io/lego/dns/) for available - options. - ''; - example = lib.literalExpression '' - { - DNSPROVIDER_TIMEOUT = "10"; - DNSPROVIDER_PROPAGATION_TIMEOUT = "240"; - } - ''; - }; - - dnsResolver = lib.mkOption { - description = "IP of a DNS server used to resolve hostnames."; type = lib.types.str; - default = "8.8.8.8"; + default = "shb-ca-bundle.service"; + }; + cas.selfsigned = lib.mkOption { + description = "Generate a self-signed Certificate Authority."; + default = {}; + type = lib.types.attrsOf (lib.types.submodule ({ config, ...}: { + options = { + name = lib.mkOption { + type = lib.types.str; + description = '' + Certificate Authority Name. You can put what you want here, it will be displayed by the + browser. + ''; + default = "Self Host Blocks Certificate"; + }; + + paths = lib.mkOption { + description = '' + Paths where CA certs will be located. + + This option is the contract output of the `shb.certs.cas` SSL block. + ''; + type = contracts.ssl.certs-paths; + default = rec { + key = "/var/lib/certs/cas/${config._module.args.name}.key"; + cert = "/var/lib/certs/cas/${config._module.args.name}.cert"; + }; + }; + + systemdService = lib.mkOption { + description = "Systemd oneshot service used to generate the certs."; + type = lib.types.str; + default = "shb-certs-ca-${config._module.args.name}.service"; + }; + }; + })); + }; + certs.selfsigned = lib.mkOption { + description = "Generate self-signed certificates signed by a Certificate Authority."; + default = {}; + type = lib.types.attrsOf (lib.types.submodule ({ config, ... }: { + options = { + ca = lib.mkOption { + type = lib.types.nullOr contracts.ssl.cas; + description = '' + CA used to generate this certificate. Only used for self-signed. + + This contract input takes the contract output of the `shb.certs.cas` SSL block. + ''; + default = null; + }; + + domain = lib.mkOption { + type = lib.types.str; + description = '' + Domain to generate a certificate for. This can be a wildcard domain like + `*.example.com`. + ''; + example = "example.com"; + }; + + paths = lib.mkOption { + description = '' + Paths where certs will be located. + + This option is the contract output of the `shb.certs.certs` SSL block. + ''; + type = contracts.ssl.certs-paths; + default = rec { + key = "/var/lib/certs/selfsigned/${config._module.args.name}.key"; + cert = "/var/lib/certs/selfsigned/${config._module.args.name}.cert"; + }; + }; + + systemdService = lib.mkOption { + description = "Systemd oneshot service used to generate the certs."; + type = lib.types.str; + default = "shb-certs-cert-selfsigned-${config._module.args.name}.service"; + }; + }; + })); }; - adminEmail = lib.mkOption { - description = "Admin email in case certificate retrieval goes wrong."; - type = lib.types.str; - }; + certs.letsencrypt = lib.mkOption { + description = "Generate certificates signed by [Let's Encrypt](https://letsencrypt.org/)."; + default = {}; + type = lib.types.attrsOf (lib.types.submodule ({ config, ... }: { + options = { + domain = lib.mkOption { + type = lib.types.str; + description = '' + Domain to generate a certificate for. This can be a wildcard domain like + `*.example.com`. + ''; + example = "example.com"; + }; - debug = lib.mkOption { - description = "Enable debug logging"; - type = lib.types.bool; - default = false; + paths = lib.mkOption { + description = '' + Paths where certs will be located. + + This option is the contract output of the `shb.certs.certs` SSL block. + ''; + type = contracts.ssl.certs-paths; + default = { + key = "/var/lib/acme/${config._module.args.name}/key.pem"; + cert = "/var/lib/acme/${config._module.args.name}/cert.pem"; + }; + }; + + systemdService = lib.mkOption { + description = "Systemd oneshot service used to generate the certs."; + type = lib.types.str; + default = "shb-certs-cert-letsencrypt-${config._module.args.name}.service"; + }; + + dnsProvider = lib.mkOption { + description = "DNS provider to use. See https://go-acme.github.io/lego/dns/ for the list of supported providers."; + type = lib.types.nullOr lib.types.str; + default = null; + example = "linode"; + }; + + dnsResolver = lib.mkOption { + description = "IP of a DNS server used to resolve hostnames."; + type = lib.types.str; + default = "8.8.8.8"; + }; + + credentialsFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + description = '' + Credentials file location for the chosen DNS provider. + + The content of this file must expose environment variables as written in the + [documentation](https://go-acme.github.io/lego/dns/) of each DNS provider. + + For example, if the documentation says the credential must be located in the environment + variable DNSPROVIDER_TOKEN, then the file content must be: + + DNSPROVIDER_TOKEN=xyz + + You can put non-secret environment variables here too or use shb.ssl.additionalcfg instead. + ''; + example = "/run/secrets/ssl"; + default = null; + }; + + additionalEnvironment = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + description = '' + Additional environment variables used to configure the DNS provider. + + For secrets, use shb.ssl.credentialsFile instead. + + See the chosen provider's [documentation](https://go-acme.github.io/lego/dns/) for + available options. + ''; + example = lib.literalExpression '' + { + DNSPROVIDER_TIMEOUT = "10"; + DNSPROVIDER_PROPAGATION_TIMEOUT = "240"; + } + ''; + }; + + makeAvailableToUser = lib.mkOption { + type = lib.types.nullOr lib.types.str; + description = '' + Make all certificates available to given user. + ''; + default = null; + }; + + adminEmail = lib.mkOption { + description = "Admin email in case certificate retrieval goes wrong."; + type = lib.types.str; + }; + + debug = lib.mkOption { + description = "Enable debug logging"; + type = lib.types.bool; + default = false; + }; + }; + })); }; }; - config = lib.mkIf cfg.enable { - users.users.${config.services.nginx.user} = { - isSystemUser = true; - group = "nginx"; - extraGroups = [ config.security.acme.defaults.group ]; - }; - users.groups.nginx = {}; + config = + let + filterProvider = provider: lib.attrsets.filterAttrs (k: i: i.provider == provider); - security.acme = { - acceptTerms = true; - certs."${cfg.domain}" = { - extraDomainNames = ["*.${cfg.domain}"]; - }; - defaults = { - email = cfg.adminEmail; - inherit (cfg) dnsProvider dnsResolver; - credentialsFile = cfg.credentialsFile; - enableDebugLogs = cfg.debug; - }; - }; + serviceName = lib.strings.removeSuffix ".service"; + in + lib.mkMerge [ + # Config for self-signed CA. + { + systemd.services = lib.mapAttrs' (_name: caCfg: + lib.nameValuePair (serviceName caCfg.systemdService) { + wantedBy = [ "multi-user.target" ]; + wants = [ config.shb.certs.systemdService ]; + before = [ config.shb.certs.systemdService ]; + serviceConfig.Type = "oneshot"; + serviceConfig.RuntimeDirectory = serviceName caCfg.systemdService; + # serviceConfig.User = "nextcloud"; + # Taken from https://github.com/NixOS/nixpkgs/blob/7f311dd9226bbd568a43632c977f4992cfb2b5c8/nixos/tests/custom-ca.nix + script = '' + cd $RUNTIME_DIRECTORY - systemd.services."acme-${cfg.domain}".environment = cfg.additionalCfg; - }; + cat >ca.template <> /etc/ssl/certs/ca-certificates.crt + done + ''; + }); + } + # Config for self-signed cert. + { + systemd.services = lib.mapAttrs' (_name: certCfg: + lib.nameValuePair (serviceName certCfg.systemdService) { + after = [ certCfg.ca.systemdService ]; + requires = [ certCfg.ca.systemdService ]; + wantedBy = [ "multi-user.target" ]; + serviceConfig.RuntimeDirectory = serviceName certCfg.systemdService; + # Taken from https://github.com/NixOS/nixpkgs/blob/7f311dd9226bbd568a43632c977f4992cfb2b5c8/nixos/tests/custom-ca.nix + script = '' + cd $RUNTIME_DIRECTORY + + # server cert template + cat >server.template <..paths.cert +config.shb.certs...paths.key +``` + +For example: + +```nix +config.shb.certs.selfsigned."example.com".paths.cert +config.shb.certs.selfsigned."example.com".paths.key +``` +We can then configure Nginx to use those certificates: + +```nix +services.nginx.virtualHosts."example.com" = + let + cert = config.shb.certs.selfsigned."example.com"; + in + { + onlySSL = true; + sslCertificate = cert.paths.cert; + sslCertificateKey = cert.paths.key; + + locations."/".extraConfig = '' + add_header Content-Type text/plain; + return 200 'It works!'; + ''; + }; +``` + +To make sure the Nginx webserver can find the generated file, we will make it wait for the +certificate to the generated: + +```nix +systemd.services.nginx = { + after = [ config.shb.certs.selfsigned."example.com".systemdService ]; + requires = [ config.shb.certs.selfsigned."example.com".systemdService ]; +}; +``` + +If needed, we can also wait on the CA bundle to be generated by waiting for the Systemd service: + +```nix +config.shb.certs.systemdService +``` + +## Debug {#ssl-block-debug} + +Each CA and Cert is generated by a systemd service whose name can be seen in `systemdService` +options below. You can then see the latest errors messages using `journalctl`. + +## Tests {#ssl-block-tests} + +This block is tested in [`/tests/vm/ssl.nix`](@REPO@/tests/vm/ssl.nix). + +## Options Reference {#ssl-block-options} + +```{=include=} options +id-prefix: blocks-ssl-options- +list-id: selfhostblocks-options +source: @OPTIONS_JSON@ +``` diff --git a/modules/contracts/default.nix b/modules/contracts/default.nix new file mode 100644 index 0000000..cb95801 --- /dev/null +++ b/modules/contracts/default.nix @@ -0,0 +1,4 @@ +{ lib }: +{ + ssl = import ./ssl.nix { inherit lib; }; +} diff --git a/modules/contracts/ssl.nix b/modules/contracts/ssl.nix new file mode 100644 index 0000000..7bbdb4a --- /dev/null +++ b/modules/contracts/ssl.nix @@ -0,0 +1,58 @@ +{ lib }: +rec { + certs-paths = lib.types.submodule { + freeformType = lib.types.anything; + + options = { + cert = lib.mkOption { + type = lib.types.path; + description = "Path to the cert file."; + }; + key = lib.mkOption { + type = lib.types.path; + description = "Path to the key file."; + }; + }; + }; + cas = lib.types.submodule { + freeformType = lib.types.anything; + + options = { + paths = lib.mkOption { + description = '' + Paths where the files for the CA will be located. + + This option is the contract output of the `shb.certs.cas` SSL block. + ''; + type = certs-paths; + }; + + systemdService = lib.mkOption { + description = "Systemd oneshot service used to generate the CA."; + type = lib.types.str; + }; + }; + }; + certs = lib.types.submodule { + freeformType = lib.types.anything; + + options = { + paths = lib.mkOption { + description = '' + Paths where the files for the certificate will be located. + + This option is the contract output of the `shb.certs.certs` SSL block. + ''; + type = certs-paths; + }; + + systemdService = lib.mkOption { + description = '' + Systemd oneshot service used to generate the certificate. The name must include the + `.service` suffix. + ''; + type = lib.types.str; + }; + }; + }; +} diff --git a/modules/services/arr.nix b/modules/services/arr.nix index f995252..388cb26 100644 --- a/modules/services/arr.nix +++ b/modules/services/arr.nix @@ -3,6 +3,8 @@ let cfg = config.shb.arr; + contracts = pkgs.callPackage ../contracts {}; + apps = { radarr = { defaultPort = 7001; @@ -146,6 +148,12 @@ let example = "example.com"; }; + ssl = lib.mkOption { + description = "Path to SSL files"; + type = lib.types.nullOr contracts.ssl.certs; + default = null; + }; + port = lib.mkOption { type = lib.types.port; description = "Port on which ${name} listens to incoming requests."; @@ -304,7 +312,7 @@ config.xml" templatedSettings) "${config.services.radarr.dataDir}/config.xml" ( c = cfg.${name}; in lib.mkIf (c.authEndpoint != null) { - inherit (c) subdomain domain authEndpoint; + inherit (c) subdomain domain authEndpoint ssl; upstream = "http://127.0.0.1:${toString c.port}"; autheliaRules = [ { diff --git a/modules/services/deluge.nix b/modules/services/deluge.nix index 064e522..8dc314b 100644 --- a/modules/services/deluge.nix +++ b/modules/services/deluge.nix @@ -3,6 +3,8 @@ let cfg = config.shb.deluge; + contracts = pkgs.callPackage ../contracts {}; + fqdn = "${cfg.subdomain}.${cfg.domain}"; in { @@ -21,6 +23,12 @@ in example = "mydomain.com"; }; + ssl = lib.mkOption { + description = "Path to SSL files"; + type = lib.types.nullOr contracts.ssl.certs; + default = null; + }; + daemonPort = lib.mkOption { type = lib.types.int; description = "Deluge daemon port"; @@ -256,7 +264,7 @@ in shb.nginx.autheliaProtect = lib.mkIf config.shb.authelia.enable [ { - inherit (cfg) subdomain domain authEndpoint; + inherit (cfg) subdomain domain authEndpoint ssl; upstream = "http://127.0.0.1:${toString config.services.deluge.web.port}"; autheliaRules = [{ domain = fqdn; diff --git a/modules/services/hledger.nix b/modules/services/hledger.nix index ce43f96..038aba9 100644 --- a/modules/services/hledger.nix +++ b/modules/services/hledger.nix @@ -3,6 +3,8 @@ let cfg = config.shb.hledger; + contracts = pkgs.callPackage ../contracts {}; + fqdn = "${cfg.subdomain}.${cfg.domain}"; in { @@ -21,6 +23,12 @@ in example = "mydomain.com"; }; + ssl = lib.mkOption { + description = "Path to SSL files"; + type = lib.types.nullOr contracts.ssl.certs; + default = null; + }; + port = lib.mkOption { type = lib.types.int; description = "HLedger port"; @@ -74,7 +82,7 @@ in shb.nginx.autheliaProtect = [ { - inherit (cfg) subdomain domain authEndpoint; + inherit (cfg) subdomain domain authEndpoint ssl; upstream = "http://${toString config.services.hledger-web.host}:${toString config.services.hledger-web.port}"; autheliaRules = [{ domain = fqdn; diff --git a/modules/services/home-assistant.nix b/modules/services/home-assistant.nix index 9eb1541..eb5bb3b 100644 --- a/modules/services/home-assistant.nix +++ b/modules/services/home-assistant.nix @@ -3,6 +3,8 @@ let cfg = config.shb.home-assistant; + contracts = pkgs.callPackage ../contracts {}; + fqdn = "${cfg.subdomain}.${cfg.domain}"; ldap_auth_script_repo = pkgs.fetchFromGitHub { @@ -33,6 +35,12 @@ in example = "mydomain.com"; }; + ssl = lib.mkOption { + description = "Path to SSL files"; + type = lib.types.nullOr contracts.ssl.certs; + default = null; + }; + ldap = lib.mkOption { description = '' LDAP Integration App. [Manual](https://docs.nextcloud.com/server/latest/admin_manual/configuration_user/user_auth_ldap.html) @@ -193,10 +201,12 @@ in }; services.nginx.virtualHosts."${fqdn}" = { - forceSSL = lib.mkIf config.shb.ssl.enable true; http2 = true; - sslCertificate = lib.mkIf config.shb.ssl.enable "/var/lib/acme/${cfg.domain}/cert.pem"; - sslCertificateKey = lib.mkIf config.shb.ssl.enable "/var/lib/acme/${cfg.domain}/key.pem"; + + forceSSL = !(isNull cfg.ssl); + sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert; + sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key; + extraConfig = '' proxy_buffering off; ''; diff --git a/modules/services/jellyfin.nix b/modules/services/jellyfin.nix index 078a43b..78bde7d 100644 --- a/modules/services/jellyfin.nix +++ b/modules/services/jellyfin.nix @@ -3,6 +3,8 @@ let cfg = config.shb.jellyfin; + contracts = pkgs.callPackage ../contracts {}; + fqdn = "${cfg.subdomain}.${cfg.domain}"; template = file: newPath: replacements: @@ -33,6 +35,12 @@ in example = "domain.com"; }; + ssl = lib.mkOption { + description = "Path to SSL files"; + type = lib.types.nullOr contracts.ssl.certs; + default = null; + }; + ldapHost = lib.mkOption { type = lib.types.str; description = "host serving the LDAP server"; @@ -108,10 +116,12 @@ in # Take advice from https://jellyfin.org/docs/general/networking/nginx/ and https://nixos.wiki/wiki/Plex services.nginx.virtualHosts."${fqdn}" = { - forceSSL = true; + forceSSL = !(isNull cfg.ssl); + sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert; + sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key; + http2 = true; - sslCertificate = "/var/lib/acme/${cfg.domain}/cert.pem"; - sslCertificateKey = "/var/lib/acme/${cfg.domain}/key.pem"; + extraConfig = '' # The default `client_max_body_size` is 1M, this might not be enough for some posters, etc. client_max_body_size 20M; diff --git a/modules/services/nextcloud-server.nix b/modules/services/nextcloud-server.nix index be85b49..a8ce8fd 100644 --- a/modules/services/nextcloud-server.nix +++ b/modules/services/nextcloud-server.nix @@ -5,6 +5,8 @@ let fqdn = "${cfg.subdomain}.${cfg.domain}"; + contracts = pkgs.callPackage ../contracts {}; + # Make sure to bump both nextcloudPkg and nextcloudApps at the same time. nextcloudPkg = pkgs.nextcloud27; nextcloudApps = pkgs.nextcloud27Packages.apps; @@ -27,6 +29,12 @@ in example = "domain.com"; }; + ssl = lib.mkOption { + description = "Path to SSL files"; + type = lib.types.nullOr contracts.ssl.certs; + default = null; + }; + externalFqdn = lib.mkOption { description = "External fqdn used to access Nextcloud. Defaults to .. This should only be set if you include the port when accessing Nextcloud."; type = lib.types.nullOr lib.types.str; @@ -146,6 +154,12 @@ in default = "oo"; }; + ssl = lib.mkOption { + description = "Path to SSL files"; + type = lib.types.nullOr contracts.ssl.certs; + default = null; + }; + localNetworkIPRange = lib.mkOption { type = lib.types.str; description = "Local network range, to restrict access to Open Office to only those IPs."; @@ -363,14 +377,14 @@ in webfinger = true; # Very important for a bunch of scripts to load correctly. Otherwise you get Content-Security-Policy errors. See https://docs.nextcloud.com/server/13/admin_manual/configuration_server/harden_server.html#enable-http-strict-transport-security - https = config.shb.ssl.enable; + https = !(isNull cfg.ssl); extraApps = if isNull cfg.extraApps then {} else cfg.extraApps nextcloudApps; extraAppsEnable = true; appstoreEnable = true; extraOptions = let - protocol = if config.shb.ssl.enable then "https" else "http"; + protocol = if !(isNull cfg.ssl) then "https" else "http"; in { "overwrite.cli.url" = "${protocol}://${fqdn}"; "overwritehost" = if (isNull cfg.externalFqdn) then fqdn else cfg.externalFqdn; @@ -382,6 +396,9 @@ in "overwritecondaddr" = ""; # We need to set it to empty otherwise overwriteprotocol does not work. "debug" = cfg.debug; "filelocking.debug" = cfg.debug; + + # Use persistent SQL connections. + "dbpersistent" = "true"; }; phpOptions = { @@ -396,7 +413,7 @@ in "opcache.max_accelerated_files" = "10000"; "opcache.memory_consumption" = "128"; "opcache.revalidate_freq" = "1"; - "openssl.cafile" = "/etc/ssl/certs/ca-certificates.crt"; + "openssl.cafile" = "/etc/ssl/certs/ca-certificates.cert"; short_open_tag = "Off"; output_buffering = "Off"; @@ -424,9 +441,9 @@ in services.nginx.virtualHosts.${fqdn} = { # listen = [ { addr = "0.0.0.0"; port = 443; } ]; - sslCertificate = lib.mkIf config.shb.ssl.enable "/var/lib/acme/${cfg.domain}/cert.pem"; - sslCertificateKey = lib.mkIf config.shb.ssl.enable "/var/lib/acme/${cfg.domain}/key.pem"; - forceSSL = lib.mkIf config.shb.ssl.enable true; + forceSSL = !(isNull cfg.ssl); + sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert; + sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key; # From [1] this should fix downloading of big files. [2] seems to indicate that buffering # happens at multiple places anyway, so disabling one place should be okay. @@ -491,9 +508,10 @@ in }; services.nginx.virtualHosts."${cfg.apps.onlyoffice.subdomain}.${cfg.domain}" = { - sslCertificate = lib.mkIf config.shb.ssl.enable "/var/lib/acme/${cfg.domain}/cert.pem"; - sslCertificateKey = lib.mkIf config.shb.ssl.enable "/var/lib/acme/${cfg.domain}/key.pem"; - forceSSL = lib.mkIf config.shb.ssl.enable true; + forceSSL = !(isNull cfg.ssl); + sslCertificate = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.cert; + sslCertificateKey = lib.mkIf (!(isNull cfg.ssl)) cfg.ssl.paths.key; + locations."/" = { extraConfig = '' allow ${cfg.apps.onlyoffice.localNetworkIPRange}; diff --git a/modules/services/vaultwarden.nix b/modules/services/vaultwarden.nix index 2e57bff..b4abbbd 100644 --- a/modules/services/vaultwarden.nix +++ b/modules/services/vaultwarden.nix @@ -3,6 +3,8 @@ let cfg = config.shb.vaultwarden; + contracts = pkgs.callPackage ../contracts {}; + fqdn = "${cfg.subdomain}.${cfg.domain}"; template = file: newPath: replacements: @@ -33,6 +35,12 @@ in example = "mydomain.com"; }; + ssl = lib.mkOption { + description = "Path to SSL files"; + type = lib.types.nullOr contracts.ssl.certs; + default = null; + }; + port = lib.mkOption { type = lib.types.port; description = "Port on which vaultwarden service listens."; @@ -164,7 +172,7 @@ in shb.nginx.autheliaProtect = [ { - inherit (cfg) subdomain domain authEndpoint; + inherit (cfg) subdomain domain authEndpoint ssl; upstream = "http://127.0.0.1:${toString config.services.vaultwarden.config.ROCKET_PORT}"; autheliaRules = [ { diff --git a/test/modules/arr.nix b/test/modules/arr.nix index 60a3677..4095992 100644 --- a/test/modules/arr.nix +++ b/test/modules/arr.nix @@ -89,6 +89,7 @@ in authEndpoint = "https://oidc.example.com"; subdomain = "radarr"; upstream = "http://127.0.0.1:7001"; + ssl = null; } ]; users.users.radarr.extraGroups = [ "media" ]; @@ -162,6 +163,7 @@ in authEndpoint = "https://oidc.example.com"; subdomain = "radarr"; upstream = "http://127.0.0.1:7001"; + ssl = null; } ]; users.users.radarr.extraGroups = [ "media" ]; diff --git a/test/modules/nginx.nix b/test/modules/nginx.nix index b023217..242b13c 100644 --- a/test/modules/nginx.nix +++ b/test/modules/nginx.nix @@ -18,9 +18,11 @@ let services = anyOpt {}; shb.authelia = anyOpt {}; shb.backup = anyOpt {}; - shb.ssl = anyOpt {}; + systemd = anyOpt {}; + users = anyOpt {}; }; } + ../../modules/blocks/ssl.nix ../../modules/blocks/nginx.nix m ]; @@ -46,36 +48,32 @@ in testAuth = { expected = { - shb.backup = {}; - shb.nginx = { - accessLog = false; - autheliaProtect = [{ - authEndpoint = "hello"; - autheliaRules = [{}]; - subdomain = "my"; - domain = "example.com"; - upstream = "http://127.0.0.1:1234"; - }]; - debugLog = false; - }; - services.nginx.enable = true; - services.nginx.virtualHosts."my.example.com" = { + nginx.enable = true; + nginx.virtualHosts."my.example.com" = { forceSSL = true; locations."/" = {}; locations."/authelia" = {}; - sslCertificate = "/var/lib/acme/example.com/cert.pem"; - sslCertificateKey = "/var/lib/acme/example.com/key.pem"; + sslCertificate = "/var/lib/certs/selfsigned/example.com.cert"; + sslCertificateKey = "/var/lib/certs/selfsigned/example.com.key"; }; }; - expr = testConfig { - shb.ssl.enable = true; + expr = (testConfig ({ config, ... }: { + shb.certs.cas.selfsigned.myca = {}; + + shb.certs.certs.selfsigned."example.com" = { + ca = config.shb.certs.cas.selfsigned.myca; + + domain = "example.com"; + }; + shb.nginx.autheliaProtect = [{ subdomain = "my"; domain = "example.com"; + ssl = config.shb.certs.certs.selfsigned."example.com"; upstream = "http://127.0.0.1:1234"; authEndpoint = "hello"; autheliaRules = [{}]; }]; - }; + })).services; }; } diff --git a/test/vm/ssl.nix b/test/vm/ssl.nix new file mode 100644 index 0000000..553d454 --- /dev/null +++ b/test/vm/ssl.nix @@ -0,0 +1,76 @@ +{ pkgs, lib, ... }: +{ + test = pkgs.nixosTest { + name = "ssl-test"; + + nodes.server = { config, pkgs, ... }: { + imports = [ + ../../modules/blocks/ssl.nix + ]; + + shb.certs = { + cas.selfsigned = { + myca = { + name = "My CA"; + }; + myotherca = { + name = "My Other CA"; + }; + }; + certs.selfsigned = { + mycert = { + ca = config.shb.certs.cas.selfsigned.myca; + + domain = "example.com"; + }; + }; + }; + + # The configuration below is to create a webserver that uses the server certificate. + networking.hosts."127.0.0.1" = [ "example.com" ]; + + services.nginx.enable = true; + services.nginx.virtualHosts."example.com" = + { + onlySSL = true; + sslCertificate = config.shb.certs.certs.selfsigned.mycert.paths.cert; + sslCertificateKey = config.shb.certs.certs.selfsigned.mycert.paths.key; + locations."/".extraConfig = '' + add_header Content-Type text/plain; + return 200 'It works!'; + ''; + }; + systemd.services.nginx = { + after = [ config.shb.certs.certs.selfsigned.mycert.systemdService ]; + requires = [ config.shb.certs.certs.selfsigned.mycert.systemdService ]; + }; + }; + + # Taken from https://github.com/NixOS/nixpkgs/blob/7f311dd9226bbd568a43632c977f4992cfb2b5c8/nixos/tests/custom-ca.nix + testScript = { nodes, ... }: + let + myca = nodes.server.shb.certs.cas.selfsigned.myca; + myotherca = nodes.server.shb.certs.cas.selfsigned.myotherca; + mycert = nodes.server.shb.certs.certs.selfsigned.mycert; + in + '' + start_all() + + # Make sure certs are generated. + server.wait_for_file("${myca.paths.key}") + server.wait_for_file("${myca.paths.cert}") + server.wait_for_file("${myotherca.paths.key}") + server.wait_for_file("${myotherca.paths.cert}") + server.wait_for_file("${mycert.paths.key}") + server.wait_for_file("${mycert.paths.cert}") + + # Wait for jkkk + server.require_unit_state("${nodes.server.shb.certs.systemdService}", "inactive") + + with subtest("Certificate is trusted in curl"): + machine.wait_for_unit("nginx") + machine.wait_for_open_port(443) + machine.succeed("curl --fail-with-body -v https://example.com") + ''; + }; +}