Fix Let's Encrypt Certbot PEM path parsing

ⓘ Prevent Webmin from swallowing Certbot's key-path output when extracting PEM paths, while preserving IPv6 cert-name support and adding regression coverage.
This commit is contained in:
Ilia Ross
2026-06-27 22:59:21 +02:00
parent 81d44f8491
commit d02f0b6cb5
2 changed files with 69 additions and 4 deletions
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/perl
# Regression tests for Certbot output path parsing used by Webmin SSL.
use strict;
use warnings;
use Test::More;
use File::Basename qw(dirname);
use File::Spec;
our %config;
our $module_config_directory = "/etc/webmin/webmin";
sub has_command { return undef; }
my $script = File::Spec->rel2abs(
File::Spec->catfile(dirname(__FILE__), '..',
'webmin', 'letsencrypt-lib.pl'));
do $script or die "failed to load $script: $@ $!";
my $certbot_out = <<'EOF';
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/test.example/fullchain.pem
Key is saved at: /etc/letsencrypt/live/test.example/privkey.pem
This certificate expires on 2026-09-25.
EOF
is(main::get_letsencrypt_output_pem_path($certbot_out),
'/etc/letsencrypt/live/test.example/fullchain.pem',
'certbot output path stops at the first PEM path');
my $ipv6_out = <<'EOF';
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/2001:db8::1/fullchain.pem
Key is saved at: /etc/letsencrypt/live/2001:db8::1/privkey.pem
EOF
is(main::get_letsencrypt_output_pem_path($ipv6_out),
'/etc/letsencrypt/live/2001:db8::1/fullchain.pem',
'IPv6 certificate names can still contain colons');
my $wrapped_out = <<'EOF';
Certificate is saved at: /etc/letsencrypt/live/wrapped.example/
fullchain.pem
EOF
is(main::get_letsencrypt_output_pem_path($wrapped_out),
'/etc/letsencrypt/live/wrapped.example/fullchain.pem',
'wrapped PEM paths are normalized');
done_testing();
+19 -4
View File
@@ -131,6 +131,20 @@ return &software::missing_install_link(
"certbot", $text{'letsencrypt_certbot'}, $rlink, $rmsg);
}
# get_letsencrypt_output_pem_path(output)
# Returns the first certbot PEM path from command output, or undef
sub get_letsencrypt_output_pem_path
{
my ($out) = @_;
if ($out =~ /((?:\/usr\/local)?\/etc\/letsencrypt\/(?:live|archive)\/[a-zA-Z0-9\.\_\-:\/\*]+\.pem)/ ||
$out =~ /((?:\/usr\/local)?\/etc\/letsencrypt\/(?:live|archive)\/[a-zA-Z0-9\.\_\-:\/\r\n\* ]*?\.pem)/) {
my $full = $1;
$full =~ s/\s//g;
return $full;
}
return undef;
}
# request_letsencrypt_cert(domain|&domains|&ips, webroot, [email], [keysize],
# [request-mode], [use-staging], [account-email],
# [key-type], [reuse-key],
@@ -387,14 +401,15 @@ if ($letsencrypt_cmd) {
goto FAILED;
}
my ($full, $cert, $key, $chain);
if ($out =~ /((?:\/usr\/local)?\/etc\/letsencrypt\/(?:live|archive)\/[a-zA-Z0-9\.\_\-:\/\r\n\* ]*\.pem)/) {
if ($full = &get_letsencrypt_output_pem_path($out)) {
# Output contained the full path
$full = $1;
$full =~ s/\s//g;
}
else {
# Try searching common paths
my @fulls = (glob("/etc/letsencrypt/live/$certname-*/cert.pem"),
my @fulls = grep { -r $_ } (
"/etc/letsencrypt/live/$certname/cert.pem",
glob("/etc/letsencrypt/live/$certname-*/cert.pem"),
"/usr/local/etc/letsencrypt/live/$certname/cert.pem",
glob("/usr/local/etc/letsencrypt/live/$certname-*/cert.pem"));
if (@fulls) {
my %stats = map { $_, [ stat($_) ] } @fulls;