summary refs log tree commit diff
path: root/nixos/modules/services/web-servers/caddy/default.nix
blob: e1456091717d471e3452ae0dbf66131e06653b1d (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
{ config, lib, pkgs, ... }:

with lib;

let
  cfg = config.services.caddy;

  virtualHosts = attrValues cfg.virtualHosts;
  acmeVHosts = filter (hostOpts: hostOpts.useACMEHost != null) virtualHosts;

  mkVHostConf = hostOpts:
    let
      sslCertDir = config.security.acme.certs.${hostOpts.useACMEHost}.directory;
    in
      ''
        ${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.writeTextDir "Caddyfile" ''
        {
          ${cfg.globalConfig}
        }
        ${cfg.extraConfig}
      '';

      Caddyfile-formatted = pkgs.runCommand "Caddyfile-formatted" { nativeBuildInputs = [ cfg.package ]; } ''
        mkdir -p $out
        ${cfg.package}/bin/caddy fmt ${Caddyfile}/Caddyfile > $out/Caddyfile
      '';
    in
      "${if pkgs.stdenv.buildPlatform == pkgs.stdenv.hostPlatform then Caddyfile-formatted else Caddyfile}/Caddyfile";

  acmeHosts = unique (catAttrs "useACMEHost" acmeVHosts);

  mkCertOwnershipAssertion = import ../../../security/acme/mk-cert-ownership-assertion.nix;
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 (lib.mdDoc "Caddy web server");

    user = mkOption {
      default = "caddy";
      type = types.str;
      description = lib.mdDoc ''
        User account under which caddy runs.

        ::: {.note}
        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 = lib.mdDoc ''
        Group account under which caddy runs.

        ::: {.note}
        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.
        :::
      '';
    };

    package = mkOption {
      default = pkgs.caddy;
      defaultText = literalExpression "pkgs.caddy";
      type = types.package;
      description = lib.mdDoc ''
        Caddy package to use.
      '';
    };

    dataDir = mkOption {
      type = types.path;
      default = "/var/lib/caddy";
      description = lib.mdDoc ''
        The data directory for caddy.

        ::: {.note}
        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 <https://caddyserver.com/docs/conventions#file-locations>.
        :::
      '';
    };

    logDir = mkOption {
      type = types.path;
      default = "/var/log/caddy";
      description = lib.mdDoc ''
        Directory for storing Caddy access logs.

        ::: {.note}
        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 = lib.mdDoc ''
        Configuration for the default logger. See
        <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.writeTextDir "Caddyfile" '''
          example.com

          root * /var/www/wordpress
          php_fastcgi unix//run/php/php-version-fpm.sock
          file_server
        ''';
      '';
      description = lib.mdDoc ''
        Override the configuration file used by Caddy. By default,
        NixOS generates one automatically.
      '';
    };

    adapter = mkOption {
      default = null;
      example = literalExpression "nginx";
      type = with types; nullOr str;
      description = lib.mdDoc ''
        Name of the config adapter to use.
        See <https://caddyserver.com/docs/config-adapters>
        for the full list.

        If `null` is specified, the `--adapter` argument is omitted when
        starting or restarting Caddy. Notably, this allows specification of a
        configuration file in Caddy's native JSON format, as long as the
        filename does not start with `Caddyfile` (in which case the `caddyfile`
        adapter is implicitly enabled). See
        <https://caddyserver.com/docs/command-line#caddy-run> for details.

        ::: {.note}
        Any value other than `null` or `caddyfile` is only valid when providing
        your own `configFile`.
        :::
      '';
    };

    resume = mkOption {
      default = false;
      type = types.bool;
      description = lib.mdDoc ''
        Use saved config, if any (and prefer over any specified configuration passed with `--config`).
      '';
    };

    globalConfig = mkOption {
      type = types.lines;
      default = "";
      example = ''
        debug
        servers {
          protocol {
            experimental_http3
          }
        }
      '';
      description = lib.mdDoc ''
        Additional lines of configuration appended to the global config section
        of the `Caddyfile`.

        Refer to <https://caddyserver.com/docs/caddyfile/options#global-options>
        for details on supported values.
      '';
    };

    extraConfig = mkOption {
      type = types.lines;
      default = "";
      example = ''
        example.com {
          encode gzip
          log
          root /srv/http
        }
      '';
      description = lib.mdDoc ''
        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 = lib.mdDoc ''
        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 = lib.mdDoc ''
        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 = lib.mdDoc ''
        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.configFile == configFile -> cfg.adapter == "caddyfile" || cfg.adapter == null;
        message = "To specify an adapter other than 'caddyfile' please provide your own configuration via `services.caddy.configFile`";
      }
    ] ++ map (name: mkCertOwnershipAssertion {
      inherit (cfg) group user;
      cert = config.security.acme.certs.${name};
      groups = config.users.groups;
    }) acmeHosts;

    services.caddy.extraConfig = concatMapStringsSep "\n" mkVHostConf virtualHosts;
    services.caddy.globalConfig = ''
      ${optionalString (cfg.email != null) "email ${cfg.email}"}
      ${optionalString (cfg.acmeCA != null) "acme_ca ${cfg.acmeCA}"}
      log {
        ${cfg.logFormat}
      }
    '';

    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;

      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 --config ${cfg.configFile} ${optionalString (cfg.adapter != null) "--adapter ${cfg.adapter}"} ${optionalString cfg.resume "--resume"}'' ];
        ExecReload = [ "" ''${cfg.package}/bin/caddy reload --config ${cfg.configFile} ${optionalString (cfg.adapter != null) "--adapter ${cfg.adapter}"} --force'' ];
        ExecStartPre = ''${cfg.package}/bin/caddy validate --config ${cfg.configFile} ${optionalString (cfg.adapter != null) "--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";

        # TODO: attempt to upstream these options
        NoNewPrivileges = true;
        PrivateDevices = true;
        ProtectHome = true;
      };
    };

    users.users = optionalAttrs (cfg.user == "caddy") {
      caddy = {
        group = cfg.group;
        uid = config.ids.uids.caddy;
        home = cfg.dataDir;
      };
    };

    users.groups = optionalAttrs (cfg.group == "caddy") {
      caddy.gid = config.ids.gids.caddy;
    };

    security.acme.certs =
      let
        certCfg = map (useACMEHost: nameValuePair useACMEHost {
          group = mkDefault cfg.group;
          reloadServices = [ "caddy.service" ];
        }) acmeHosts;
      in
        listToAttrs certCfg;

  };
}