1{
2 serverName,
3 group,
4 baseModule,
5 domain,
6}:
7{
8 config,
9 lib,
10 pkgs,
11 ...
12}:
13{
14 name = serverName;
15 meta = {
16 maintainers = lib.teams.acme.members;
17 # Hard timeout in seconds. Average run time is about 100 seconds.
18 timeout = 300;
19 };
20
21 interactive.sshBackdoor.enable = true;
22
23 nodes = {
24 # The fake ACME server which will respond to client requests
25 acme =
26 { nodes, ... }:
27 {
28 imports = [ ../common/acme/server ];
29 };
30
31 webserver =
32 { nodes, ... }:
33 {
34 imports = [
35 ../common/acme/client
36 baseModule
37 ];
38 networking.domain = domain;
39 networking.firewall.allowedTCPPorts = [
40 80
41 443
42 ];
43
44 # Resolve the vhosts the easy way
45 networking.hosts."127.0.0.1" = [
46 "proxied.${domain}"
47 "certchange.${domain}"
48 "zeroconf.${domain}"
49 "zeroconf2.${domain}"
50 "zeroconf3.${domain}"
51 "nullroot.${domain}"
52 ];
53
54 # OpenSSL will be used for more thorough certificate validation
55 environment.systemPackages = [ pkgs.openssl ];
56
57 # Used to determine if service reload was triggered.
58 # This does not provide a guarantee that the webserver is finished reloading,
59 # to handle that there is retry logic wrapping any connectivity checks.
60 systemd.targets."renew-triggered" = {
61 wantedBy = [ "${serverName}-config-reload.service" ];
62 after = [ "${serverName}-config-reload.service" ];
63 unitConfig.RefuseManualStart = true;
64 };
65
66 security.acme.certs."proxied.${domain}" = {
67 listenHTTP = ":8080";
68 group = group;
69 };
70
71 specialisation = {
72 # Test that the web server is correctly reloaded when the cert changes
73 certchange.configuration = {
74 security.acme.certs."proxied.${domain}".extraDomainNames = [
75 "certchange.${domain}"
76 ];
77 };
78
79 # A useful transitional step before other tests, and tests behaviour
80 # of removing an extra domain from a cert.
81 certundo.configuration = { };
82
83 # Tests these features:
84 # - enableACME behaves as expected
85 # - serverAliases are appended to extraDomainNames
86 # - Correct routing to the specific virtualHost for a cert
87 # Inherits previous test config
88 zeroconf.configuration = {
89 services.${serverName}.virtualHosts."zeroconf.${domain}" = {
90 addSSL = true;
91 enableACME = true;
92 serverAliases = [ "zeroconf2.${domain}" ];
93 };
94 };
95
96 # Test that serverAliases are correctly removed which triggers
97 # cert regeneration and service reload.
98 rmalias.configuration = {
99 services.${serverName}.virtualHosts."zeroconf.${domain}" = {
100 addSSL = true;
101 enableACME = true;
102 };
103 };
104
105 # Test that "acmeRoot = null" still results in
106 # valid cert generation by inheriting defaults.
107 nullroot.configuration = {
108 # The default.nix has the server-type dependent config statements
109 # to properly set up the proxying. We need a separate port here to
110 # avoid hostname issues with the proxy already running on :8080
111 security.acme.defaults.listenHTTP = ":8081";
112 services.${serverName}.virtualHosts."nullroot.${domain}" = {
113 addSSL = true;
114 enableACME = true;
115 acmeRoot = null;
116 };
117 };
118
119 # Test that a adding a second virtual host will not trigger
120 # other units (account and renewal service for first)
121 zeroconf3.configuration = {
122 services.${serverName}.virtualHosts = {
123 "zeroconf.${domain}" = {
124 addSSL = true;
125 enableACME = true;
126 serverAliases = [ "zeroconf2.${domain}" ];
127 };
128 "zeroconf3.${domain}" = {
129 addSSL = true;
130 enableACME = true;
131 };
132 };
133 # We're doing something risky with the combination of the service unit being persistent
134 # that could end up that the timers do not trigger properly. Show that timers have the
135 # desired effect.
136 systemd.timers."acme-renew-zeroconf3.${domain}".timerConfig = {
137 OnCalendar = lib.mkForce "*-*-* *:*:0/5";
138 AccuracySec = lib.mkForce 0;
139 # Skew randomly within the day, per https://letsencrypt.org/docs/integration-guide/.
140 RandomizedDelaySec = lib.mkForce 0;
141 FixedRandomDelay = lib.mkForce 0;
142 };
143 };
144 };
145 };
146 };
147
148 testScript =
149 { nodes, ... }:
150 ''
151 ${(import ./utils.nix).pythonUtils}
152
153 domain = "${domain}"
154 ca_domain = "${nodes.acme.test-support.acme.caDomain}"
155 fqdn = f"proxied.{domain}"
156
157 webserver.start()
158 webserver.wait_for_unit("${serverName}.service")
159
160 with subtest("Can run on self-signed certificates"):
161 check_issuer(webserver, fqdn, "minica")
162 # Check that the web server has picked up the selfsigned cert
163 check_connection(webserver, fqdn, minica=True)
164
165 acme.start()
166 wait_for_running(acme)
167 acme.wait_for_open_port(443)
168
169 with subtest("Acquire a cert through a proxied lego"):
170 webserver.succeed(f"systemctl start acme-order-renew-{fqdn}.service")
171 webserver.wait_for_unit("renew-triggered.target")
172 download_ca_certs(webserver, ca_domain)
173 check_issuer(webserver, fqdn, "pebble")
174 check_connection(webserver, fqdn)
175
176 with subtest("security.acme changes reflect on web server part 1"):
177 check_connection(webserver, f"certchange.{domain}", fail=True)
178 switch_to(webserver, "certchange")
179 webserver.wait_for_unit("renew-triggered.target")
180 check_connection(webserver, f"certchange.{domain}")
181 check_connection(webserver, fqdn)
182
183 with subtest("security.acme changes reflect on web server part 2"):
184 check_connection(webserver, f"certchange.{domain}")
185 switch_to(webserver, "certundo")
186 webserver.wait_for_unit("renew-triggered.target")
187 check_connection(webserver, f"certchange.{domain}", fail=True)
188 check_connection(webserver, fqdn)
189
190 with subtest("Zero configuration SSL certificates for a vhost"):
191 check_connection(webserver, f"zeroconf.{domain}", fail=True)
192 switch_to(webserver, "zeroconf")
193 webserver.wait_for_unit("renew-triggered.target")
194 check_connection(webserver, f"zeroconf.{domain}")
195 check_connection(webserver, f"zeroconf2.{domain}")
196 check_connection(webserver, fqdn)
197
198 with subtest("Removing an alias from a vhost"):
199 check_connection(webserver, f"zeroconf2.{domain}")
200 switch_to(webserver, "rmalias")
201 webserver.wait_for_unit("renew-triggered.target")
202 check_connection(webserver, f"zeroconf2.{domain}", fail=True)
203 check_connection(webserver, f"zeroconf.{domain}")
204 check_connection(webserver, fqdn)
205
206 with subtest("Create cert using inherited default validation mechanism"):
207 check_connection(webserver, f"nullroot.{domain}", fail=True)
208 switch_to(webserver, "nullroot")
209 webserver.wait_for_unit("renew-triggered.target")
210 check_connection(webserver, f"nullroot.{domain}")
211
212 with subtest("Ensure that adding a second vhost does not trigger first vhost acme units"):
213 switch_to(webserver, "zeroconf")
214 webserver.wait_for_unit("renew-triggered.target")
215 webserver.succeed("journalctl --cursor-file=/tmp/cursor | grep acme")
216 switch_to(webserver, "zeroconf3")
217 webserver.wait_for_unit("renew-triggered.target")
218 output = webserver.succeed("journalctl --cursor-file=/tmp/cursor | grep acme")
219 # The new certificate unit gets triggered:
220 t.assertIn(f"acme-zeroconf3.{domain}-start", output)
221 # The account generation should not be triggered again:
222 t.assertNotIn("acme-account-d590213ed52603e9128d.target", output)
223 # The other certificates should also not be triggered:
224 t.assertNotIn(f"acme-zeroconf.{domain}-start", output)
225 t.assertNotIn(f"acme-proxied.{domain}-start", output)
226 # Ensure the timer works, due to our shenanigans with
227 # RemainAfterExit=true
228 webserver.wait_until_succeeds(f"journalctl --cursor-file=/tmp/cursor | grep 'Starting Order (and renew) ACME certificate for zeroconf3.{domain}...'")
229 '';
230}