1{ config, lib, pkgs, ... }:
2
3with lib;
4
5let
6
7 cfg = config.security.acme;
8
9 certOpts = { ... }: {
10 options = {
11 webroot = mkOption {
12 type = types.str;
13 description = ''
14 Where the webroot of the HTTP vhost is located.
15 <filename>.well-known/acme-challenge/</filename> directory
16 will be created automatically if it doesn't exist.
17 <literal>http://example.org/.well-known/acme-challenge/</literal> must also
18 be available (notice unencrypted HTTP).
19 '';
20 };
21
22 email = mkOption {
23 type = types.nullOr types.str;
24 default = null;
25 description = "Contact email address for the CA to be able to reach you.";
26 };
27
28 user = mkOption {
29 type = types.str;
30 default = "root";
31 description = "User running the ACME client.";
32 };
33
34 group = mkOption {
35 type = types.str;
36 default = "root";
37 description = "Group running the ACME client.";
38 };
39
40 allowKeysForGroup = mkOption {
41 type = types.bool;
42 default = false;
43 description = "Give read permissions to the specified group to read SSL private certificates.";
44 };
45
46 postRun = mkOption {
47 type = types.lines;
48 default = "";
49 example = "systemctl reload nginx.service";
50 description = ''
51 Commands to run after certificates are re-issued. Typically
52 the web server and other servers using certificates need to
53 be reloaded.
54 '';
55 };
56
57 plugins = mkOption {
58 type = types.listOf (types.enum [
59 "cert.der" "cert.pem" "chain.pem" "external.sh"
60 "fullchain.pem" "full.pem" "key.der" "key.pem" "account_key.json"
61 ]);
62 default = [ "fullchain.pem" "key.pem" "account_key.json" ];
63 description = ''
64 Plugins to enable. With default settings simp_le will
65 store public certificate bundle in <filename>fullchain.pem</filename>
66 and private key in <filename>key.pem</filename> in its state directory.
67 '';
68 };
69
70 extraDomains = mkOption {
71 type = types.attrsOf (types.nullOr types.str);
72 default = {};
73 example = {
74 "example.org" = "/srv/http/nginx";
75 "mydomain.org" = null;
76 };
77 description = ''
78 Extra domain names for which certificates are to be issued, with their
79 own server roots if needed.
80 '';
81 };
82 };
83 };
84
85in
86
87{
88
89 ###### interface
90
91 options = {
92 security.acme = {
93 directory = mkOption {
94 default = "/var/lib/acme";
95 type = types.str;
96 description = ''
97 Directory where certs and other state will be stored by default.
98 '';
99 };
100
101 validMin = mkOption {
102 type = types.int;
103 default = 30 * 24 * 3600;
104 description = "Minimum remaining validity before renewal in seconds.";
105 };
106
107 renewInterval = mkOption {
108 type = types.str;
109 default = "weekly";
110 description = ''
111 Systemd calendar expression when to check for renewal. See
112 <citerefentry><refentrytitle>systemd.time</refentrytitle>
113 <manvolnum>5</manvolnum></citerefentry>.
114 '';
115 };
116
117 certs = mkOption {
118 default = { };
119 type = types.loaOf types.optionSet;
120 description = ''
121 Attribute set of certificates to get signed and renewed.
122 '';
123 options = [ certOpts ];
124 example = {
125 "example.com" = {
126 webroot = "/var/www/challenges/";
127 email = "foo@example.com";
128 extraDomains = { "www.example.com" = null; "foo.example.com" = "/var/www/foo/"; };
129 };
130 "bar.example.com" = {
131 webroot = "/var/www/challenges/";
132 email = "bar@example.com";
133 };
134 };
135 };
136 };
137 };
138
139 ###### implementation
140 config = mkMerge [
141 (mkIf (cfg.certs != { }) {
142
143 systemd.services = flip mapAttrs' cfg.certs (cert: data:
144 let
145 cpath = "${cfg.directory}/${cert}";
146 rights = if data.allowKeysForGroup then "750" else "700";
147 cmdline = [ "-v" "-d" cert "--default_root" data.webroot "--valid_min" cfg.validMin ]
148 ++ optionals (data.email != null) [ "--email" data.email ]
149 ++ concatMap (p: [ "-f" p ]) data.plugins
150 ++ concatLists (mapAttrsToList (name: root: [ "-d" (if root == null then name else "${name}:${root}")]) data.extraDomains);
151
152 in nameValuePair
153 ("acme-${cert}")
154 ({
155 description = "ACME cert renewal for ${cert} using simp_le";
156 after = [ "network.target" ];
157 serviceConfig = {
158 Type = "oneshot";
159 SuccessExitStatus = [ "0" "1" ];
160 PermissionsStartOnly = true;
161 User = data.user;
162 Group = data.group;
163 PrivateTmp = true;
164 };
165 path = [ pkgs.simp_le ];
166 preStart = ''
167 mkdir -p '${cfg.directory}'
168 if [ ! -d '${cpath}' ]; then
169 mkdir '${cpath}'
170 fi
171 chmod ${rights} '${cpath}'
172 chown -R '${data.user}:${data.group}' '${cpath}'
173 '';
174 script = ''
175 cd '${cpath}'
176 set +e
177 simp_le ${concatMapStringsSep " " (arg: escapeShellArg (toString arg)) cmdline}
178 EXITCODE=$?
179 set -e
180 echo "$EXITCODE" > /tmp/lastExitCode
181 exit "$EXITCODE"
182 '';
183 postStop = ''
184 if [ -e /tmp/lastExitCode ] && [ "$(cat /tmp/lastExitCode)" = "0" ]; then
185 echo "Executing postRun hook..."
186 ${data.postRun}
187 fi
188 '';
189 })
190 );
191
192 systemd.timers = flip mapAttrs' cfg.certs (cert: data: nameValuePair
193 ("acme-${cert}")
194 ({
195 description = "timer for ACME cert renewal of ${cert}";
196 wantedBy = [ "timers.target" ];
197 timerConfig = {
198 OnCalendar = cfg.renewInterval;
199 Unit = "acme-${cert}.service";
200 };
201 })
202 );
203 })
204
205 { meta.maintainers = with lib.maintainers; [ abbradar fpletz globin ];
206 meta.doc = ./acme.xml;
207 }
208 ];
209
210}