Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
name: Build and Publish

on:
push:
branches:
- master
- develop
workflow_dispatch:

jobs:
build:
uses: mikopbx/.github-workflows/.github/workflows/extension-publish.yml@master
with:
initial_version: "1.18"
secrets: inherit
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
.idea
/.idea/
.DS_Store
3 changes: 3 additions & 0 deletions App/Views/ModuleGetSsl/index.volt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@
<div class="field hidden" id="div-result">
<label>{{ t._('module_getssl_getUpdateLogHeader') }}</label>
<div id="user-edit-config" class="application-code"></div>
<a href="{{ url('system-diagnostic/index/') }}#file=ModuleGetSsl%2Flast-result.log" target="_blank">
<i class="external alternate icon"></i>{{ t._('module_getssl_ViewFullLogLink') }}
</a>
</div>

{{ partial("partials/submitbutton",['submitBtnIconClass':'exchange icon', 'submitBtnText': t._('module_getssl_getUpdateSSLButton')]) }}
Expand Down
80 changes: 80 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Overview

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.

## Build Commands

### JavaScript Compilation
Source files live in `public/assets/js/src/`, compiled output goes to `public/assets/js/`:
```bash
/Users/nb/PhpstormProjects/mikopbx/MikoPBXUtils/node_modules/.bin/babel \
"public/assets/js/src/module-get-ssl-index.js" \
--out-dir "public/assets/js/" \
--source-maps inline \
--presets airbnb
```
Repeat for each source file (`module-get-ssl-index.js`, `module-get-ssl-status-worker.js`).

### PHP Static Analysis
```bash
phpstan analyse
```

## Architecture

### Module Lifecycle
1. **Installation** (`Setup/PbxExtensionSetup.php`): Creates DB table `m_ModuleGetSsl`, sets defaults, detects domain from internet interface
2. **Runtime** (`Lib/GetSslConf.php`): Registers REST API callbacks, cron tasks, reacts to model changes and PBX lifecycle events
3. **Certificate Request** (`Lib/GetSslMain.php`): Generates getssl config, launches async certificate request, streams progress to browser
4. **Uninstall**: Removes symlinks from `/usr/bin/getssl`, `/usr/share/getssl`, `/usr/www/sites/.well-known`

### Key Classes

- **`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`)
- **`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
- **`Lib/MikoPBXVersion.php`** — Compatibility layer for Phalcon 4 vs 5 class names. Version cutoff at PBX 2024.2.30
- **`Models/ModuleGetSsl.php`** — Phalcon ORM model for `m_ModuleGetSsl` table (fields: `id`, `domainName`, `autoUpdate`)
- **`App/Controllers/ModuleGetSslController.php`** — Web UI controller: renders form, handles save, triggers certificate request on save
- **`App/Forms/ModuleGetSslForm.php`** — Phalcon form definition with Semantic UI integration

### Frontend (ES6 → Babel → ES5)

- **`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
- **`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)

### REST API

- `POST /pbxcore/api/modules/ModuleGetSsl/get-cert` — Start certificate request. Supports async via `X-Async-Response-Channel-Id` header
- `GET /pbxcore/api/modules/ModuleGetSsl/check-result` — Poll log file contents (fallback for older PBX versions)

### Real-time Updates

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.

### Symlinks Created at Runtime
- `/usr/bin/getssl` → `{moduleDir}/bin/getssl`
- `/usr/share/getssl` → `{moduleDir}/bin/utils`
- `/usr/www/sites/.well-known` → `{moduleDir}/db/getssl/.well-known`
- `/usr/bin/nslookup` → busybox

### Cron Auto-Renewal
Runs `getssl -a -U -q -w {confDir}` on 1st and 15th of each month at 01:00. Cron is reloaded whenever module settings change.

## Phalcon Version Compatibility

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.

## Internationalization

31 language files in `Messages/`. Translation keys prefixed with `module_getssl_`. English (`en.php`) is the reference file.

## Dependencies

- PHP 7.4+ / 8.0+, Phalcon 4 or 5
- MikoPBX Core framework (`MikoPBX\Common`, `MikoPBX\Core`, `MikoPBX\Modules`, `MikoPBX\AdminCabinet`)
- jQuery, Semantic UI, Ace Editor (from MikoPBX core frontend)
- `getssl` ACME client (`bin/getssl`, embedded 142KB bash script)
270 changes: 270 additions & 0 deletions Lib/AcmeHttpPort.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
<?php

/*
* MikoPBX - free phone system for small business
* Copyright © 2017-2024 Alexey Portnov and Nikolay Beketov
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with this program.
* If not, see <https://www.gnu.org/licenses/>.
*/

namespace Modules\ModuleGetSsl\Lib;

use MikoPBX\Common\Models\PbxSettings;
use MikoPBX\Core\System\Directories;
use MikoPBX\Core\System\Processes;
use MikoPBX\Core\System\System;
use MikoPBX\Core\System\Util;
use Modules\ModuleGetSsl\Models\ModuleGetSsl;

/**
* Manages temporary port 80 opening for ACME HTTP-01 validation.
*
* Creates a dedicated nginx server block on port 80 serving only
* /.well-known/acme-challenge/ and adds iptables rules when firewall is active.
*/
class AcmeHttpPort
{
private const LOCK_FILE = '/var/run/custom_modules/ModuleGetSsl/acme_port80.lock';
private const NGINX_ACME_CONF = '/etc/nginx/mikopbx/modules_servers/ModuleGetSsl_acme80.conf';
private const MAX_OPEN_SECONDS = 300;

private string $logFile;

public function __construct()
{
$logDir = Directories::getDir(Directories::CORE_LOGS_DIR);
$this->logFile = "$logDir/ModuleGetSsl/last-result.log";
}

/**
* Opens port 80 for ACME HTTP-01 validation.
*
* Creates a dedicated nginx server block and adds firewall rules if needed.
*
* @return bool true on success or if port is already open
*/
public function openPort(): bool
{
if ($this->isAlreadyOpen()) {
$this->log('Port 80 already open, skipping');
return true;
}

$lockDir = dirname(self::LOCK_FILE);
Util::mwMkdir($lockDir);
$lockData = json_encode(['pid' => getmypid(), 'time' => time()]);
file_put_contents(self::LOCK_FILE, $lockData);

$domainName = $this->getDomainName();
if (empty($domainName)) {
unlink(self::LOCK_FILE);
$this->log('Port 80 open skipped: domain name is empty');
return false;
}

$this->createNginxConf($domainName);
$this->reloadNginx();

$firewallManaged = $this->isFirewallManaged();
if ($firewallManaged) {
$this->addFirewallRules();
$this->log("Port 80 opened for $domainName (nginx + iptables)");
} else {
$this->log("Port 80 opened for $domainName (nginx only, firewall not managed)");
}

return true;
}

/**
* Closes port 80 after ACME validation completes.
*
* Removes the nginx config, reloads nginx, removes firewall rules, and cleans up the lock file.
*/
public function closePort(): void
{
if (file_exists(self::NGINX_ACME_CONF)) {
unlink(self::NGINX_ACME_CONF);
}
$this->reloadNginx();
$this->removeFirewallRules();

if (file_exists(self::LOCK_FILE)) {
unlink(self::LOCK_FILE);
}

$this->log('Port 80 closed');
}

/**
* Cleans up stale port 80 state from a previous crash or timeout.
*/
public static function cleanupStale(): void
{
if (!file_exists(self::LOCK_FILE)) {
return;
}

$lockContent = file_get_contents(self::LOCK_FILE);
$lockData = json_decode($lockContent, true);
if (!is_array($lockData)) {
// Corrupted lock file, clean up
$instance = new self();
$instance->closePort();
Util::sysLogMsg(__CLASS__, 'Cleaned up corrupted ACME port 80 lock file');
return;
}

$pid = $lockData['pid'] ?? 0;
$lockTime = $lockData['time'] ?? 0;
$elapsed = time() - $lockTime;
$pidDead = ($pid > 0) ? !file_exists("/proc/$pid") : true;

if ($elapsed > self::MAX_OPEN_SECONDS || $pidDead) {
$instance = new self();
$instance->closePort();
Util::sysLogMsg(
__CLASS__,
"Cleaned up stale ACME port 80 (elapsed: {$elapsed}s, pid: $pid, dead: " . ($pidDead ? 'yes' : 'no') . ')'
);
}
}

/**
* Checks if port 80 is already open by this module.
*/
private function isAlreadyOpen(): bool
{
if (!file_exists(self::LOCK_FILE)) {
return false;
}

$lockContent = file_get_contents(self::LOCK_FILE);
$lockData = json_decode($lockContent, true);
if (!is_array($lockData)) {
return false;
}

$pid = $lockData['pid'] ?? 0;
if ($pid > 0 && file_exists("/proc/$pid")) {
return true;
}

return false;
}

/**
* Gets domain name from module settings.
*/
private function getDomainName(): string
{
$settings = ModuleGetSsl::findFirst();
if ($settings === null) {
return '';
}
return $settings->domainName ?? '';
}

/**
* Creates a dedicated nginx server block for ACME validation on port 80.
*/
private function createNginxConf(string $domainName): void
{
$confDir = dirname(self::NGINX_ACME_CONF);
Util::mwMkdir($confDir);

$conf = <<<NGINX
server {
listen 80;
listen [::]:80;
server_name $domainName;

location /.well-known/acme-challenge/ {
root /usr/www/sites;
allow all;
}

location / {
return 444;
}
}
NGINX;

file_put_contents(self::NGINX_ACME_CONF, $conf);
}

/**
* Reloads nginx configuration.
*/
private function reloadNginx(): void
{
$nginxPath = Util::which('nginx');
Processes::mwExec("$nginxPath -s reload");
}

/**
* Adds iptables rules to allow traffic on port 80.
*/
private function addFirewallRules(): void
{
if (!$this->isFirewallManaged()) {
return;
}

$iptablesPath = Util::which('iptables');
Processes::mwExec("$iptablesPath -I INPUT -p tcp --dport 80 -j ACCEPT");

$ip6tablesPath = Util::which('ip6tables');
Processes::mwExec("$ip6tablesPath -I INPUT -p tcp --dport 80 -j ACCEPT");
}

/**
* Removes iptables rules for port 80.
*/
private function removeFirewallRules(): void
{
if (!$this->isFirewallManaged()) {
return;
}

$iptablesPath = Util::which('iptables');
Processes::mwExec("$iptablesPath -D INPUT -p tcp --dport 80 -j ACCEPT");

$ip6tablesPath = Util::which('ip6tables');
Processes::mwExec("$ip6tablesPath -D INPUT -p tcp --dport 80 -j ACCEPT");
}

/**
* Checks whether firewall rules need to be managed.
*
* Returns true only when PBX firewall is enabled AND system can manage iptables.
*/
private function isFirewallManaged(): bool
{
$firewallEnabled = PbxSettings::getValueByKey(PbxSettings::PBX_FIREWALL_ENABLED);
if ($firewallEnabled !== '1') {
return false;
}
return System::canManageFirewall();
}

/**
* Appends a timestamped message to the module log file.
*/
private function log(string $message): void
{
$timestamp = date('Y-m-d H:i:s');
file_put_contents($this->logFile, "[$timestamp] $message" . PHP_EOL, FILE_APPEND);
}
}
Loading