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
46 changes: 44 additions & 2 deletions App/Controllers/ModuleGetSslController.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use MikoPBX\Common\Models\LanInterfaces;
use Modules\ModuleGetSsl\App\Forms\ModuleGetSslForm;
use Modules\ModuleGetSsl\Lib\DnsProviderRegistry;
use Modules\ModuleGetSsl\Lib\CertificateIdentifierPolicy;
use Modules\ModuleGetSsl\Models\ModuleGetSsl;

class ModuleGetSslController extends BaseController
Expand Down Expand Up @@ -59,18 +60,41 @@ public function indexAction(): void
$headerCollectionCSS = $this->assets->collection(AssetProvider::HEADER_CSS);
$headerCollectionCSS->addCss("css/cache/{$this->moduleUniqueID}/module-get-ssl.css", true);

$internetInterface = LanInterfaces::findFirst("internet = '1'");
$interfaceSettings = $internetInterface !== null ? $internetInterface->toArray() : [];
$settings = ModuleGetSsl::findFirst();
if ($settings === null) {
$settings = new ModuleGetSsl();
$res = LanInterfaces::findFirst("internet = '1'")->toArray();
$settings->domainName = $res['exthostname'] ?? '';
$settings->domainName = $interfaceSettings['exthostname'] ?? '';
}

$resolvedIps = [];
$domainName = trim((string)($settings->domainName ?? ''));
if ($domainName !== '' && !CertificateIdentifierPolicy::isIpAddress($domainName)) {
$resolvedV4 = gethostbynamel($domainName);
if (is_array($resolvedV4)) {
$resolvedIps = array_merge($resolvedIps, $resolvedV4);
}
$resolvedV6 = dns_get_record($domainName, DNS_AAAA);
if (is_array($resolvedV6)) {
foreach ($resolvedV6 as $record) {
if (!empty($record['ipv6'])) {
$resolvedIps[] = $record['ipv6'];
}
}
}
}
$suggestedPublicIp = CertificateIdentifierPolicy::selectSuggestedPublicIp(
(string)($interfaceSettings['extipaddr'] ?? ''),
$resolvedIps
);

$dnsProviderOptions = DnsProviderRegistry::getProviderSelectOptions();
$this->view->form = new ModuleGetSslForm($settings, [
'dnsProviderOptions' => $dnsProviderOptions,
]);
$this->view->dnsProvidersJson = json_encode(DnsProviderRegistry::getProviders());
$this->view->suggestedPublicIpJson = json_encode($suggestedPublicIp);
}

/**
Expand All @@ -92,8 +116,12 @@ public function saveAction(): void
case 'id':
break;
case 'autoUpdate':
case 'includeIpAddress':
$record->$key = ($newVal === 'on') ? '1' : '0';
break;
case 'publicIpAddress':
$record->$key = CertificateIdentifierPolicy::normalizeIpAddress($newVal);
break;
case 'dnsCredentials':
// Store raw base64-encoded JSON as-is from the frontend
$record->$key = $newVal;
Expand All @@ -103,6 +131,20 @@ public function saveAction(): void
}
}

try {
$settings = $record->toArray();
CertificateIdentifierPolicy::getIdentifiers($settings);
if (CertificateIdentifierPolicy::containsIpAddress($settings)) {
$record->challengeType = 'http';
$record->autoUpdate = '1';
}
} catch (\InvalidArgumentException $e) {
$this->flash->error($this->translation->_('module_getssl_PublicIpAddressInvalid'));
$this->view->success = false;
$this->db->rollback();
return;
}

if ($record->save() === false) {
$errors = $record->getMessages();
$this->flash->error(implode('<br>', $errors));
Expand Down
6 changes: 6 additions & 0 deletions App/Forms/ModuleGetSslForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ public function initialize($entity = null, $options = null): void
// DomainName
$this->add(new Text('domainName'));

// Optional additional public IP SAN
$this->addCheckBox('includeIpAddress', intval($entity->includeIpAddress ?? 0) === 1);
$this->add(new Text('publicIpAddress', [
'value' => $entity->publicIpAddress ?? '',
]));

// Challenge type: http or dns
$challengeTypeOptions = [
'http' => 'HTTP-01 (port 80)',
Expand Down
25 changes: 23 additions & 2 deletions App/Views/ModuleGetSsl/index.volt
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
<script>var dnsProvidersMeta = {{ dnsProvidersJson }};</script>
<script>
var dnsProvidersMeta = {{ dnsProvidersJson }};
var suggestedPublicIp = {{ suggestedPublicIpJson }};
</script>

<form class="ui large grey segment form" id="module-get-ssl-form">
{{ form.render('id') }}
Expand All @@ -7,6 +10,24 @@
<div class="field disability">
<label>{{ t._('module_getssl_DomainNameLabel') }}</label>
{{ form.render('domainName') }}
<div class="ui warning message" id="ip-address-certificate-warning" style="display:none">
<p>{{ t._('module_getssl_IpAddressCertificateWarning') }}</p>
</div>
</div>

<div class="field disability" id="include-ip-address-field" style="display:none">
<div class="ui checkbox" id="include-ip-address-checkbox">
{{ form.render('includeIpAddress') }}
<label>{{ t._('module_getssl_IncludeIpAddressLabel') }}</label>
</div>
</div>

<div class="field disability" id="public-ip-address-settings" style="display:none">
<label>{{ t._('module_getssl_PublicIpAddressLabel') }}</label>
{{ form.render('publicIpAddress') }}
<div class="ui warning message">
<p>{{ t._('module_getssl_DomainAndIpCertificateWarning') }}</p>
</div>
</div>

<div class="field disability">
Expand Down Expand Up @@ -37,7 +58,7 @@

<div class="field disability">
<div class="ui segment">
<div class="ui checkbox">
<div class="ui checkbox" id="auto-update-checkbox">
<label>{{ t._('module_getssl_autoUpdateLabel') }}</label>
{{ form.render('autoUpdate') }}
</div>
Expand Down
21 changes: 11 additions & 10 deletions Lib/AcmeHttpPort.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,22 +66,22 @@ public function openPort(): bool
$lockData = json_encode(['pid' => getmypid(), 'time' => time()]);
file_put_contents(self::LOCK_FILE, $lockData);

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

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

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

return true;
Expand Down Expand Up @@ -164,16 +164,17 @@ private function isAlreadyOpen(): bool
return false;
}

/**
* Gets domain name from module settings.
*/
private function getDomainName(): string
private function getServerNames(): string
{
$settings = ModuleGetSsl::findFirst();
if ($settings === null) {
return '';
}
return $settings->domainName ?? '';
try {
return implode(' ', CertificateIdentifierPolicy::getIdentifiers($settings->toArray()));
} catch (\InvalidArgumentException $e) {
return $settings->domainName ?? '';
}
}

/**
Expand Down
155 changes: 155 additions & 0 deletions Lib/CertificateIdentifierPolicy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<?php

namespace Modules\ModuleGetSsl\Lib;

use InvalidArgumentException;

final class CertificateIdentifierPolicy
{
public static function normalizeIpAddress(string $value): string
{
$value = trim($value);
if (strlen($value) >= 2 && $value[0] === '[' && substr($value, -1) === ']') {
return substr($value, 1, -1);
}
return $value;
}

public static function isIpAddress(string $value): bool
{
return filter_var(self::normalizeIpAddress($value), FILTER_VALIDATE_IP) !== false;
}

public static function isPublicIpAddress(string $value): bool
{
$value = self::normalizeIpAddress($value);
$flags = FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE;
if (filter_var($value, FILTER_VALIDATE_IP, $flags) === false) {
return false;
}

foreach (['100.64.0.0/10', '192.0.2.0/24', '198.51.100.0/24', '203.0.113.0/24',
'2001:db8::/32'] as $reservedRange) {
if (self::isInCidr($value, $reservedRange)) {
return false;
}
}
return true;
}

public static function getIdentifiers(array $settings): array
{
$primary = trim((string)($settings['domainName'] ?? ''));
if ($primary === '') {
throw new InvalidArgumentException('Primary certificate identifier is empty');
}
if (self::isIpAddress($primary)) {
$primary = self::normalizeIpAddress($primary);
if (!self::isPublicIpAddress($primary)) {
throw new InvalidArgumentException('The primary IP address must be public');
}
}

$includeIp = (int)($settings['includeIpAddress'] ?? 0) === 1;
if (!$includeIp) {
return [$primary];
}
if (self::isIpAddress($primary)) {
throw new InvalidArgumentException('An IP primary identifier cannot include an additional IP');
}

$publicIp = self::normalizeIpAddress((string)($settings['publicIpAddress'] ?? ''));
if (!self::isPublicIpAddress($publicIp)) {
throw new InvalidArgumentException('The additional IP address must be public');
}
return [$primary, $publicIp];
}

public static function containsIpAddress(array $settings): bool
{
foreach (self::getIdentifiers($settings) as $identifier) {
if (self::isIpAddress($identifier)) {
return true;
}
}
return false;
}

public static function requiresHttp01(array $settings): bool
{
return self::containsIpAddress($settings);
}

public static function requiresAutoUpdate(array $settings): bool
{
return self::containsIpAddress($settings);
}

public static function getCronSchedule(array $settings): string
{
return self::containsIpAddress($settings) ? '17 * * * *' : '0 1 1,15 * *';
}

public static function buildAcmeIdentifierArguments(array $settings): string
{
$arguments = '';
foreach (self::getIdentifiers($settings) as $identifier) {
$arguments .= ' -d ' . escapeshellarg($identifier);
}
if (self::containsIpAddress($settings)) {
// acme.sh otherwise retains its default ~60-day renewal window,
// which is longer than Let's Encrypt short-lived certificates.
// acme.sh subtracts one day from this value, so 5 renews after 4 days.
$arguments .= ' --cert-profile shortlived --days 5';
}
return $arguments;
}

public static function selectSuggestedPublicIp(string $configuredExternalIp, array $resolvedIps): string
{
$configuredExternalIp = self::normalizeIpWithOptionalPort($configuredExternalIp);
if (self::isPublicIpAddress($configuredExternalIp)) {
return $configuredExternalIp;
}
foreach ($resolvedIps as $resolvedIp) {
$resolvedIp = self::normalizeIpAddress((string)$resolvedIp);
if (self::isPublicIpAddress($resolvedIp)) {
return $resolvedIp;
}
}
return '';
}

private static function normalizeIpWithOptionalPort(string $value): string
{
$value = trim($value);
if (preg_match('/^\[([^]]+)](?::\d+)?$/', $value, $matches) === 1) {
return $matches[1];
}
if (substr_count($value, ':') === 1 && preg_match('/^(.+):(\d+)$/', $value, $matches) === 1) {
return $matches[1];
}
return self::normalizeIpAddress($value);
}

private static function isInCidr(string $ipAddress, string $cidr): bool
{
[$network, $prefixLength] = explode('/', $cidr, 2);
$ipBytes = inet_pton($ipAddress);
$networkBytes = inet_pton($network);
if ($ipBytes === false || $networkBytes === false || strlen($ipBytes) !== strlen($networkBytes)) {
return false;
}

$remainingBits = (int)$prefixLength;
for ($index = 0, $length = strlen($ipBytes); $index < $length && $remainingBits > 0; $index++) {
$bits = min(8, $remainingBits);
$mask = (0xff << (8 - $bits)) & 0xff;
if ((ord($ipBytes[$index]) & $mask) !== (ord($networkBytes[$index]) & $mask)) {
return false;
}
$remainingBits -= $bits;
}
return true;
}
}
Loading
Loading