Fix IP address support for Webmin Let’s Encrypt certificates

ⓘ Support IPv4/IPv6 identifiers in Webmin Let’s Encrypt requests, add days/months renewal scheduling, and enforce safe automatic renewal defaults for short-lived IP certificates.

https://github.com/webmin/webmin/commit/a56748a3fc136ddaa6d24a626d100beb9a8b870a#r188893457
This commit is contained in:
Ilia Ross
2026-06-16 03:15:02 +02:00
parent e580dcdd40
commit 8cca3a117f
5 changed files with 115 additions and 22 deletions
+23 -3
View File
@@ -406,9 +406,29 @@ else {
# Renewal option
my $job = &find_letsencrypt_cron_job();
my $renew = $job && $job->{'months'} =~ /^\*\/(\d+)$/ ? $1 : undef;
print &ui_table_row($text{'ssl_letsrenew'},
&ui_opt_textbox("renew", $renew, 4, $text{'ssl_letsnotrenew'}));
my $has_ip_doms = &letsencrypt_doms_have_ips(\@doms);
my $renew_unit = $has_ip_doms ? "days" :
($config{'letsencrypt_renew_unit'} || "months");
my $renew;
if ($job && $renew_unit eq "days" && $job->{'interval'}) {
$renew = int($job->{'interval'} / (24*60*60));
}
elsif ($job && $job->{'months'} =~ /^\*\/(\d+)$/) {
$renew_unit = "months";
$renew = $1;
}
elsif ($job && $job->{'interval'}) {
$renew_unit = "months";
$renew = int($job->{'interval'} / (30*24*60*60));
}
my @renew_units = ( [ "months", $text{'ssl_letsrenew_months'} ],
[ "days", $text{'ssl_letsrenew_days'} ] );
my $renew_disabled = $renew eq '';
print &ui_table_row(&hlink($text{'ssl_letsrenew'}, "letsrenew"),
&ui_opt_textbox("renew", $renew, 4, $text{'ssl_letsnotrenew'},
undef, undef, [ "renew_unit" ])." ".
&ui_select("renew_unit", $renew_unit, \@renew_units,
undef, undef, undef, $renew_disabled));
print &ui_table_end();
print &ui_form_end([ [ undef, $text{'ssl_letsok'} ],
+22
View File
@@ -0,0 +1,22 @@
<header>Automatic renewal interval</header>
<p>Sets how often Webmin should request a replacement certificate from the ACME
provider.</p>
<p>For hostname certificates, a renewal interval measured in months is usually
appropriate. You can also choose days if you want Webmin to rotate the
certificate more frequently.</p>
<p>IP address certificates are issued as short-lived certificates by CAs such as
Let's Encrypt. Let's Encrypt currently makes these certificates valid for
160 hours, just over six days, and requires IP address certificates to use the
short-lived profile because IP addresses can change ownership more often than
domain names.</p>
<p>For IP address certificates, Webmin defaults to days and using 1 or 2 days
is recommended so there is time to retry before the certificate expires. If
Webmin is managing automatic renewal, the interval must be no more than 5 days.
Manual renewal remains an administrator choice, especially if renewal is handled
by separate automation or another operational policy.</p>
<footer>
+10 -6
View File
@@ -427,11 +427,11 @@ ssl_already2=Previously generated certificate signing request :
ssl_letsdesc=This form lets you request a trusted SSL certificate for Webmin from an ACME-compatible provider such as Let's Encrypt.
ssl_letserr=Unfortunately, this system cannot request certificates from an SSL provider : $1.
ssl_letserr2=Alternately, check the <a href='$1'>module configuration</a> page to ensure you are using the correct path to the configured ACME client command.
ssl_letsdesc2=The new certificate will replace the one currently configured in Webmin. Before it can be issued, the ACME provider must verify control of each hostname using either a temporary file in the website's document directory or a temporary DNS TXT record.
ssl_letsdesc2=The new certificate will replace the one currently configured in Webmin. Before it can be issued, the ACME provider must verify control of each hostname or IP address. Hostnames can be verified using either a temporary file in the website's document directory or a temporary DNS TXT record; IP addresses require webserver-based validation.
ssl_letsheader=Certificate request options
ssl_letsdoms=Hostnames for certificate
ssl_subset=Skip unverifiable hostnames?
ssl_letsmode=Domain validation method
ssl_letsdoms=Hostnames or IP addresses for certificate
ssl_subset=Skip unverifiable hostnames or IP addresses?
ssl_letsmode=Validation method
ssl_letsmode0=Apache virtual host matching hostname
ssl_letsmode1=Selected Apache virtual host
ssl_letsmode2=Other webserver document directory
@@ -441,7 +441,9 @@ ssl_certbotprehook=Nevertheless, this warning can be ignored if the pre-request
ssl_letsok=Request Certificate
ssl_letsonly=Just Update Renewal
ssl_usewebmin=Copy new key and certificate to Webmin?
ssl_letsrenew=Months between automatic renewal
ssl_letsrenew=Automatic renewal interval
ssl_letsrenew_months=months
ssl_letsrenew_days=days
ssl_letsnotrenew=Only renew manually
ssl_staging=Provider server
ssl_staging0=Production
@@ -1237,8 +1239,10 @@ letsencrypt_epythonver=Failed to get the Python version : $1
letsencrypt_epythonver2=Python version $1 or above is required, but you only have version $2.
letsencrypt_certbot=Certbot
letsencrypt_edroot=The Apache virtual host $1 has no document directory!
letsencrypt_edom=Missing or invalid domain name
letsencrypt_edom=Missing or invalid domain name or IP address
letsencrypt_erenew=Missing or non-numeric renewal interval
letsencrypt_erenewunit=Invalid renewal interval unit
letsencrypt_erenewipauto=Automatic renewal for IP address certificates must use days and be no more than $1 days
letsencrypt_eacmedir=Missing or invalid ACME directory URL
letsencrypt_eeabdir=An ACME directory URL must be entered when using External Account Binding
letsencrypt_eeabpair=Both the External Account Binding key ID and HMAC key must be entered
+28 -6
View File
@@ -18,6 +18,26 @@ $letsencrypt_chain_urls = [
"https://letsencrypt.org/certs/lets-encrypt-e1.pem",
];
# letsencrypt_is_ip_cert_identifier(identifier)
# Returns 1 if some certificate identifier is an IPv4 or IPv6 address
sub letsencrypt_is_ip_cert_identifier
{
my ($id) = @_;
return 1 if (&check_ipaddress($id));
return $id !~ /\// && $id =~ /:/ && &check_ip6address($id);
}
# letsencrypt_doms_have_ips(&domains)
# Returns 1 if any certificate identifier is an IP address
sub letsencrypt_doms_have_ips
{
my ($doms) = @_;
foreach my $d (@$doms) {
return 1 if (&letsencrypt_is_ip_cert_identifier($d));
}
return 0;
}
# check_letsencrypt()
# Returns undef if all dependencies are installed, or an error message
sub check_letsencrypt
@@ -126,8 +146,8 @@ my ($dom, $webroot, $email, $size, $mode, $staging, $account_email,
$subset) = @_;
my @doms = ref($dom) ? @$dom : ($dom);
@doms = &unique(@doms);
my @ips = grep { &check_ipaddress($_) } @doms;
@doms = grep { !&check_ipaddress($_) } @doms;
my @ips = grep { &letsencrypt_is_ip_cert_identifier($_) } @doms;
@doms = grep { !&letsencrypt_is_ip_cert_identifier($_) } @doms;
$email ||= (@doms ? "root\@$doms[0]" : "root\@".&get_system_hostname());
$mode ||= "web";
$reuse_key = $config{'letsencrypt_reuse'} if (!defined($reuse_key));
@@ -152,9 +172,11 @@ if (@ips) {
"for IP addresses");
$letsencrypt_cmd || return (0, "The certbot command is required for ".
"IP address certificates");
&compare_version_numbers($cmd_ver, "5.3") >= 0 ||
return (0, "Certbot version 5.3 or later is required for ".
"IP address certificates");
my $ip_min_certbot = $mode eq "web" ? "5.4" : "5.3";
&compare_version_numbers($cmd_ver, $ip_min_certbot) >= 0 ||
return (0, "Certbot version $ip_min_certbot or later is ".
"required for IP address certificates".
($mode eq "web" ? " in webroot mode" : ""));
}
if ($mode eq "web") {
@@ -365,7 +387,7 @@ 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 ($out =~ /((?:\/usr\/local)?\/etc\/letsencrypt\/(?:live|archive)\/[a-zA-Z0-9\.\_\-:\/\r\n\* ]*\.pem)/) {
# Output contained the full path
$full = $1;
$full =~ s/\s//g;
+32 -7
View File
@@ -16,6 +16,8 @@ our $module_name;
our $letsencrypt_cmd;
&error_setup($text{'letsencrypt_err'});
my $letsencrypt_ip_cert_renewal_max_days = 5;
# Re-check if let's encrypt is available
my $err = &check_letsencrypt();
&error($err) if ($err);
@@ -24,8 +26,11 @@ my $err = &check_letsencrypt();
&ReadParse();
my @doms = split(/\s+/, $in{'dom'});
foreach my $dom (@doms) {
$dom =~ /^(\*\.)?[a-z0-9\-\.\_]+$/i || &error($text{'letsencrypt_edom'});
$dom =~ /^(\*\.)?[a-z0-9\-\.\_]+$/i ||
&letsencrypt_is_ip_cert_identifier($dom) ||
&error($text{'letsencrypt_edom'});
}
my $has_ip_doms = &letsencrypt_doms_have_ips(\@doms);
$in{'directory_url'} = &trim($in{'directory_url'});
$in{'eab_kid'} = &trim($in{'eab_kid'});
$in{'eab_hmac'} = &trim($in{'eab_hmac'});
@@ -39,8 +44,19 @@ if ($in{'eab_kid'} || $in{'eab_hmac'}) {
&error($text{'letsencrypt_eeabpair'});
$letsencrypt_cmd || &error($text{'letsencrypt_eeabnative'});
}
$in{'renew_def'} || $in{'renew'} =~ /^[1-9][0-9]*$/ ||
&error($text{'letsencrypt_erenew'});
if (!$in{'renew_def'}) {
$in{'renew'} =~ /^[1-9][0-9]*$/ ||
&error($text{'letsencrypt_erenew'});
$in{'renew_unit'} ||= $has_ip_doms ? "days" : "months";
$in{'renew_unit'} =~ /^(days|months)$/ ||
&error($text{'letsencrypt_erenewunit'});
my $ip_max_days = $letsencrypt_ip_cert_renewal_max_days;
if ($has_ip_doms && ($in{'renew_unit'} ne "days" ||
$in{'renew'} > $ip_max_days)) {
&error(&text('letsencrypt_erenewipauto',
$ip_max_days));
}
}
$in{'size_def'} || $in{'size'} =~ /^\d+$/ ||
&error($text{'newkey_esize'});
my $size = $in{'size_def'} ? undef : $in{'size'};
@@ -194,6 +210,11 @@ $config{'letsencrypt_mode'} = $mode;
$config{'letsencrypt_size'} = $size;
$config{'letsencrypt_subset'} = $subset;
$config{'letsencrypt_nouse'} = $usewebmin ? 0 : 1;
my $renew_has_ip_doms = &letsencrypt_doms_have_ips($doms);
my $renew_unit = $in{'renew_unit'} =~ /^(days|months)$/ ?
$in{'renew_unit'} :
($renew_has_ip_doms ? "days" : "months");
$config{'letsencrypt_renew_unit'} = $renew_unit;
if ($directory_url) {
$config{'letsencrypt_directory_url'} = $directory_url;
}
@@ -229,14 +250,18 @@ if (&foreign_check("webmincron")) {
}
$job ||= { 'module' => $module_name,
'func' => 'renew_letsencrypt_cert' };
# Scheduling is driven by 'interval' (elapsed seconds); 'months'
# is retained only so edit_ssl.cgi can show the renewal interval
# Scheduling is driven by 'interval' (elapsed seconds).
# 'months' is retained only for month-based schedules so
# edit_ssl.cgi can show the renewal interval.
$job->{'mins'} = '';
$job->{'hours'} = '';
$job->{'days'} = '';
$job->{'months'} = '*/'.$in{'renew'};
$job->{'months'} = $renew_unit eq "months" ?
'*/'.$in{'renew'} : '';
$job->{'weekdays'} = '';
$job->{'interval'} = $in{'renew'}*30*24*60*60;
$job->{'interval'} = $renew_unit eq "days" ?
$in{'renew'}*24*60*60 :
$in{'renew'}*30*24*60*60;
&webmincron::create_webmin_cron($job);
}
}