From 81a67a3353b09c0abade5f2d17e91d23873fc7fb Mon Sep 17 00:00:00 2001 From: Aaron Andersen Date: Mon, 29 Nov 2021 22:39:27 -0500 Subject: [PATCH] nixos/caddy: introduce several new options --- .../services/web-servers/caddy/default.nix | 365 +++++++++++------- .../web-servers/caddy/vhost-options.nix | 71 +++- 2 files changed, 292 insertions(+), 144 deletions(-) diff --git a/nixos/modules/services/web-servers/caddy/default.nix b/nixos/modules/services/web-servers/caddy/default.nix index ed27dd375c86..d51effa31c97 100644 --- a/nixos/modules/services/web-servers/caddy/default.nix +++ b/nixos/modules/services/web-servers/caddy/default.nix @@ -4,159 +4,81 @@ with lib; let cfg = config.services.caddy; - vhostToConfig = vhostName: vhostAttrs: '' - ${vhostName} ${builtins.concatStringsSep " " vhostAttrs.serverAliases} { - ${vhostAttrs.extraConfig} - } - ''; - configFile = pkgs.writeText "Caddyfile" (builtins.concatStringsSep "\n" - ([ cfg.config ] ++ (mapAttrsToList vhostToConfig cfg.virtualHosts))); - formattedConfig = pkgs.runCommand "formattedCaddyFile" { } '' - ${cfg.package}/bin/caddy fmt ${configFile} > $out - ''; + virtualHosts = attrValues cfg.virtualHosts; + acmeVHosts = filter (hostOpts: hostOpts.useACMEHost != null) virtualHosts; - tlsConfig = { - apps.tls.automation.policies = [{ - issuers = [{ - inherit (cfg) ca email; - module = "acme"; - }]; - }]; - }; - - adaptedConfig = pkgs.runCommand "caddy-config-adapted.json" { } '' - ${cfg.package}/bin/caddy adapt \ - --config ${formattedConfig} --adapter ${cfg.adapter} > $out - ''; - tlsJSON = pkgs.writeText "tls.json" (builtins.toJSON tlsConfig); - - # merge the TLS config options we expose with the ones originating in the Caddyfile - configJSON = - if cfg.ca != null then - let tlsConfigMerge = '' - {"apps": - {"tls": - {"automation": - {"policies": - (if .[0].apps.tls.automation.policies == .[1]?.apps.tls.automation.policies - then .[0].apps.tls.automation.policies - else (.[0].apps.tls.automation.policies + .[1]?.apps.tls.automation.policies) - end) - } - } - } - }''; - in - pkgs.runCommand "caddy-config.json" { } '' - ${pkgs.jq}/bin/jq -s '.[0] * ${tlsConfigMerge}' ${adaptedConfig} ${tlsJSON} > $out + mkVHostConf = hostOpts: + let + sslCertDir = config.security.acme.certs.${hostOpts.useACMEHost}.directory; + in '' - else - adaptedConfig; + ${hostOpts.hostName} ${concatStringsSep " " hostOpts.serverAliases} { + bind ${concatStringsSep " " hostOpts.listenAddresses} + ${optionalString (hostOpts.useACMEHost != null) "tls ${sslCertDir}/cert.pem ${sslCertDir}/key.pem"} + log { + ${hostOpts.logFormat} + } + + ${hostOpts.extraConfig} + } + ''; + + configFile = + let + Caddyfile = pkgs.writeText "Caddyfile" '' + { + ${optionalString (cfg.email != null) "email ${cfg.email}"} + ${optionalString (cfg.acmeCA != null) "acme_ca ${cfg.acmeCA}"} + log { + ${cfg.logFormat} + } + } + ${cfg.extraConfig} + ''; + + Caddyfile-formatted = pkgs.runCommand "Caddyfile-formatted" { nativeBuildInputs = [ cfg.package ]; } '' + ${cfg.package}/bin/caddy fmt ${Caddyfile} > $out + ''; + in + if pkgs.stdenv.buildPlatform == pkgs.stdenv.hostPlatform then Caddyfile-formatted else Caddyfile; in { imports = [ (mkRemovedOptionModule [ "services" "caddy" "agree" ] "this option is no longer necessary for Caddy 2") + (mkRenamedOptionModule [ "services" "caddy" "ca" ] [ "services" "caddy" "acmeCA" ]) + (mkRenamedOptionModule [ "services" "caddy" "config" ] [ "services" "caddy" "extraConfig" ]) ]; + # interface options.services.caddy = { enable = mkEnableOption "Caddy web server"; - config = mkOption { - default = ""; - example = '' - example.com { - encode gzip - log - root /srv/http - } - ''; - type = types.lines; - description = '' - Verbatim Caddyfile to use. - Caddy v2 supports multiple config formats via adapters (see ). - ''; - }; - - virtualHosts = mkOption { - type = types.attrsOf (types.submodule (import ./vhost-options.nix { - inherit config lib; - })); - default = { }; - example = literalExpression '' - { - "hydra.example.com" = { - serverAliases = [ "www.hydra.example.com" ]; - extraConfig = '''''' - encode gzip - log - root /srv/http - ''''''; - }; - }; - ''; - description = "Declarative vhost config"; - }; - - user = mkOption { default = "caddy"; type = types.str; - description = "User account under which caddy runs."; + description = '' + User account under which caddy runs. + + + If left as the default value this user will automatically be created + on system activation, otherwise you are responsible for + ensuring the user exists before the Caddy service starts. + + ''; }; group = mkOption { default = "caddy"; type = types.str; - description = "Group account under which caddy runs."; - }; - - adapter = mkOption { - default = "caddyfile"; - example = "nginx"; - type = types.str; description = '' - Name of the config adapter to use. - See https://caddyserver.com/docs/config-adapters for the full list. - ''; - }; + Group account under which caddy runs. - resume = mkOption { - default = false; - type = types.bool; - description = '' - Use saved config, if any (and prefer over configuration passed with ). - ''; - }; - - ca = mkOption { - default = "https://acme-v02.api.letsencrypt.org/directory"; - example = "https://acme-staging-v02.api.letsencrypt.org/directory"; - type = types.nullOr types.str; - description = '' - Certificate authority ACME server. The default (Let's Encrypt - production server) should be fine for most people. Set it to null if - you don't want to include any authority (or if you want to write a more - fine-graned configuration manually) - ''; - }; - - email = mkOption { - default = ""; - type = types.str; - description = "Email address (for Let's Encrypt certificate)"; - }; - - dataDir = mkOption { - default = "/var/lib/caddy"; - type = types.path; - description = '' - The data directory, for storing certificates. Before 17.09, this - would create a .caddy directory. With 17.09 the contents of the - .caddy directory are in the specified data directory instead. - - Caddy v2 replaced CADDYPATH with XDG directories. - See https://caddyserver.com/docs/conventions#file-locations. + + If left as the default value this user will automatically be created + on system activation, otherwise you are responsible for + ensuring the user exists before the Caddy service starts. + ''; }; @@ -168,11 +90,176 @@ in Caddy package to use. ''; }; + + dataDir = mkOption { + type = types.path; + default = "/var/lib/caddy"; + description = '' + The data directory for caddy. + + + + If left as the default value this directory will automatically be created + before the Caddy server starts, otherwise you are responsible for ensuring + the directory exists with appropriate ownership and permissions. + + + Caddy v2 replaced CADDYPATH with XDG directories. + See . + + + ''; + }; + + logDir = mkOption { + type = types.path; + default = "/var/log/caddy"; + description = '' + Directory for storing Caddy access logs. + + + If left as the default value this directory will automatically be created + before the Caddy server starts, otherwise the sysadmin is responsible for + ensuring the directory exists with appropriate ownership and permissions. + + ''; + }; + + logFormat = mkOption { + type = types.lines; + default = '' + level ERROR + ''; + example = literalExpression '' + mkForce "level INFO"; + ''; + description = '' + Configuration for the default logger. See + + for details. + ''; + }; + + configFile = mkOption { + type = types.path; + default = configFile; + defaultText = "A Caddyfile automatically generated by values from services.caddy.*"; + example = literalExpression '' + pkgs.writeText "Caddyfile" ''' + example.com + + root * /var/www/wordpress + php_fastcgi unix//run/php/php-version-fpm.sock + file_server + '''; + ''; + description = '' + Override the configuration file used by Caddy. By default, + NixOS generates one automatically. + ''; + }; + + adapter = mkOption { + default = "caddyfile"; + example = "nginx"; + type = types.str; + description = '' + Name of the config adapter to use. + See + for the full list. + + + Any value other than caddyfile is only valid when + providing your own . + + ''; + }; + + resume = mkOption { + default = false; + type = types.bool; + description = '' + Use saved config, if any (and prefer over any specified configuration passed with --config). + ''; + }; + + extraConfig = mkOption { + type = types.lines; + default = ""; + example = '' + example.com { + encode gzip + log + root /srv/http + } + ''; + description = '' + Additional lines of configuration appended to the automatically + generated Caddyfile. + ''; + }; + + virtualHosts = mkOption { + type = with types; attrsOf (submodule (import ./vhost-options.nix { inherit cfg; })); + default = {}; + example = literalExpression '' + { + "hydra.example.com" = { + serverAliases = [ "www.hydra.example.com" ]; + extraConfig = ''' + encode gzip + root /srv/http + '''; + }; + }; + ''; + description = '' + Declarative specification of virtual hosts served by Caddy. + ''; + }; + + acmeCA = mkOption { + default = "https://acme-v02.api.letsencrypt.org/directory"; + example = "https://acme-staging-v02.api.letsencrypt.org/directory"; + type = with types; nullOr str; + description = '' + The URL to the ACME CA's directory. It is strongly recommended to set + this to Let's Encrypt's staging endpoint for testing or development. + + Set it to null if you want to write a more + fine-grained configuration manually. + ''; + }; + + email = mkOption { + default = null; + type = with types; nullOr str; + description = '' + Your email address. Mainly used when creating an ACME account with your + CA, and is highly recommended in case there are problems with your + certificates. + ''; + }; + }; + # implementation config = mkIf cfg.enable { + + assertions = [ + { assertion = cfg.adapter != "caddyfile" -> cfg.configFile != configFile; + message = "Any value other than 'caddyfile' is only valid when providing your own `services.caddy.configFile`"; + } + ]; + + services.caddy.extraConfig = concatMapStringsSep "\n" mkVHostConf virtualHosts; + systemd.packages = [ cfg.package ]; systemd.services.caddy = { + wants = map (hostOpts: "acme-finished-${hostOpts.useACMEHost}.target") acmeVHosts; + after = map (hostOpts: "acme-selfsigned-${hostOpts.useACMEHost}.service") acmeVHosts; + before = map (hostOpts: "acme-${hostOpts.useACMEHost}.service") acmeVHosts; + wantedBy = [ "multi-user.target" ]; startLimitIntervalSec = 14400; startLimitBurst = 10; @@ -180,13 +267,17 @@ in serviceConfig = { # https://www.freedesktop.org/software/systemd/man/systemd.service.html#ExecStart= # If the empty string is assigned to this option, the list of commands to start is reset, prior assignments of this option will have no effect. - ExecStart = [ "" "${cfg.package}/bin/caddy run ${optionalString cfg.resume "--resume"} --config ${configJSON}" ]; - ExecReload = [ "" "${cfg.package}/bin/caddy reload --config ${configJSON}" ]; + ExecStart = [ "" "${cfg.package}/bin/caddy run --config ${cfg.configFile} --adapter ${cfg.adapter} ${optionalString cfg.resume "--resume"}" ]; + ExecReload = [ "" "${cfg.package}/bin/caddy reload --config ${cfg.configFile} --adapter ${cfg.adapter}" ]; + ExecStartPre = "${cfg.package}/bin/caddy validate --config ${cfg.configFile} --adapter ${cfg.adapter}"; User = cfg.user; Group = cfg.group; ReadWriteDirectories = cfg.dataDir; + StateDirectory = mkIf (cfg.dataDir == "/var/lib/caddy") [ "caddy" ]; + LogsDirectory = mkIf (cfg.logDir == "/var/log/caddy") [ "caddy" ]; Restart = "on-abnormal"; + SupplementaryGroups = mkIf (length acmeVHosts != 0) [ "acme" ]; # TODO: attempt to upstream these options NoNewPrivileges = true; @@ -200,7 +291,6 @@ in group = cfg.group; uid = config.ids.uids.caddy; home = cfg.dataDir; - createHome = true; }; }; @@ -208,5 +298,12 @@ in caddy.gid = config.ids.gids.caddy; }; + security.acme.certs = + let + eachACMEHost = unique (catAttrs "useACMEHost" acmeVHosts); + reloads = map (useACMEHost: nameValuePair useACMEHost { reloadServices = [ "caddy.service" ]; }) eachACMEHost; + in + listToAttrs reloads; + }; } diff --git a/nixos/modules/services/web-servers/caddy/vhost-options.nix b/nixos/modules/services/web-servers/caddy/vhost-options.nix index 1f74295fc9a2..f240ec605c29 100644 --- a/nixos/modules/services/web-servers/caddy/vhost-options.nix +++ b/nixos/modules/services/web-servers/caddy/vhost-options.nix @@ -1,15 +1,19 @@ -# This file defines the options that can be used both for the Nginx -# main server configuration, and for the virtual hosts. (The latter -# has additional options that affect the web server as a whole, like -# the user/group to run under.) - -{ lib, ... }: - -with lib; +{ cfg }: +{ config, lib, name, ... }: +let + inherit (lib) literalExpression mkOption types; +in { options = { + + hostName = mkOption { + type = types.str; + default = name; + description = "Canonical hostname for the server."; + }; + serverAliases = mkOption { - type = types.listOf types.str; + type = with types; listOf str; default = [ ]; example = [ "www.example.org" "example.org" ]; description = '' @@ -17,12 +21,59 @@ with lib; ''; }; + listenAddresses = mkOption { + type = with types; listOf str; + description = '' + A list of host interfaces to bind to for this virtual host. + ''; + default = [ ]; + example = [ "127.0.0.1" "::1" ]; + }; + + useACMEHost = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + A host of an existing Let's Encrypt certificate to use. + This is mostly useful if you use DNS challenges but Caddy does not + currently support your provider. + + Note that this option does not create any certificates, nor + does it add subdomains to existing ones – you will need to create them + manually using . Additionally, + you should probably add the caddy user to the + acme group to grant access to the certificates. + ''; + }; + + logFormat = mkOption { + type = types.lines; + default = '' + output file ${cfg.logDir}/access-${config.hostName}.log + ''; + defaultText = '' + output file ''${config.services.caddy.logDir}/access-''${hostName}.log + ''; + example = literalExpression '' + mkForce ''' + output discard + '''; + ''; + description = '' + Configuration for HTTP request logging (also known as access logs). See + + for details. + ''; + }; + extraConfig = mkOption { type = types.lines; default = ""; description = '' - These lines go into the vhost verbatim + Additional lines of configuration appended to this virtual host in the + automatically generated Caddyfile. ''; }; + }; }