Add OPNsense certificate activation plugin

This commit is contained in:
ms2k 2026-10-19 01:03:09 +09:00
commit c685229437
5 changed files with 559 additions and 0 deletions

View file

@ -0,0 +1,5 @@
#!/bin/sh
if ! output=$(/usr/local/opnsense/scripts/OPNsense/WebGuiCertificate/transaction.php recover 2>&1); then
logger -t opnsense-certificate-activator "$output"
fi

View file

@ -0,0 +1,81 @@
<?php
namespace OPNsense\WebGuiCertificate\Api;
use OPNsense\Base\ApiControllerBase;
use OPNsense\Core\Backend;
class ServiceController extends ApiControllerBase
{
private const REFERENCE_PATTERN = '/^[0-9a-f]{13}$/D';
private const TRANSACTION_PATTERN = '/^[0-9a-f]{32}$/D';
public function statusAction()
{
if (!$this->request->isGet()) {
return ['status' => 'failed'];
}
return $this->run('webguicertificate status');
}
public function activateAction()
{
if (!$this->request->isPost()) {
return ['status' => 'failed'];
}
$reference = (string)$this->request->getPost('refid', null, '');
if (!preg_match(self::REFERENCE_PATTERN, $reference)) {
return ['status' => 'failed', 'message' => 'invalid certificate reference'];
}
$backend = new Backend();
$transaction = bin2hex(random_bytes(16));
$prepared = $this->decode($backend->configdpRun('webguicertificate prepare', [$reference, $transaction]));
if (($prepared['status'] ?? '') === 'active') {
return $prepared;
}
if (($prepared['status'] ?? '') !== 'prepared') {
return $prepared;
}
$backend->configdpRun('webguicertificate watchdog', [$transaction], true);
$deadline = microtime(true) + 5;
do {
usleep(100000);
$status = $this->decode($backend->configdpRun('webguicertificate status'));
if (($status['phase'] ?? '') === 'armed' && ($status['transaction'] ?? '') === $transaction) {
return $this->decode($backend->configdpRun('webguicertificate activate', [$transaction]));
}
} while (microtime(true) < $deadline);
$backend->configdpRun('webguicertificate cancel', [$transaction]);
return ['status' => 'failed', 'message' => 'transaction watchdog did not arm'];
}
public function confirmAction()
{
if (!$this->request->isPost()) {
return ['status' => 'failed'];
}
$transaction = (string)$this->request->getPost('transaction', null, '');
if (!preg_match(self::TRANSACTION_PATTERN, $transaction)) {
return ['status' => 'failed', 'message' => 'invalid transaction'];
}
return $this->run('webguicertificate confirm', [$transaction]);
}
private function run($action, $parameters = [])
{
return $this->decode((new Backend())->configdpRun($action, $parameters));
}
private function decode($payload)
{
$result = json_decode(trim($payload), true);
return is_array($result) ? $result : ['status' => 'failed'];
}
}

View file

@ -0,0 +1,10 @@
<acl>
<page-webgui-certificate-activation>
<name>System: WebGUI certificate activation</name>
<patterns>
<pattern>api/webguicertificate/service/status</pattern>
<pattern>api/webguicertificate/service/activate</pattern>
<pattern>api/webguicertificate/service/confirm</pattern>
</patterns>
</page-webgui-certificate-activation>
</acl>

View file

@ -0,0 +1,416 @@
#!/usr/local/bin/php
<?php
require_once('script/load_phalcon.php');
use OPNsense\Core\Backend;
use OPNsense\Core\Config;
const MINIMUM_REMAINING_VALIDITY = 604800;
const ARM_TTL = 15;
const TRANSACTION_TTL = 120;
const STATE_VERSION = 1;
const STATE_DIRECTORY = '/var/db/opnsense-certificate-activator';
const STATE_FILE = STATE_DIRECTORY . '/transaction.json';
const LOCK_FILE = STATE_DIRECTORY . '/transaction.lock';
function result($status, $values = [])
{
echo json_encode(array_merge(['status' => $status], $values), JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL;
}
function openLock()
{
if (!is_dir(STATE_DIRECTORY) && !mkdir(STATE_DIRECTORY, 0700, true) && !is_dir(STATE_DIRECTORY)) {
throw new RuntimeException('unable to create state directory');
}
$lock = fopen(LOCK_FILE, 'c');
if ($lock === false || !flock($lock, LOCK_EX)) {
throw new RuntimeException('unable to lock transaction state');
}
if (!chmod(LOCK_FILE, 0600)) {
flock($lock, LOCK_UN);
fclose($lock);
throw new RuntimeException('unable to protect transaction lock');
}
return $lock;
}
function closeLock($lock)
{
flock($lock, LOCK_UN);
fclose($lock);
}
function validateState($state)
{
$required = ['version', 'transaction', 'previous_refid', 'candidate_refid', 'phase', 'deadline'];
foreach ($required as $key) {
if (!array_key_exists($key, $state)) {
throw new RuntimeException('invalid transaction state');
}
}
if ($state['version'] !== STATE_VERSION ||
!preg_match('/^[0-9a-f]{32}$/D', (string)$state['transaction']) ||
!preg_match('/^[0-9a-f]{13}$/D', (string)$state['previous_refid']) ||
!preg_match('/^[0-9a-f]{13}$/D', (string)$state['candidate_refid']) ||
!in_array($state['phase'], ['prepared', 'armed', 'activating', 'activated'], true) ||
!is_int($state['deadline'])) {
throw new RuntimeException('invalid transaction state');
}
}
function readState()
{
if (!is_file(STATE_FILE)) {
return null;
}
$encoded = file_get_contents(STATE_FILE);
if ($encoded === false) {
throw new RuntimeException('unable to read transaction state');
}
$state = json_decode($encoded, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($state)) {
throw new RuntimeException('invalid transaction state');
}
validateState($state);
return $state;
}
function writeState($state)
{
validateState($state);
$temporary = STATE_FILE . '.' . bin2hex(random_bytes(8));
$encoded = json_encode($state, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
if (file_put_contents($temporary, $encoded, LOCK_EX) === false) {
throw new RuntimeException('unable to write transaction state');
}
if (!chmod($temporary, 0600)) {
unlink($temporary);
throw new RuntimeException('unable to protect transaction state');
}
if (!rename($temporary, STATE_FILE)) {
@unlink($temporary);
throw new RuntimeException('unable to publish transaction state');
}
}
function removeState()
{
if (is_file(STATE_FILE) && !unlink(STATE_FILE)) {
throw new RuntimeException('unable to remove transaction state');
}
}
function certificateByReference($reference)
{
foreach (Config::getInstance()->object()->cert as $certificate) {
if ((string)$certificate->refid === $reference) {
return $certificate;
}
}
return null;
}
function validateCandidate($reference)
{
$certificate = certificateByReference($reference);
if ($certificate === null) {
throw new InvalidArgumentException('certificate reference does not exist');
}
$leaf = base64_decode((string)$certificate->crt, true);
$privateKey = base64_decode((string)$certificate->prv, true);
if ($leaf === false || $privateKey === false || !openssl_x509_check_private_key($leaf, $privateKey)) {
throw new InvalidArgumentException('certificate and private key do not match');
}
$details = openssl_x509_parse($leaf);
if (!is_array($details)) {
throw new InvalidArgumentException('certificate cannot be parsed');
}
if (($details['validFrom_time_t'] ?? PHP_INT_MAX) > time()) {
throw new InvalidArgumentException('certificate is not valid yet');
}
if (($details['validTo_time_t'] ?? 0) < time() + MINIMUM_REMAINING_VALIDITY) {
throw new InvalidArgumentException('certificate validity is too short');
}
$extensions = $details['extensions'] ?? [];
$purposes = array_map('trim', explode(',', $extensions['extendedKeyUsage'] ?? ''));
if (!in_array('TLS Web Server Authentication', $purposes, true)) {
throw new InvalidArgumentException('certificate lacks server authentication usage');
}
if (isset($extensions['basicConstraints']) && $extensions['basicConstraints'] !== 'CA:FALSE') {
throw new InvalidArgumentException('certificate is not a server leaf');
}
}
function setWebGuiReference($reference, $description)
{
$config = Config::getInstance();
$config->lock();
try {
if (certificateByReference($reference) === null) {
throw new InvalidArgumentException('certificate reference does not exist');
}
$config->object()->system->webgui->{'ssl-certref'} = $reference;
$config->save(['description' => $description]);
} finally {
$config->unlock();
}
}
function currentWebGuiReference()
{
return (string)Config::getInstance()->object()->system->webgui->{'ssl-certref'};
}
function restartWebGui()
{
(new Backend())->configdpRun('webgui restart', ['0'], true);
}
function requireTransaction($state, $transaction)
{
if ($state === null || !hash_equals((string)$state['transaction'], $transaction)) {
throw new InvalidArgumentException('transaction is not pending');
}
}
function prepare($reference, $transaction)
{
if (!preg_match('/^[0-9a-f]{13}$/D', $reference)) {
throw new InvalidArgumentException('invalid certificate reference');
}
if (!preg_match('/^[0-9a-f]{32}$/D', $transaction)) {
throw new InvalidArgumentException('invalid transaction');
}
$lock = openLock();
try {
$existing = readState();
if ($existing !== null) {
if (in_array($existing['phase'], ['prepared', 'armed'], true) && $existing['deadline'] <= time()) {
removeState();
} else {
throw new RuntimeException('another activation is pending');
}
}
validateCandidate($reference);
$previous = currentWebGuiReference();
if ($previous === $reference) {
result('active', ['refid' => $reference]);
return;
}
if (certificateByReference($previous) === null) {
throw new RuntimeException('active certificate reference does not exist');
}
$state = [
'version' => STATE_VERSION,
'transaction' => $transaction,
'previous_refid' => $previous,
'candidate_refid' => $reference,
'phase' => 'prepared',
'deadline' => time() + ARM_TTL,
];
writeState($state);
result('prepared', $state);
} finally {
closeLock($lock);
}
}
function activate($transaction)
{
$lock = openLock();
try {
$state = readState();
requireTransaction($state, $transaction);
if ($state['phase'] !== 'armed' || $state['deadline'] <= time()) {
throw new RuntimeException('transaction watchdog is not armed');
}
$state['phase'] = 'activating';
$state['deadline'] = time() + TRANSACTION_TTL;
writeState($state);
try {
setWebGuiReference($state['candidate_refid'], 'Staged transactional WebGUI certificate activation');
$state['phase'] = 'activated';
writeState($state);
} catch (Throwable $error) {
setWebGuiReference($state['previous_refid'], 'Recovered failed WebGUI certificate activation');
removeState();
throw $error;
}
result('pending', $state);
} finally {
closeLock($lock);
}
}
function confirm($transaction)
{
$lock = openLock();
try {
$state = readState();
requireTransaction($state, $transaction);
if ($state['phase'] !== 'activated') {
throw new RuntimeException('candidate certificate is not active');
}
if ($state['deadline'] <= time()) {
throw new RuntimeException('transaction confirmation deadline expired');
}
if (currentWebGuiReference() !== (string)$state['candidate_refid']) {
throw new RuntimeException('candidate certificate is not configured');
}
removeState();
result('confirmed', ['refid' => $state['candidate_refid']]);
} finally {
closeLock($lock);
}
}
function cancel($transaction)
{
$lock = openLock();
try {
$state = readState();
requireTransaction($state, $transaction);
if (!in_array($state['phase'], ['prepared', 'armed'], true)) {
throw new RuntimeException('active transaction cannot be cancelled');
}
removeState();
result('cancelled');
} finally {
closeLock($lock);
}
}
function rollback($transaction, $restart)
{
$lock = openLock();
try {
$state = readState();
if ($state === null || !hash_equals((string)$state['transaction'], $transaction)) {
return false;
}
if (in_array($state['phase'], ['activating', 'activated'], true)) {
setWebGuiReference($state['previous_refid'], 'Rolled back unconfirmed WebGUI certificate activation');
}
removeState();
} finally {
closeLock($lock);
}
if ($restart) {
restartWebGui();
}
return true;
}
function watchdog($transaction)
{
$lock = openLock();
try {
$state = readState();
requireTransaction($state, $transaction);
if ($state['phase'] !== 'prepared' || $state['deadline'] <= time()) {
throw new RuntimeException('transaction cannot be armed');
}
$state['phase'] = 'armed';
$state['deadline'] = time() + ARM_TTL;
writeState($state);
} finally {
closeLock($lock);
}
$restarted = false;
while (true) {
$lock = openLock();
try {
$state = readState();
if ($state === null || !hash_equals((string)$state['transaction'], $transaction)) {
return;
}
$phase = $state['phase'];
$remaining = $state['deadline'] - time();
} finally {
closeLock($lock);
}
if ($phase === 'activated' && !$restarted) {
restartWebGui();
$restarted = true;
}
if ($remaining <= 0) {
rollback($transaction, in_array($phase, ['activating', 'activated'], true));
return;
}
usleep(200000);
}
}
function status()
{
$lock = openLock();
try {
$state = readState();
if ($state === null) {
result('idle', ['refid' => currentWebGuiReference()]);
} else {
result($state['deadline'] <= time() ? 'expired' : 'pending', $state);
}
} finally {
closeLock($lock);
}
}
function recover()
{
$lock = openLock();
try {
$state = readState();
} finally {
closeLock($lock);
}
if ($state !== null) {
rollback($state['transaction'], false);
}
}
try {
$action = $argv[1] ?? '';
$argument = $argv[2] ?? '';
$secondArgument = $argv[3] ?? '';
switch ($action) {
case 'status':
status();
break;
case 'prepare':
prepare($argument, $secondArgument);
break;
case 'activate':
activate($argument);
break;
case 'confirm':
confirm($argument);
break;
case 'cancel':
cancel($argument);
break;
case 'watchdog':
watchdog($argument);
break;
case 'recover':
recover();
break;
default:
throw new InvalidArgumentException('unknown action');
}
} catch (Throwable $error) {
result('failed', ['message' => $error->getMessage()]);
exit(1);
}

View file

@ -0,0 +1,35 @@
[status]
command:/usr/local/opnsense/scripts/OPNsense/WebGuiCertificate/transaction.php
parameters:status
type:script_output
message:Reading WebGUI certificate activation status
[prepare]
command:/usr/local/opnsense/scripts/OPNsense/WebGuiCertificate/transaction.php
parameters:prepare %s %s
type:script_output
message:Preparing a WebGUI certificate transaction
[activate]
command:/usr/local/opnsense/scripts/OPNsense/WebGuiCertificate/transaction.php
parameters:activate %s
type:script_output
message:Activating a WebGUI certificate transaction
[confirm]
command:/usr/local/opnsense/scripts/OPNsense/WebGuiCertificate/transaction.php
parameters:confirm %s
type:script_output
message:Confirming WebGUI certificate activation
[cancel]
command:/usr/local/opnsense/scripts/OPNsense/WebGuiCertificate/transaction.php
parameters:cancel %s
type:script_output
message:Cancelling a WebGUI certificate transaction
[watchdog]
command:/usr/local/opnsense/scripts/OPNsense/WebGuiCertificate/transaction.php
parameters:watchdog %s
type:script
message:Watching WebGUI certificate activation