Skip to content

Commit f17b5b0

Browse files
committed
feat: temporarily open port 80 for ACME HTTP-01 validation
Add AcmeHttpPort class that creates a dedicated nginx server block on port 80 serving only /.well-known/acme-challenge/ (returns 444 for everything else) and adds iptables rules when firewall is managed. Port 80 is opened before certificate request and guaranteed closed after via try/finally in all code paths: REST API GET-CERT handler, updateCert.php, and new cronRenewCert.php wrapper for cron renewal. Safety mechanisms: - Lock file with PID+timestamp in /var/run (tmpfs, survives no crash) - Watchdog cron (every minute) cleans up stale state after 5min/dead PID - Cleanup on PBX startup via onAfterPbxStarted Also adds timestamp wrapper for getssl output (ISO format matching syslog) and phpstan.neon config for the module.
1 parent 87fbb06 commit f17b5b0

File tree

8 files changed

+376
-14
lines changed

8 files changed

+376
-14
lines changed

Lib/AcmeHttpPort.php

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
<?php
2+
3+
/*
4+
* MikoPBX - free phone system for small business
5+
* Copyright © 2017-2024 Alexey Portnov and Nikolay Beketov
6+
*
7+
* This program is free software: you can redistribute it and/or modify
8+
* it under the terms of the GNU General Public License as published by
9+
* the Free Software Foundation; either version 3 of the License, or
10+
* (at your option) any later version.
11+
*
12+
* This program is distributed in the hope that it will be useful,
13+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
* GNU General Public License for more details.
16+
*
17+
* You should have received a copy of the GNU General Public License along with this program.
18+
* If not, see <https://www.gnu.org/licenses/>.
19+
*/
20+
21+
namespace Modules\ModuleGetSsl\Lib;
22+
23+
use MikoPBX\Common\Models\PbxSettings;
24+
use MikoPBX\Core\System\Processes;
25+
use MikoPBX\Core\System\System;
26+
use MikoPBX\Core\System\Util;
27+
use Modules\ModuleGetSsl\Models\ModuleGetSsl;
28+
29+
/**
30+
* Manages temporary port 80 opening for ACME HTTP-01 validation.
31+
*
32+
* Creates a dedicated nginx server block on port 80 serving only
33+
* /.well-known/acme-challenge/ and adds iptables rules when firewall is active.
34+
*/
35+
class AcmeHttpPort
36+
{
37+
private const LOCK_FILE = '/var/run/custom_modules/ModuleGetSsl/acme_port80.lock';
38+
private const NGINX_ACME_CONF = '/etc/nginx/mikopbx/modules_servers/ModuleGetSsl_acme80.conf';
39+
private const MAX_OPEN_SECONDS = 300;
40+
41+
/**
42+
* Opens port 80 for ACME HTTP-01 validation.
43+
*
44+
* Creates a dedicated nginx server block and adds firewall rules if needed.
45+
*
46+
* @return bool true on success or if port is already open
47+
*/
48+
public function openPort(): bool
49+
{
50+
if ($this->isAlreadyOpen()) {
51+
return true;
52+
}
53+
54+
$lockDir = dirname(self::LOCK_FILE);
55+
Util::mwMkdir($lockDir);
56+
$lockData = json_encode(['pid' => getmypid(), 'time' => time()]);
57+
file_put_contents(self::LOCK_FILE, $lockData);
58+
59+
$domainName = $this->getDomainName();
60+
if (empty($domainName)) {
61+
unlink(self::LOCK_FILE);
62+
return false;
63+
}
64+
65+
$this->createNginxConf($domainName);
66+
$this->reloadNginx();
67+
$this->addFirewallRules();
68+
69+
return true;
70+
}
71+
72+
/**
73+
* Closes port 80 after ACME validation completes.
74+
*
75+
* Removes the nginx config, reloads nginx, removes firewall rules, and cleans up the lock file.
76+
*/
77+
public function closePort(): void
78+
{
79+
if (file_exists(self::NGINX_ACME_CONF)) {
80+
unlink(self::NGINX_ACME_CONF);
81+
}
82+
$this->reloadNginx();
83+
$this->removeFirewallRules();
84+
85+
if (file_exists(self::LOCK_FILE)) {
86+
unlink(self::LOCK_FILE);
87+
}
88+
}
89+
90+
/**
91+
* Cleans up stale port 80 state from a previous crash or timeout.
92+
*/
93+
public static function cleanupStale(): void
94+
{
95+
if (!file_exists(self::LOCK_FILE)) {
96+
return;
97+
}
98+
99+
$lockContent = file_get_contents(self::LOCK_FILE);
100+
$lockData = json_decode($lockContent, true);
101+
if (!is_array($lockData)) {
102+
// Corrupted lock file, clean up
103+
$instance = new self();
104+
$instance->closePort();
105+
Util::sysLogMsg(__CLASS__, 'Cleaned up corrupted ACME port 80 lock file');
106+
return;
107+
}
108+
109+
$pid = $lockData['pid'] ?? 0;
110+
$lockTime = $lockData['time'] ?? 0;
111+
$elapsed = time() - $lockTime;
112+
$pidDead = ($pid > 0) ? !file_exists("/proc/$pid") : true;
113+
114+
if ($elapsed > self::MAX_OPEN_SECONDS || $pidDead) {
115+
$instance = new self();
116+
$instance->closePort();
117+
Util::sysLogMsg(
118+
__CLASS__,
119+
"Cleaned up stale ACME port 80 (elapsed: {$elapsed}s, pid: $pid, dead: " . ($pidDead ? 'yes' : 'no') . ')'
120+
);
121+
}
122+
}
123+
124+
/**
125+
* Checks if port 80 is already open by this module.
126+
*/
127+
private function isAlreadyOpen(): bool
128+
{
129+
if (!file_exists(self::LOCK_FILE)) {
130+
return false;
131+
}
132+
133+
$lockContent = file_get_contents(self::LOCK_FILE);
134+
$lockData = json_decode($lockContent, true);
135+
if (!is_array($lockData)) {
136+
return false;
137+
}
138+
139+
$pid = $lockData['pid'] ?? 0;
140+
if ($pid > 0 && file_exists("/proc/$pid")) {
141+
return true;
142+
}
143+
144+
return false;
145+
}
146+
147+
/**
148+
* Gets domain name from module settings.
149+
*/
150+
private function getDomainName(): string
151+
{
152+
$settings = ModuleGetSsl::findFirst();
153+
if ($settings === null) {
154+
return '';
155+
}
156+
return $settings->domainName ?? '';
157+
}
158+
159+
/**
160+
* Creates a dedicated nginx server block for ACME validation on port 80.
161+
*/
162+
private function createNginxConf(string $domainName): void
163+
{
164+
$confDir = dirname(self::NGINX_ACME_CONF);
165+
Util::mwMkdir($confDir);
166+
167+
$conf = <<<NGINX
168+
server {
169+
listen 80;
170+
listen [::]:80;
171+
server_name $domainName;
172+
173+
location /.well-known/acme-challenge/ {
174+
root /usr/www/sites;
175+
allow all;
176+
}
177+
178+
location / {
179+
return 444;
180+
}
181+
}
182+
NGINX;
183+
184+
file_put_contents(self::NGINX_ACME_CONF, $conf);
185+
}
186+
187+
/**
188+
* Reloads nginx configuration.
189+
*/
190+
private function reloadNginx(): void
191+
{
192+
$nginxPath = Util::which('nginx');
193+
Processes::mwExec("$nginxPath -s reload");
194+
}
195+
196+
/**
197+
* Adds iptables rules to allow traffic on port 80.
198+
*/
199+
private function addFirewallRules(): void
200+
{
201+
if (!$this->isFirewallManaged()) {
202+
return;
203+
}
204+
205+
$iptablesPath = Util::which('iptables');
206+
Processes::mwExec("$iptablesPath -I INPUT -p tcp --dport 80 -j ACCEPT");
207+
208+
$ip6tablesPath = Util::which('ip6tables');
209+
Processes::mwExec("$ip6tablesPath -I INPUT -p tcp --dport 80 -j ACCEPT");
210+
}
211+
212+
/**
213+
* Removes iptables rules for port 80.
214+
*/
215+
private function removeFirewallRules(): void
216+
{
217+
if (!$this->isFirewallManaged()) {
218+
return;
219+
}
220+
221+
$iptablesPath = Util::which('iptables');
222+
Processes::mwExec("$iptablesPath -D INPUT -p tcp --dport 80 -j ACCEPT");
223+
224+
$ip6tablesPath = Util::which('ip6tables');
225+
Processes::mwExec("$ip6tablesPath -D INPUT -p tcp --dport 80 -j ACCEPT");
226+
}
227+
228+
/**
229+
* Checks whether firewall rules need to be managed.
230+
*
231+
* Returns true only when PBX firewall is enabled AND system can manage iptables.
232+
*/
233+
private function isFirewallManaged(): bool
234+
{
235+
$firewallEnabled = PbxSettings::getValueByKey(PbxSettings::PBX_FIREWALL_ENABLED);
236+
if ($firewallEnabled !== '1') {
237+
return false;
238+
}
239+
return System::canManageFirewall();
240+
}
241+
}

Lib/GetSslConf.php

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use MikoPBX\Core\System\Util;
2626
use MikoPBX\Modules\Config\ConfigClass;
2727
use MikoPBX\PBXCoreREST\Lib\PBXApiResult;
28+
use Modules\ModuleGetSsl\Lib\AcmeHttpPort;
2829
use Modules\ModuleGetSsl\Models\ModuleGetSsl;
2930

3031
class GetSslConf extends ConfigClass
@@ -65,11 +66,17 @@ public function moduleRestAPICallback(array $request): PBXApiResult
6566
}
6667
switch ($action) {
6768
case 'GET-CERT':
68-
$moduleMain = new GetSslMain($asyncChannelId);
69-
$moduleMain->createAclConf();
70-
$res = $moduleMain->startGetCertSsl();
71-
if (!empty($asyncChannelId)) {
72-
$res = $moduleMain->checkResultAsync();
69+
$portManager = new AcmeHttpPort();
70+
$portManager->openPort();
71+
try {
72+
$moduleMain = new GetSslMain($asyncChannelId);
73+
$moduleMain->createAclConf();
74+
$res = $moduleMain->startGetCertSsl();
75+
if (!empty($asyncChannelId)) {
76+
$res = $moduleMain->checkResultAsync();
77+
}
78+
} finally {
79+
$portManager->closePort();
7380
}
7481
break;
7582
case 'CHECK-RESULT':
@@ -92,6 +99,7 @@ public function moduleRestAPICallback(array $request): PBXApiResult
9299
*/
93100
public function onAfterPbxStarted(): void
94101
{
102+
AcmeHttpPort::cleanupStale();
95103
$moduleMain = new GetSslMain();
96104
$moduleMain->createAclConf();
97105
}
@@ -123,5 +131,9 @@ public function createCronTasks(array &$tasks): void
123131
if (!empty($task)) {
124132
$tasks[] = $task;
125133
}
134+
135+
// Watchdog: check every minute for stale port 80 state
136+
$phpPath = Util::which('php');
137+
$tasks[] = "* * * * * $phpPath -f $this->moduleDir/bin/cleanupPort80.php > /dev/null 2>&1" . PHP_EOL;
126138
}
127139
}

Lib/GetSslMain.php

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -298,13 +298,15 @@ public function startGetCertSsl(bool $asynchronously = true): PBXApiResult
298298
$pid = Processes::getPidOfProcess("$getSsl $extHostname");
299299
if ($pid === '') {
300300
$confDir = $this->dirs['confDir'];
301+
$shPath = Util::which('sh');
302+
$tsWrapper = $this->dirs['binDir'] . '/timestampWrapper.sh';
301303
if($asynchronously){
302-
Processes::mwExecBg("$getSsl $extHostname -w '$confDir'", $this->logFile);
304+
Processes::mwExecBg("$shPath $tsWrapper $getSsl $extHostname -w '$confDir'", $this->logFile);
303305
$pid = Processes::getPidOfProcess("$getSsl $extHostname");
304306
}else{
305307
echo('starting'.PHP_EOL);
306308
passthru("cat '$confDir/getssl.cfg' ");
307-
passthru("$getSsl $extHostname -w '$confDir'", $this->logFile);
309+
passthru("$shPath $tsWrapper $getSsl $extHostname -w '$confDir'", $this->logFile);
308310
}
309311
}
310312
$result->data['result'] = $this->translation->_('module_getssl_GetSSLProcessing');
@@ -400,9 +402,9 @@ public function getCronTask(): string
400402
&& intval($this->module_settings['autoUpdate']) === 1
401403
&& !empty($this->module_settings['domainName'])
402404
) {
403-
$workerPath = $this->dirs['moduleDir']. '/db/getssl';
404-
$getSslPath = $this->dirs['getSslPath'];
405-
return "0 1 1,15 * * $getSslPath -a -U -q -w '$workerPath' > /dev/null 2> /dev/null" . PHP_EOL;
405+
$phpPath = Util::which('php');
406+
$cronScript = $this->dirs['moduleDir'] . '/bin/cronRenewCert.php';
407+
return "0 1 1,15 * * $phpPath -f $cronScript > /dev/null 2>&1" . PHP_EOL;
406408
}
407409
return '';
408410
}

bin/cleanupPort80.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
#!/usr/bin/php
2+
<?php
3+
4+
/*
5+
* MikoPBX - free phone system for small business
6+
* Copyright © 2017-2024 Alexey Portnov and Nikolay Beketov
7+
*
8+
* This program is free software: you can redistribute it and/or modify
9+
* it under the terms of the GNU General Public License as published by
10+
* the Free Software Foundation; either version 3 of the License, or
11+
* (at your option) any later version.
12+
*
13+
* This program is distributed in the hope that it will be useful,
14+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16+
* GNU General Public License for more details.
17+
*
18+
* You should have received a copy of the GNU General Public License along with this program.
19+
* If not, see <https://www.gnu.org/licenses/>.
20+
*/
21+
22+
use Modules\ModuleGetSsl\Lib\AcmeHttpPort;
23+
24+
require_once('Globals.php');
25+
26+
AcmeHttpPort::cleanupStale();

bin/cronRenewCert.php

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#!/usr/bin/php
2+
<?php
3+
4+
/*
5+
* MikoPBX - free phone system for small business
6+
* Copyright © 2017-2024 Alexey Portnov and Nikolay Beketov
7+
*
8+
* This program is free software: you can redistribute it and/or modify
9+
* it under the terms of the GNU General Public License as published by
10+
* the Free Software Foundation; either version 3 of the License, or
11+
* (at your option) any later version.
12+
*
13+
* This program is distributed in the hope that it will be useful,
14+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16+
* GNU General Public License for more details.
17+
*
18+
* You should have received a copy of the GNU General Public License along with this program.
19+
* If not, see <https://www.gnu.org/licenses/>.
20+
*/
21+
22+
use MikoPBX\Core\System\Processes;
23+
use MikoPBX\Core\System\Util;
24+
use Modules\ModuleGetSsl\Lib\AcmeHttpPort;
25+
use Modules\ModuleGetSsl\Lib\GetSslMain;
26+
27+
require_once('Globals.php');
28+
29+
$portManager = new AcmeHttpPort();
30+
$portManager->openPort();
31+
try {
32+
$moduleMain = new GetSslMain();
33+
$moduleMain->createAclConf();
34+
35+
$getSslPath = $moduleMain->dirs['getSslPath'];
36+
$confDir = $moduleMain->dirs['confDir'];
37+
$shPath = Util::which('sh');
38+
$tsWrapper = $moduleMain->dirs['binDir'] . '/timestampWrapper.sh';
39+
Processes::mwExec("$shPath $tsWrapper $getSslPath -a -U -q -w '$confDir'");
40+
41+
$moduleMain->run();
42+
} finally {
43+
$portManager->closePort();
44+
}

bin/timestampWrapper.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#!/bin/sh
2+
# Wraps a command's output with ISO timestamps on each line
3+
"$@" 2>&1 | while IFS= read -r line; do
4+
printf "[%s] %s\n" "$(date '+%Y-%m-%d %H:%M:%S')" "$line"
5+
done

0 commit comments

Comments
 (0)