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 ©_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 ©_source_dest($cert, $certfinal, 1); 378 ©_source_dest($key, $keyfinal, 1); 379 ©_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