Skip to content

Commit 6039b82

Browse files
committed
feat: add automatic garbage collection for Let's Encrypt certificates.
When enabled (enabled by default for new installs) ISPConfig will automatically remove certbot/acme.sh issued certificates that do not get used anymore. You can specify a list of domains that should never be automatically deleted. If you use certbot/acme.sh on your server outside ISPConfig you should list all these domains in the denylist otherwise ISPConfig will automatically remove the certificates. Fixes #5226
1 parent 7ed6ee9 commit 6039b82

File tree

7 files changed

+549
-90
lines changed

7 files changed

+549
-90
lines changed

install/tpl/server.ini.master

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,8 @@ vhost_proxy_protocol_enabled=n
142142
vhost_proxy_protocol_protocols=ipv4
143143
vhost_proxy_protocol_http_port=880
144144
vhost_proxy_protocol_https_port=8443
145+
le_auto_cleanup=y
146+
le_auto_cleanup_denylist=[server_name]
145147

146148
[dns]
147149
bind_user=root

interface/web/admin/form/server_config.tform.php

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -574,15 +574,6 @@
574574
'default' => '2048',
575575
'value' => array('1024' => 'weak (1024)', '2048' => 'normal (2048)', '4096' => 'strong (4096)')
576576
),
577-
'relayhost_password' => array(
578-
'datatype' => 'VARCHAR',
579-
'formtype' => 'TEXT',
580-
'default' => '',
581-
'value' => '',
582-
'width' => '40',
583-
'maxlength' => '255'
584-
),
585-
586577
'pop3_imap_daemon' => array(
587578
'datatype' => 'VARCHAR',
588579
'formtype' => 'SELECT',
@@ -1642,6 +1633,20 @@
16421633
'width' => '40',
16431634
'maxlength' => '255'
16441635
),
1636+
'le_auto_cleanup' => array(
1637+
'datatype' => 'VARCHAR',
1638+
'formtype' => 'CHECKBOX',
1639+
'default' => 'y',
1640+
'value' => array(0 => 'n', 1 => 'y')
1641+
),
1642+
'le_auto_cleanup_denylist' => array(
1643+
'datatype' => 'VARCHAR',
1644+
'formtype' => 'TEXT',
1645+
'default' => '[server_name]',
1646+
'value' => '',
1647+
'width' => '40',
1648+
'maxlength' => '255'
1649+
),
16451650
//#################################
16461651
// END Datatable fields
16471652
//#################################

interface/web/admin/lib/lang/en_server_config.lng

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,3 +370,6 @@ $wb['sysbackup_copies_txt'] = 'Number of ISPConfig backups';
370370
$wb['sysbackup_copies_error_empty'] = 'Number of ISPConfig backups must not be empty';
371371
$wb['sysbackup_copies_error_regex'] = 'Number of ISPConfig backups must be a number between 1 and 3';
372372
$wb['sysbackup_copies_note_txt'] = '(0 = off)';
373+
$wb['le_auto_cleanup_txt'] = 'Automatically purge unused Let\'s Encrypt certificates';
374+
$wb['le_auto_cleanup_denylist_txt'] = 'Comma seperated list of domains that should never be purged.';
375+
$wb['le_auto_cleanup_denylist_note_txt'] = 'Placeholders:';

interface/web/admin/templates/server_config_web_edit.htm

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,17 @@ <h4 class="panel-title">
248248
<label class="col-sm-3 control-label"><tmpl_var name="skip_le_check_txt"></label>
249249
<div class="col-sm-9"><tmpl_var name="skip_le_check"></div>
250250
</div>
251+
<div class="form-group">
252+
<label class="col-sm-3 control-label"><tmpl_var name="le_auto_cleanup_txt"></label>
253+
<div class="col-sm-9"><tmpl_var name="le_auto_cleanup"></div>
254+
</div>
255+
<div class="form-group">
256+
<label for="le_auto_cleanup_denylist" class="col-sm-3 control-label">{tmpl_var name='le_auto_cleanup_denylist_txt'}</label>
257+
<div class="col-sm-9">
258+
<input type="text" name="CA_path" id="le_auto_cleanup_denylist" value="{tmpl_var name='le_auto_cleanup_denylist'}" class="form-control" />
259+
<br>{tmpl_var name='le_auto_cleanup_denylist_note_txt'} <a href="javascript:void(0);" class="addPlaceholder">[server_name]</a>
260+
</div>
261+
</div>
251262
<!-- End content -->
252263
</div>
253264
</div>
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
<?php
2+
3+
/*
4+
Copyright (c) 2024, Daniel Jagszent
5+
All rights reserved.
6+
7+
Redistribution and use in source and binary forms, with or without modification,
8+
are permitted provided that the following conditions are met:
9+
10+
* Redistributions of source code must retain the above copyright notice,
11+
this list of conditions and the following disclaimer.
12+
* Redistributions in binary form must reproduce the above copyright notice,
13+
this list of conditions and the following disclaimer in the documentation
14+
and/or other materials provided with the distribution.
15+
* Neither the name of ISPConfig nor the names of its contributors
16+
may be used to endorse or promote products derived from this software without
17+
specific prior written permission.
18+
19+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
20+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
21+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
22+
IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
23+
INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
24+
BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25+
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
26+
OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
27+
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
28+
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
31+
class letsencrypt_cli extends cli
32+
{
33+
34+
function __construct()
35+
{
36+
$cmd_opt = [];
37+
$cmd_opt['letsencrypt'] = 'showHelp';
38+
$cmd_opt['letsencrypt:list'] = 'list';
39+
$cmd_opt['letsencrypt:cleanup-expired'] = 'cleanupExpired';
40+
$this->addCmdOpt($cmd_opt);
41+
}
42+
43+
public function list($arg)
44+
{
45+
global $app;
46+
$app->uses('letsencrypt');
47+
$certificates = $app->letsencrypt->get_certificate_list();
48+
foreach ($certificates as $certificate) {
49+
print_r($certificate);
50+
}
51+
}
52+
53+
public function cleanupExpired($arg)
54+
{
55+
global $app;
56+
$app->uses('letsencrypt');
57+
$removals = 0;
58+
$hasErrors = false;
59+
$certificates = $app->letsencrypt->get_certificate_list();
60+
foreach ($certificates as $certificate) {
61+
if (! $certificate['is_valid']) {
62+
$this->swriteln("Removing ".join(', ', $certificate['domains'])." expired certificate...");
63+
if ($app->letsencrypt->remove_certificate($certificate)) {
64+
$removals += 1;
65+
} else {
66+
$this->swriteln("Could not remove ".print_r($certificate, true));
67+
$hasErrors = true;
68+
}
69+
}
70+
}
71+
if ($removals) {
72+
$this->swriteln("Removed $removals expired certificates");
73+
} else {
74+
$this->swriteln("No certificates were removed");
75+
}
76+
if ($hasErrors) {
77+
exit(1);
78+
}
79+
}
80+
81+
public function showHelp($arg)
82+
{
83+
global $conf;
84+
85+
$this->swriteln("---------------------------------");
86+
$this->swriteln("- Available commandline option -");
87+
$this->swriteln("---------------------------------");
88+
$this->swriteln("ispc letsencrypt list - lists all known certificates");
89+
$this->swriteln("ispc letsencrypt cleanup-expired - Cleanup all expired certificates.");
90+
$this->swriteln("---------------------------------");
91+
$this->swriteln();
92+
}
93+
94+
}
95+
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
<?php
2+
3+
/*
4+
Copyright (c) 2024, Daniel Jagszent
5+
All rights reserved.
6+
7+
Redistribution and use in source and binary forms, with or without modification,
8+
are permitted provided that the following conditions are met:
9+
10+
* Redistributions of source code must retain the above copyright notice,
11+
this list of conditions and the following disclaimer.
12+
* Redistributions in binary form must reproduce the above copyright notice,
13+
this list of conditions and the following disclaimer in the documentation
14+
and/or other materials provided with the distribution.
15+
* Neither the name of ISPConfig nor the names of its contributors
16+
may be used to endorse or promote products derived from this software without
17+
specific prior written permission.
18+
19+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
20+
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
21+
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
22+
IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
23+
INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
24+
BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
25+
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY
26+
OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
27+
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
28+
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
29+
*/
30+
31+
class cronjob_letsencrypt_cleanup extends cronjob
32+
{
33+
34+
// job schedule
35+
protected $_schedule = '@weekly';
36+
37+
private $denylist = [];
38+
39+
protected function onBeforeRun()
40+
{
41+
global $app, $conf;
42+
$app->uses('letsencrypt,ini_parser,getconf');
43+
44+
$server_db_record = $app->db->queryOneRecord("SELECT * FROM server WHERE server_id = ?", $conf['server_id']);
45+
if (! $server_db_record || ! $server_db_record['web_server']) {
46+
if ($conf['log_priority'] <= LOGLEVEL_DEBUG) {
47+
print 'Webserver not active, not running Let\'s Encrypt cleanup.'."\n";
48+
}
49+
50+
return false;
51+
}
52+
53+
$server_config = $app->getconf->get_server_config($conf['server_id'], 'server');
54+
if (($server_config['migration_mode'] ?? 'n') != 'y') {
55+
if ($conf['log_priority'] <= LOGLEVEL_DEBUG) {
56+
print 'Migration mode active, not running Let\'s Encrypt cleanup.'."\n";
57+
}
58+
59+
return false;
60+
}
61+
62+
if (! $app->letsencrypt->get_acme_script() && ! $app->letsencrypt->get_certbot_script()) {
63+
if ($conf['log_priority'] <= LOGLEVEL_DEBUG) {
64+
print 'No Let\'s Encrypt client found, not running Let\'s Encrypt cleanup.'."\n";
65+
}
66+
67+
return false;
68+
}
69+
70+
$web_config = $app->getconf->get_server_config($conf['server_id'], 'web');
71+
$le_auto_cleanup = $web_config['le_auto_cleanup'] ?? 'n';
72+
if ($le_auto_cleanup == 'n') {
73+
return false;
74+
}
75+
$this->denylist = array_filter(array_map(function ($domain) use ($server_db_record) {
76+
$domain = trim($domain);
77+
if ($domain == '[server_name]') {
78+
return $server_db_record['server_name'];
79+
}
80+
81+
return $domain;
82+
}, explode(',', $web_config['le_auto_cleanup_denylist'] ?? '')));
83+
84+
return parent::onBeforeRun();
85+
}
86+
87+
88+
public function onRunJob()
89+
{
90+
global $app, $conf;
91+
$used_serials = [];
92+
$active_ssl_records = $app->db->queryAllRecords(
93+
"SELECT * FROM web_domain WHERE active = 'y' AND ssl_letsencrypt = 'y' AND `ssl` = 'y' AND document_root IS NOT NULL AND server_id = ?",
94+
$conf['server_id']
95+
);
96+
foreach ($active_ssl_records as $active_ssl_record) {
97+
$cert_paths = $app->letsencrypt->get_website_certificate_paths(['new' => $active_ssl_record]);
98+
if (is_readable($cert_paths['crt'])) {
99+
$info = $app->letsencrypt->extract_x509($cert_paths['crt']);
100+
if ($info) {
101+
$used_serials[] = $info['serialNumber'];
102+
if ($conf['log_priority'] <= LOGLEVEL_DEBUG) {
103+
print 'mark serial number '.$info['serialNumber'].' as used from '.$cert_paths['crt']."\n";
104+
}
105+
} else {
106+
if ($conf['log_priority'] <= LOGLEVEL_DEBUG) {
107+
print 'cannot extract x509 information from '.$cert_paths['crt']."\n";
108+
}
109+
}
110+
} else {
111+
if ($conf['log_priority'] <= LOGLEVEL_DEBUG) {
112+
print $cert_paths['crt'].' is not readable'."\n";
113+
}
114+
}
115+
}
116+
117+
$certificates = $app->letsencrypt->get_certificate_list();
118+
foreach ($certificates as $certificate) {
119+
if (in_array($certificate['serialNumber'], $used_serials)) {
120+
if ($conf['log_priority'] <= LOGLEVEL_DEBUG) {
121+
print 'Skip '.$certificate['id'] . ' because it still gets used by ISPConfig' . "\n";
122+
}
123+
continue;
124+
}
125+
foreach ($this->denylist as $domain) {
126+
if (in_array($domain, $certificate['domains'])) {
127+
if ($conf['log_priority'] <= LOGLEVEL_DEBUG) {
128+
print 'Skip '.$certificate['id'] . ' because it is on denylist' . "\n";
129+
}
130+
continue 2;
131+
}
132+
}
133+
if ($app->letsencrypt->remove_certificate($certificate)) {
134+
print 'Removed unused certificate '.$certificate['id']."\n";
135+
} else {
136+
print 'Error removing certificate '.$certificate['id']."\n";
137+
}
138+
}
139+
140+
parent::onRunJob();
141+
}
142+
}

0 commit comments

Comments
 (0)