1# Functions for cert creation with Let's Encrypt
2
3if ($config{'letsencrypt_cmd'}) {
4	$letsencrypt_cmd = &has_command($config{'letsencrypt_cmd'});
5	}
6else {
7	$letsencrypt_cmd = &has_command("letsencrypt-auto") ||
8			   &has_command("letsencrypt") ||
9			   &has_command("certbot-auto") ||
10			   &has_command("certbot");
11	}
12
13$account_key = "$module_config_directory/letsencrypt.pem";
14
15$letsencrypt_chain_urls = [
16	"https://letsencrypt.org/certs/lets-encrypt-r3-cross-signed.pem",
17	];
18
19# check_letsencrypt()
20# Returns undef if all dependencies are installed, or an error message
21sub check_letsencrypt
22{
23if (&has_command($letsencrypt_cmd)) {
24	# Use official client
25	return undef;
26	}
27my $python = &get_python_cmd();
28if (!$python || !&has_command("openssl")) {
29        return $text{'letsencrypt_ecmds'};
30        }
31my $out = &backquote_command("$python -c 'import argparse' 2>&1");
32if ($?) {
33        return &text('letsencrypt_epythonmod', '<tt>argparse</tt>');
34        }
35my $ver = &backquote_command("$python --version 2>&1");
36if ($ver !~ /Python\s+([0-9\.]+)/) {
37        return &text('letsencrypt_epythonver',
38                     "<tt>".&html_escape($out)."</tt>");
39        }
40$ver = $1;
41if ($ver < 2.5) {
42        return &text('letsencrypt_epythonver2', '2.5', $ver);
43        }
44return undef;
45}
46
47# get_letsencrypt_install_message(return-link, return-title)
48# Returns a link or form to install Let's Encrypt
49sub get_letsencrypt_install_message
50{
51my ($rlink, $rmsg) = @_;
52&foreign_require("software");
53return &software::missing_install_link(
54	"certbot", $text{'letsencrypt_certbot'}, $rlink, $rmsg);
55}
56
57# request_letsencrypt_cert(domain|&domains, webroot, [email], [keysize],
58# 			   [request-mode], [use-staging], [account-email])
59# Attempt to request a cert using a generated key with the Let's Encrypt client
60# command, and write it to the given path. Returns a status flag, and either
61# an error message or the paths to cert, key and chain files.
62sub request_letsencrypt_cert
63{
64my ($dom, $webroot, $email, $size, $mode, $staging, $account_email) = @_;
65my @doms = ref($dom) ? @$dom : ($dom);
66$email ||= "root\@$doms[0]";
67$mode ||= "web";
68my ($challenge, $wellknown, $challenge_new, $wellknown_new, $wildcard);
69
70# Wildcard mode?
71foreach my $d (@doms) {
72	if ($d =~ /^\*/) {
73		$wildcard = $d;
74		}
75	}
76
77if ($mode eq "web") {
78	# Create a challenges directory under the web root
79	if ($wildcard) {
80		return (0, "Wildcard hostname $wildcard can only be ".
81			   "validated in DNS mode");
82		}
83	$wellknown = "$webroot/.well-known";
84	$challenge = "$wellknown/acme-challenge";
85	$wellknown_new = !-d $wellknown ? $wellknown : undef;
86	$challenge_new = !-d $challenge ? $challenge : undef;
87	my @st = stat($webroot);
88	my $user = getpwuid($st[4]);
89	if (!-d $challenge) {
90		my $cmd = "mkdir -p -m 755 ".quotemeta($challenge).
91			  " && chmod 755 ".quotemeta($wellknown);
92		if ($user && $user ne "root") {
93			$cmd = &command_as_user($user, 0, $cmd);
94			}
95		my $out = &backquote_logged("$cmd 2>&1");
96		if ($?) {
97			return (0, "mkdir failed : $out");
98			}
99		}
100
101	# Create a .htaccess file to ensure the directory is accessible
102	if (&foreign_installed("apache")) {
103		&foreign_require("apache");
104		my $htaccess = "$challenge/.htaccess";
105		if (!-r $htaccess && $apache::httpd_modules{'core'} >= 2.2) {
106			&open_tempfile(HT, ">$htaccess");
107			&print_tempfile(HT, "AuthType None\n");
108			&print_tempfile(HT, "Require all granted\n");
109			&print_tempfile(HT, "Satisfy any\n");
110			&close_tempfile(HT);
111			&set_ownership_permissions(
112				$user, undef, 0755, $htaccess);
113			}
114		}
115	}
116elsif ($mode eq "dns") {
117	# Make sure all the DNS zones exist
118	if ($wildcard && !$letsencrypt_cmd) {
119		return (0, "Wildcard hostname $wildcard can only be ".
120			   "validated when the certbot Let's Encrypt client ".
121			   "is installed");
122		}
123	&foreign_require("bind8");
124	foreach my $d (@doms) {
125		my $z = &get_bind_zone_for_domain($d);
126		my $d = &get_virtualmin_for_domain($d);
127		$z || $d || return (0, "Neither DNS zone $d or any of its ".
128				       "sub-domains exist on this system");
129		}
130	}
131else {
132	return (0, "Unknown mode $mode");
133	}
134
135# Create DNS hook wrapper scripts
136my $dns_hook = "$module_config_directory/letsencrypt-dns.pl";
137my $cleanup_hook = "$module_config_directory/letsencrypt-cleanup.pl";
138if ($mode eq "dns") {
139	&foreign_require("cron");
140	&cron::create_wrapper($dns_hook, $module_name,
141			      "letsencrypt-dns.pl");
142	&cron::create_wrapper($cleanup_hook, $module_name,
143			      "letsencrypt-cleanup.pl");
144	}
145
146if ($letsencrypt_cmd) {
147	# Call the native Let's Encrypt client
148	my $temp = &transname();
149	&open_tempfile(TEMP, ">$temp");
150	&print_tempfile(TEMP, "email = $email\n");
151	&print_tempfile(TEMP, "text = True\n");
152	&close_tempfile(TEMP);
153	my $dir = $letsencrypt_cmd;
154	my $cmd_ver = &get_certbot_major_version($letsencrypt_cmd);
155	my $old_flags;
156	if ($cmd_ver < 1.11) {
157		$old_flags = " --manual-public-ip-logging-ok";
158		}
159	$dir =~ s/\/[^\/]+$//;
160	$size ||= 2048;
161	my $out;
162	if ($mode eq "web") {
163		# Webserver based validation
164		&clean_environment();
165		$out = &backquote_command(
166			"cd $dir && (echo A | $letsencrypt_cmd certonly".
167			" -a webroot ".
168			join("", map { " -d ".quotemeta($_) } @doms).
169			" --webroot-path ".quotemeta($webroot).
170			" --duplicate".
171			" --force-renewal".
172			"$old_flags".
173			" --non-interactive".
174			" --agree-tos".
175			" --config $temp".
176			" --rsa-key-size $size".
177			" --cert-name ".quotemeta($doms[0]).
178			($staging ? " --test-cert" : "").
179			" 2>&1)");
180		&reset_environment();
181		}
182	elsif ($mode eq "dns") {
183		# DNS based validation, via hook script
184		&clean_environment();
185		$out = &backquote_command(
186			"cd $dir && (echo A | $letsencrypt_cmd certonly".
187			" --manual".
188			join("", map { " -d ".quotemeta($_) } @doms).
189			" --preferred-challenges=dns".
190			" --manual-auth-hook $dns_hook".
191			" --manual-cleanup-hook $cleanup_hook".
192			" --duplicate".
193			" --force-renewal".
194			"$old_flags".
195			" --non-interactive".
196			" --agree-tos".
197			" --config $temp".
198			" --rsa-key-size $size".
199			" --cert-name ".quotemeta($doms[0]).
200			($staging ? " --test-cert" : "").
201			" 2>&1)");
202		&reset_environment();
203		}
204	else {
205		&cleanup_wellknown($wellknown_new, $challenge_new);
206		return (0, "Bad mode $mode");
207		}
208	if ($?) {
209		&cleanup_wellknown($wellknown_new, $challenge_new);
210		return (0, "<pre>".&html_escape($out || "No output from $letsencrypt_cmd")."</pre>");
211		}
212	my ($full, $cert, $key, $chain);
213	if ($out =~ /(\/etc\/letsencrypt\/(?:live|archive)\/[a-zA-Z0-9\.\_\-\/\r\n\* ]*\.pem)/) {
214		# Output contained the full path
215		$full = $1;
216		$full =~ s/\s//g;
217		}
218	else {
219		# Try searching common paths
220		my @fulls = (glob("/etc/letsencrypt/live/$doms[0]-*/cert.pem"),
221			     glob("/usr/local/etc/letsencrypt/live/$doms[0]-*/cert.pem"));
222		if (@fulls) {
223			my %stats = map { $_, [ stat($_) ] } @fulls;
224			@fulls = sort { $stats{$a}->[9] <=> $stats{$b}->[9] }
225				      @fulls;
226			$full = pop(@fulls);
227			}
228		else {
229			&cleanup_wellknown($wellknown_new, $challenge_new);
230			&error("Output did not contain a PEM path!");
231			}
232		}
233	-r $full && -s $full || return (0, &text('letsencrypt_efull', $full));
234	$full =~ s/\/[^\/]+$//;
235	$cert = $full."/cert.pem";
236	-r $cert && -s $cert || return (0, &text('letsencrypt_ecert', $cert));
237	$key = $full."/privkey.pem";
238	-r $key && -s $key || return (0, &text('letsencrypt_ekey', $key));
239	$chain = $full."/chain.pem";
240	$chain = undef if (!-r $chain);
241	&set_ownership_permissions(undef, undef, 0600, $cert);
242	&set_ownership_permissions(undef, undef, 0600, $key);
243	&set_ownership_permissions(undef, undef, 0600, $chain);
244	&cleanup_wellknown($wellknown_new, $challenge_new);
245
246	if ($account_email) {
247		# Attempt to update the contact email on file with let's encrypt
248		&system_logged(
249		    "$letsencrypt_cmd register --update-registration".
250		    " --email ".quotemeta($account_email).
251		    " >/dev/null 2>&1 </dev/null");
252		}
253
254	return (1, $cert, $key, $chain);
255	}
256elsif ($mode eq "dns") {
257	# Python client doesn't support DNS
258	return (0, $text{'letsencrypt_eacmedns'});
259	}
260else {
261	# Fall back to local Python client
262	$size ||= 4096;
263
264	# Generate the account key if missing
265	if (!-r $account_key) {
266		my $out = &backquote_logged(
267			"openssl genrsa 4096 2>&1 >$account_key");
268		if ($?) {
269			&cleanup_wellknown($wellknown_new, $challenge_new);
270			return (0, &text('letsencrypt_eaccountkey',
271					 &html_escape($out)));
272			}
273		}
274
275	# Generate a key for the domain
276	my $key = &transname();
277	my $out = &backquote_logged("openssl genrsa $size 2>&1 >$key");
278	if ($?) {
279		&cleanup_wellknown($wellknown_new, $challenge_new);
280		return (0, &text('letsencrypt_ekeygen', &html_escape($out)));
281		}
282
283	# Generate a CSR
284	my $csr = &transname();
285	my ($ok, $csr) = &generate_ssl_csr($key, undef, undef, undef,
286					   undef, undef, \@doms, undef);
287	if (!$ok) {
288		&cleanup_wellknown($wellknown_new, $challenge_new);
289		return &text('letsencrypt_ecsr', $csr);
290		}
291	&copy_source_dest($csr, "/tmp/lets.csr", 1);
292
293	# Find a reasonable python version
294	my $python = &get_python_cmd();
295
296	# Request the cert and key
297	my $cert = &transname();
298	&clean_environment();
299	my $out = &backquote_logged(
300		"$python $module_root_directory/acme_tiny.py ".
301		"--account-key ".quotemeta($account_key)." ".
302		"--csr ".quotemeta($csr)." ".
303		($mode eq "web" ? "--acme-dir ".quotemeta($challenge)." "
304				: "--dns-hook $dns_hook ".
305				  "--cleanup-hook $cleanup_hook ").
306		($staging ? "--ca https://acme-staging.api.letsencrypt.org "
307			  : "--disable-check ").
308		"--quiet ".
309		"2>&1 >".quotemeta($cert));
310	&reset_environment();
311	if ($?) {
312		my @lines = split(/\r?\n/, $out);
313		my $trace;
314		for(my $i=1; $i<@lines; $i++) {
315			if ($lines[$i] =~ /^Traceback\s+/) {
316				$trace = $i;
317				last;
318				}
319			}
320		if ($trace) {
321			@lines = @lines[0 .. $trace-1];
322			$out = join("\n", @lines);
323			}
324		&cleanup_wellknown($wellknown_new, $challenge_new);
325		return (0, &text('letsencrypt_etiny',
326				 "<pre>".&html_escape($out))."</pre>");
327		}
328	-r $cert && -s $cert || return (0, &text('letsencrypt_ecert', $cert));
329
330	# Check if the returned cert contains a CA cert as well
331	my $chain = &transname();
332	my @certs = &cert_file_split($cert);
333	my %donecert;
334	if (@certs > 1) {
335		# Yes .. keep the first as the cert, and use the others as
336		# the chain
337		my $orig = shift(@certs);
338		my $fh = "CHAIN";
339		&open_tempfile($fh, ">$chain");
340		foreach my $c (@certs) {
341			&print_tempfile($fh, $c);
342			$donecert{$c}++;
343			}
344		&close_tempfile($fh);
345		my $fh2 = "CERT";
346		&open_tempfile($fh2, ">$cert");
347		&print_tempfile($fh2, $orig);
348		&close_tempfile($fh2);
349		}
350
351	# Download the latest chained cert files
352	foreach my $url (@$letsencrypt_chain_urls) {
353		my $cout;
354		my ($host, $port, $page, $ssl) = &parse_http_url($url);
355		my $err;
356		&http_download($host, $port, $page, \$cout, \$err, undef, $ssl);
357		if ($err) {
358			&cleanup_wellknown($wellknown_new, $challenge_new);
359			return (0, &text('letsencrypt_echain', $err));
360			}
361		if ($cout !~ /\S/ && !-r $chain) {
362			&cleanup_wellknown($wellknown_new, $challenge_new);
363			return (0, &text('letsencrypt_echain2', $url));
364			}
365		if (!$donecert{$cout}++) {
366			my $fh = "CHAIN";
367			&open_tempfile($fh, ">>$chain");
368			&print_tempfile($fh, $cout);
369			&close_tempfile($fh);
370			}
371		}
372
373	# Copy the per-domain files
374	my $certfinal = "$module_config_directory/$doms[0].cert";
375	my $keyfinal = "$module_config_directory/$doms[0].key";
376	my $chainfinal = "$module_config_directory/$doms[0].ca";
377	&copy_source_dest($cert, $certfinal, 1);
378	&copy_source_dest($key, $keyfinal, 1);
379	&copy_source_dest($chain, $chainfinal, 1);
380	&set_ownership_permissions(undef, undef, 0600, $certfinal);
381	&set_ownership_permissions(undef, undef, 0600, $keyfinal);
382	&set_ownership_permissions(undef, undef, 0600, $chainfinal);
383	&unlink_file($cert);
384	&unlink_file($key);
385	&unlink_file($chain);
386
387	&cleanup_wellknown($wellknown_new, $challenge_new);
388	return (1, $certfinal, $keyfinal, $chainfinal);
389	}
390}
391
392# cleanup_wellknown(wellknown, challenge)
393# Delete directories that were created as part of this process
394sub cleanup_wellknown
395{
396my ($wellknown_new, $challenge_new) = @_;
397&unlink_file($challenge_new) if ($challenge_new);
398&unlink_file($wellknown_new) if ($wellknown_new);
399}
400
401# get_bind_zone_for_domain(domain)
402# Given a hostname like www.foo.com, return the local BIND zone that contains
403# it like foo.com
404sub get_bind_zone_for_domain
405{
406my ($d) = @_;
407&foreign_require("bind8");
408my $bd = $d;
409while ($bd =~ /\./) {
410	my $z = &bind8::get_zone_name($bd, "any");
411	if ($z && $z->{'file'} && $z->{'type'} eq 'master') {
412		return ($z, $bd);
413		}
414	$bd =~ s/^[^\.]+\.//;
415	}
416return ( );
417}
418
419# get_virtualmin_for_domain(domain-name)
420# If Virtualmin is installed, return the domain object that contains the given DNS domain
421sub get_virtualmin_for_domain
422{
423my ($bd) = @_;
424return undef if (!&foreign_check("virtual-server"));
425&foreign_require("virtual-server");
426while ($bd =~ /\./) {
427	my $d = &virtual_server::get_domain_by("dom", $bd);
428	if ($d && $d->{'dns'}) {
429		return $d;
430		}
431	$bd =~ s/^[^\.]+\.//;
432	}
433return undef;
434}
435
436# get_certbot_major_version(cmd)
437# Returns Let's Encrypt client major version, such as 1.11 or 0.40
438sub get_certbot_major_version
439{
440my ($cmd) = @_;
441my $out = &backquote_command("$cmd --version 2>&1");
442if ($out && $out =~ /\s*(\d+\.\d+)\s*/) {
443	return $1;
444	}
445return undef;
446}
447
4481;
449