Add OPNsense certificate activation plugin
This commit is contained in:
parent
bb55b949a3
commit
c685229437
5 changed files with 559 additions and 0 deletions
5
plugin/src/etc/rc.syshook.d/early/40-opnsense-certificate-activator
Executable file
5
plugin/src/etc/rc.syshook.d/early/40-opnsense-certificate-activator
Executable 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
|
||||
|
|
@ -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'];
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
416
plugin/src/opnsense/scripts/OPNsense/WebGuiCertificate/transaction.php
Executable file
416
plugin/src/opnsense/scripts/OPNsense/WebGuiCertificate/transaction.php
Executable 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);
|
||||
}
|
||||
|
|
@ -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
|
||||
Loading…
Reference in a new issue