Skip to content

Commit a59b9a1

Browse files
authored
Merge pull request #11 from mikopbx/develop
Develop
2 parents bb63a6c + f8359c0 commit a59b9a1

23 files changed

+715
-26
lines changed

.github/workflows/build.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
name: Build and Publish
2+
3+
on:
4+
push:
5+
branches:
6+
- master
7+
- develop
8+
workflow_dispatch:
9+
10+
jobs:
11+
build:
12+
uses: mikopbx/.github-workflows/.github/workflows/extension-publish.yml@master
13+
with:
14+
initial_version: "1.18"
15+
secrets: inherit

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
.idea
22
/.idea/
3+
.DS_Store

App/Views/ModuleGetSsl/index.volt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
<div class="field hidden" id="div-result">
1919
<label>{{ t._('module_getssl_getUpdateLogHeader') }}</label>
2020
<div id="user-edit-config" class="application-code"></div>
21+
<a href="{{ url('system-diagnostic/index/') }}#file=ModuleGetSsl%2Flast-result.log" target="_blank">
22+
<i class="external alternate icon"></i>{{ t._('module_getssl_ViewFullLogLink') }}
23+
</a>
2124
</div>
2225

2326
{{ partial("partials/submitbutton",['submitBtnIconClass':'exchange icon', 'submitBtnText': t._('module_getssl_getUpdateSSLButton')]) }}

CLAUDE.md

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Overview
6+
7+
MikoPBX extension module for automated SSL certificate management via Let's Encrypt (ACME v2). Uses the `getssl` bash script as the ACME client, supports 40+ DNS providers for DNS-01 validation, and provides real-time certificate request progress via nchan Pub/Sub or polling fallback.
8+
9+
## Build Commands
10+
11+
### JavaScript Compilation
12+
Source files live in `public/assets/js/src/`, compiled output goes to `public/assets/js/`:
13+
```bash
14+
/Users/nb/PhpstormProjects/mikopbx/MikoPBXUtils/node_modules/.bin/babel \
15+
"public/assets/js/src/module-get-ssl-index.js" \
16+
--out-dir "public/assets/js/" \
17+
--source-maps inline \
18+
--presets airbnb
19+
```
20+
Repeat for each source file (`module-get-ssl-index.js`, `module-get-ssl-status-worker.js`).
21+
22+
### PHP Static Analysis
23+
```bash
24+
phpstan analyse
25+
```
26+
27+
## Architecture
28+
29+
### Module Lifecycle
30+
1. **Installation** (`Setup/PbxExtensionSetup.php`): Creates DB table `m_ModuleGetSsl`, sets defaults, detects domain from internet interface
31+
2. **Runtime** (`Lib/GetSslConf.php`): Registers REST API callbacks, cron tasks, reacts to model changes and PBX lifecycle events
32+
3. **Certificate Request** (`Lib/GetSslMain.php`): Generates getssl config, launches async certificate request, streams progress to browser
33+
4. **Uninstall**: Removes symlinks from `/usr/bin/getssl`, `/usr/share/getssl`, `/usr/www/sites/.well-known`
34+
35+
### Key Classes
36+
37+
- **`Lib/GetSslConf.php`** — Module configuration hook. Handles REST API routing (`GET-CERT`, `CHECK-RESULT`), cron task registration (1st/15th at 01:00), and PBX lifecycle events (`onAfterPbxStarted`, `onAfterModuleEnable`)
38+
- **`Lib/GetSslMain.php`** — Core orchestrator. Manages directories, generates getssl config file, launches certificate requests, monitors process completion (120s timeout), pushes real-time updates via nchan, updates SSL keys in PbxSettings DB
39+
- **`Lib/MikoPBXVersion.php`** — Compatibility layer for Phalcon 4 vs 5 class names. Version cutoff at PBX 2024.2.30
40+
- **`Models/ModuleGetSsl.php`** — Phalcon ORM model for `m_ModuleGetSsl` table (fields: `id`, `domainName`, `autoUpdate`)
41+
- **`App/Controllers/ModuleGetSslController.php`** — Web UI controller: renders form, handles save, triggers certificate request on save
42+
- **`App/Forms/ModuleGetSslForm.php`** — Phalcon form definition with Semantic UI integration
43+
44+
### Frontend (ES6 → Babel → ES5)
45+
46+
- **`public/assets/js/src/module-get-ssl-index.js`** — Form controller: validation, module status toggle, triggers API call to start certificate request, handles async response channel
47+
- **`public/assets/js/src/module-get-ssl-status-worker.js`** — Real-time progress: EventSource (PBX ≥2024.2.30) or polling fallback, Ace editor for log display, 4-stage processing pipeline (STAGE_1–4)
48+
49+
### REST API
50+
51+
- `POST /pbxcore/api/modules/ModuleGetSsl/get-cert` — Start certificate request. Supports async via `X-Async-Response-Channel-Id` header
52+
- `GET /pbxcore/api/modules/ModuleGetSsl/check-result` — Poll log file contents (fallback for older PBX versions)
53+
54+
### Real-time Updates
55+
56+
Pub/Sub channel `module-get-ssl-pub` pushes JSON messages with `moduleUniqueId`, `stage`, `stageDetails`, `pid`. Browser subscribes via EventSource on PBX ≥2024.2.30, falls back to polling `check-result` endpoint on older versions.
57+
58+
### Symlinks Created at Runtime
59+
- `/usr/bin/getssl``{moduleDir}/bin/getssl`
60+
- `/usr/share/getssl``{moduleDir}/bin/utils`
61+
- `/usr/www/sites/.well-known``{moduleDir}/db/getssl/.well-known`
62+
- `/usr/bin/nslookup` → busybox
63+
64+
### Cron Auto-Renewal
65+
Runs `getssl -a -U -q -w {confDir}` on 1st and 15th of each month at 01:00. Cron is reloaded whenever module settings change.
66+
67+
## Phalcon Version Compatibility
68+
69+
Always use `MikoPBXVersion` for version-dependent class imports (Di, Validation, Uniqueness, Text, Logger). PBX versions ≥2024.2.30 use Phalcon 5; older versions use Phalcon 4. Do not hardcode Phalcon namespace paths.
70+
71+
## Internationalization
72+
73+
31 language files in `Messages/`. Translation keys prefixed with `module_getssl_`. English (`en.php`) is the reference file.
74+
75+
## Dependencies
76+
77+
- PHP 7.4+ / 8.0+, Phalcon 4 or 5
78+
- MikoPBX Core framework (`MikoPBX\Common`, `MikoPBX\Core`, `MikoPBX\Modules`, `MikoPBX\AdminCabinet`)
79+
- jQuery, Semantic UI, Ace Editor (from MikoPBX core frontend)
80+
- `getssl` ACME client (`bin/getssl`, embedded 142KB bash script)

Lib/AcmeHttpPort.php

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
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\Directories;
25+
use MikoPBX\Core\System\Processes;
26+
use MikoPBX\Core\System\System;
27+
use MikoPBX\Core\System\Util;
28+
use Modules\ModuleGetSsl\Models\ModuleGetSsl;
29+
30+
/**
31+
* Manages temporary port 80 opening for ACME HTTP-01 validation.
32+
*
33+
* Creates a dedicated nginx server block on port 80 serving only
34+
* /.well-known/acme-challenge/ and adds iptables rules when firewall is active.
35+
*/
36+
class AcmeHttpPort
37+
{
38+
private const LOCK_FILE = '/var/run/custom_modules/ModuleGetSsl/acme_port80.lock';
39+
private const NGINX_ACME_CONF = '/etc/nginx/mikopbx/modules_servers/ModuleGetSsl_acme80.conf';
40+
private const MAX_OPEN_SECONDS = 300;
41+
42+
private string $logFile;
43+
44+
public function __construct()
45+
{
46+
$logDir = Directories::getDir(Directories::CORE_LOGS_DIR);
47+
$this->logFile = "$logDir/ModuleGetSsl/last-result.log";
48+
}
49+
50+
/**
51+
* Opens port 80 for ACME HTTP-01 validation.
52+
*
53+
* Creates a dedicated nginx server block and adds firewall rules if needed.
54+
*
55+
* @return bool true on success or if port is already open
56+
*/
57+
public function openPort(): bool
58+
{
59+
if ($this->isAlreadyOpen()) {
60+
$this->log('Port 80 already open, skipping');
61+
return true;
62+
}
63+
64+
$lockDir = dirname(self::LOCK_FILE);
65+
Util::mwMkdir($lockDir);
66+
$lockData = json_encode(['pid' => getmypid(), 'time' => time()]);
67+
file_put_contents(self::LOCK_FILE, $lockData);
68+
69+
$domainName = $this->getDomainName();
70+
if (empty($domainName)) {
71+
unlink(self::LOCK_FILE);
72+
$this->log('Port 80 open skipped: domain name is empty');
73+
return false;
74+
}
75+
76+
$this->createNginxConf($domainName);
77+
$this->reloadNginx();
78+
79+
$firewallManaged = $this->isFirewallManaged();
80+
if ($firewallManaged) {
81+
$this->addFirewallRules();
82+
$this->log("Port 80 opened for $domainName (nginx + iptables)");
83+
} else {
84+
$this->log("Port 80 opened for $domainName (nginx only, firewall not managed)");
85+
}
86+
87+
return true;
88+
}
89+
90+
/**
91+
* Closes port 80 after ACME validation completes.
92+
*
93+
* Removes the nginx config, reloads nginx, removes firewall rules, and cleans up the lock file.
94+
*/
95+
public function closePort(): void
96+
{
97+
if (file_exists(self::NGINX_ACME_CONF)) {
98+
unlink(self::NGINX_ACME_CONF);
99+
}
100+
$this->reloadNginx();
101+
$this->removeFirewallRules();
102+
103+
if (file_exists(self::LOCK_FILE)) {
104+
unlink(self::LOCK_FILE);
105+
}
106+
107+
$this->log('Port 80 closed');
108+
}
109+
110+
/**
111+
* Cleans up stale port 80 state from a previous crash or timeout.
112+
*/
113+
public static function cleanupStale(): void
114+
{
115+
if (!file_exists(self::LOCK_FILE)) {
116+
return;
117+
}
118+
119+
$lockContent = file_get_contents(self::LOCK_FILE);
120+
$lockData = json_decode($lockContent, true);
121+
if (!is_array($lockData)) {
122+
// Corrupted lock file, clean up
123+
$instance = new self();
124+
$instance->closePort();
125+
Util::sysLogMsg(__CLASS__, 'Cleaned up corrupted ACME port 80 lock file');
126+
return;
127+
}
128+
129+
$pid = $lockData['pid'] ?? 0;
130+
$lockTime = $lockData['time'] ?? 0;
131+
$elapsed = time() - $lockTime;
132+
$pidDead = ($pid > 0) ? !file_exists("/proc/$pid") : true;
133+
134+
if ($elapsed > self::MAX_OPEN_SECONDS || $pidDead) {
135+
$instance = new self();
136+
$instance->closePort();
137+
Util::sysLogMsg(
138+
__CLASS__,
139+
"Cleaned up stale ACME port 80 (elapsed: {$elapsed}s, pid: $pid, dead: " . ($pidDead ? 'yes' : 'no') . ')'
140+
);
141+
}
142+
}
143+
144+
/**
145+
* Checks if port 80 is already open by this module.
146+
*/
147+
private function isAlreadyOpen(): bool
148+
{
149+
if (!file_exists(self::LOCK_FILE)) {
150+
return false;
151+
}
152+
153+
$lockContent = file_get_contents(self::LOCK_FILE);
154+
$lockData = json_decode($lockContent, true);
155+
if (!is_array($lockData)) {
156+
return false;
157+
}
158+
159+
$pid = $lockData['pid'] ?? 0;
160+
if ($pid > 0 && file_exists("/proc/$pid")) {
161+
return true;
162+
}
163+
164+
return false;
165+
}
166+
167+
/**
168+
* Gets domain name from module settings.
169+
*/
170+
private function getDomainName(): string
171+
{
172+
$settings = ModuleGetSsl::findFirst();
173+
if ($settings === null) {
174+
return '';
175+
}
176+
return $settings->domainName ?? '';
177+
}
178+
179+
/**
180+
* Creates a dedicated nginx server block for ACME validation on port 80.
181+
*/
182+
private function createNginxConf(string $domainName): void
183+
{
184+
$confDir = dirname(self::NGINX_ACME_CONF);
185+
Util::mwMkdir($confDir);
186+
187+
$conf = <<<NGINX
188+
server {
189+
listen 80;
190+
listen [::]:80;
191+
server_name $domainName;
192+
193+
location /.well-known/acme-challenge/ {
194+
root /usr/www/sites;
195+
allow all;
196+
}
197+
198+
location / {
199+
return 444;
200+
}
201+
}
202+
NGINX;
203+
204+
file_put_contents(self::NGINX_ACME_CONF, $conf);
205+
}
206+
207+
/**
208+
* Reloads nginx configuration.
209+
*/
210+
private function reloadNginx(): void
211+
{
212+
$nginxPath = Util::which('nginx');
213+
Processes::mwExec("$nginxPath -s reload");
214+
}
215+
216+
/**
217+
* Adds iptables rules to allow traffic on port 80.
218+
*/
219+
private function addFirewallRules(): void
220+
{
221+
if (!$this->isFirewallManaged()) {
222+
return;
223+
}
224+
225+
$iptablesPath = Util::which('iptables');
226+
Processes::mwExec("$iptablesPath -I INPUT -p tcp --dport 80 -j ACCEPT");
227+
228+
$ip6tablesPath = Util::which('ip6tables');
229+
Processes::mwExec("$ip6tablesPath -I INPUT -p tcp --dport 80 -j ACCEPT");
230+
}
231+
232+
/**
233+
* Removes iptables rules for port 80.
234+
*/
235+
private function removeFirewallRules(): void
236+
{
237+
if (!$this->isFirewallManaged()) {
238+
return;
239+
}
240+
241+
$iptablesPath = Util::which('iptables');
242+
Processes::mwExec("$iptablesPath -D INPUT -p tcp --dport 80 -j ACCEPT");
243+
244+
$ip6tablesPath = Util::which('ip6tables');
245+
Processes::mwExec("$ip6tablesPath -D INPUT -p tcp --dport 80 -j ACCEPT");
246+
}
247+
248+
/**
249+
* Checks whether firewall rules need to be managed.
250+
*
251+
* Returns true only when PBX firewall is enabled AND system can manage iptables.
252+
*/
253+
private function isFirewallManaged(): bool
254+
{
255+
$firewallEnabled = PbxSettings::getValueByKey(PbxSettings::PBX_FIREWALL_ENABLED);
256+
if ($firewallEnabled !== '1') {
257+
return false;
258+
}
259+
return System::canManageFirewall();
260+
}
261+
262+
/**
263+
* Appends a timestamped message to the module log file.
264+
*/
265+
private function log(string $message): void
266+
{
267+
$timestamp = date('Y-m-d H:i:s');
268+
file_put_contents($this->logFile, "[$timestamp] $message" . PHP_EOL, FILE_APPEND);
269+
}
270+
}

0 commit comments

Comments
 (0)