nixos/caddy: introduce several new options

This commit is contained in:
Aaron Andersen 2021-11-29 22:39:27 -05:00
parent cf7889426a
commit 81a67a3353
2 changed files with 292 additions and 144 deletions

View File

@ -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 <option>services.caddy.adapter</option>).
'';
};
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.
<note><para>
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.
</para></note>
'';
};
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 <option>services.caddy.config</option>).
'';
};
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.
<note><para>
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.
</para></note>
'';
};
@ -168,11 +90,176 @@ in
Caddy package to use.
'';
};
dataDir = mkOption {
type = types.path;
default = "/var/lib/caddy";
description = ''
The data directory for caddy.
<note>
<para>
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.
</para>
<para>
Caddy v2 replaced <literal>CADDYPATH</literal> with XDG directories.
See <link xlink:href="https://caddyserver.com/docs/conventions#file-locations"/>.
</para>
</note>
'';
};
logDir = mkOption {
type = types.path;
default = "/var/log/caddy";
description = ''
Directory for storing Caddy access logs.
<note><para>
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.
</para></note>
'';
};
logFormat = mkOption {
type = types.lines;
default = ''
level ERROR
'';
example = literalExpression ''
mkForce "level INFO";
'';
description = ''
Configuration for the default logger. See
<link xlink:href="https://caddyserver.com/docs/caddyfile/options#log"/>
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 <link xlink:href="https://caddyserver.com/docs/config-adapters"/>
for the full list.
<note><para>
Any value other than <literal>caddyfile</literal> is only valid when
providing your own <option>configFile</option>.
</para></note>
'';
};
resume = mkOption {
default = false;
type = types.bool;
description = ''
Use saved config, if any (and prefer over any specified configuration passed with <literal>--config</literal>).
'';
};
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 <literal>Caddyfile</literal>.
'';
};
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 <literal>null</literal> 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;
};
}

View File

@ -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.
<emphasis>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 <xref linkend="opt-security.acme.certs"/>. Additionally,
you should probably add the <literal>caddy</literal> user to the
<literal>acme</literal> group to grant access to the certificates.</emphasis>
'';
};
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
<link xlink:href="https://caddyserver.com/docs/caddyfile/directives/log#log"/>
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 <literal>Caddyfile</literal>.
'';
};
};
}