From 19188dcf8c54e79497f44aa7afc82f800dc2917d Mon Sep 17 00:00:00 2001 From: Javier Pastor Date: Sun, 21 Jun 2026 09:32:46 +0200 Subject: [PATCH 1/2] feat: Add private ACME server support, installed-CA viewer and web/CLI option to install a CA certificate into the system trust store (via privileged sysadmin hook). --- .gitignore | 1 + Acme/AcmeHttpClient.php | 141 +++++++++++++++++ Certman.class.php | 325 +++++++++++++++++++++++++++++++++++--- Console/Certman.class.php | 32 ++++ assets/js/certman.js | 2 +- hooks/install-ca | 59 +++++++ module.xml | 3 +- views/certgrid.php | 1 + views/le.php | 58 +++++++ views/systemcas.php | 150 ++++++++++++++++++ 10 files changed, 750 insertions(+), 22 deletions(-) create mode 100644 Acme/AcmeHttpClient.php create mode 100755 hooks/install-ca create mode 100644 views/systemcas.php diff --git a/.gitignore b/.gitignore index c2f639a2..22fad12d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ assets/less/cache module.sig +ca-staging diff --git a/Acme/AcmeHttpClient.php b/Acme/AcmeHttpClient.php new file mode 100644 index 00000000..07eb0572 --- /dev/null +++ b/Acme/AcmeHttpClient.php @@ -0,0 +1,141 @@ +/directory, Pebble: /dir, ...). When a + * directory URL is configured we substitute it for that relative request. + * - An optional custom CA bundle (CURLOPT_CAINFO) for servers presenting a + * certificate signed by a private CA. + * - An optional insecure mode that skips TLS verification for self-signed + * setups. Off by default; only used when the operator explicitly opts in. + * + * Keeping this in the module (rather than patching vendor/) means a composer + * update of analogic/lescript will not wipe the customisation. + */ +#[\AllowDynamicProperties] +class AcmeHttpClient implements ClientInterface +{ + private $lastCode; + private $lastHeader; + private $base; + private $directoryUrl; + private $caBundle; + private $insecure; + + /** + * @param string $base Origin (scheme://host[:port]) used for any relative request + * @param string|null $directoryUrl Full ACME directory URL (substituted for lescript's '/directory') + * @param string|null $caBundle Path to a PEM CA bundle that signs the ACME server certificate + * @param bool $insecure Skip TLS verification of the ACME server (self-signed) + */ + public function __construct($base, $directoryUrl = null, $caBundle = null, $insecure = false) + { + $this->base = rtrim((string)$base, '/'); + $this->directoryUrl = $directoryUrl; + $this->caBundle = $caBundle; + $this->insecure = (bool)$insecure; + } + + private function curl($method, $url, $data = null) + { + // lescript always fetches the directory via the relative path '/directory'. + // Private servers expose it on non-standard paths, so honour the explicit URL. + if ($url === '/directory' && !empty($this->directoryUrl)) { + $url = $this->directoryUrl; + } + + $headers = array('Accept: application/json', 'Content-Type: application/jose+json'); + $handle = curl_init(); + curl_setopt($handle, CURLOPT_URL, preg_match('~^http~', $url) ? $url : $this->base.$url); + curl_setopt($handle, CURLOPT_HTTPHEADER, $headers); + curl_setopt($handle, CURLOPT_RETURNTRANSFER, true); + curl_setopt($handle, CURLOPT_HEADER, true); + + if (!empty($this->caBundle)) { + curl_setopt($handle, CURLOPT_CAINFO, $this->caBundle); + } + if ($this->insecure) { + curl_setopt($handle, CURLOPT_SSL_VERIFYHOST, 0); + curl_setopt($handle, CURLOPT_SSL_VERIFYPEER, false); + } + + switch ($method) { + case 'GET': + break; + case 'POST': + curl_setopt($handle, CURLOPT_POST, true); + curl_setopt($handle, CURLOPT_POSTFIELDS, $data); + break; + } + $response = curl_exec($handle); + + if (curl_errno($handle)) { + throw new RuntimeException('Curl: '.curl_error($handle)); + } + + $header_size = curl_getinfo($handle, CURLINFO_HEADER_SIZE); + + $header = substr($response, 0, $header_size); + $body = substr($response, $header_size); + + $this->lastHeader = $header; + $this->lastCode = curl_getinfo($handle, CURLINFO_HTTP_CODE); + + if ($this->lastCode >= 400 && $this->lastCode < 600) { + throw new RuntimeException($this->lastCode."\n".$body); + } + + $data = json_decode($body, true); + return $data === null ? $body : $data; + } + + public function post($url, $data) + { + return $this->curl('POST', $url, $data); + } + + public function get($url) + { + return $this->curl('GET', $url); + } + + public function getLastNonce() + { + if (preg_match('~Replay-Nonce: (.+)~i', $this->lastHeader, $matches)) { + return trim($matches[1]); + } + + throw new RuntimeException("We don't have nonce"); + } + + public function getLastLocation() + { + if (preg_match('~Location: (.+)~i', $this->lastHeader, $matches)) { + return trim($matches[1]); + } + return null; + } + + public function getLastCode() + { + return $this->lastCode; + } + + public function getLastLinks() + { + preg_match_all('~Link: <(.+)>;rel="up"~', $this->lastHeader, $matches); + return $matches[1]; + } +} diff --git a/Certman.class.php b/Certman.class.php index cb5e8389..91cef7df 100644 --- a/Certman.class.php +++ b/Certman.class.php @@ -129,6 +129,22 @@ public function doConfigPageInit($page){ $request = $_REQUEST; $request['certaction'] = !empty($request['certaction']) ? $request['certaction'] : ""; switch($request['certaction']) { + case "installca": + $pem = ''; + if (!empty($_FILES['ca_file']['tmp_name']) && is_uploaded_file($_FILES['ca_file']['tmp_name'])) { + $pem = (string)file_get_contents($_FILES['ca_file']['tmp_name']); + } elseif (!empty($_POST['ca_pem'])) { + $pem = (string)$_POST['ca_pem']; + } + $res = $this->installSystemCA($pem, $_POST['ca_name'] ?? ''); + if (!empty($res['status'])) { + $msg = $res['message']; + if (!empty($res['warning'])) { $msg .= ' ' . $res['warning']; } + $this->message = array('type' => 'success', 'message' => $msg); + } else { + $this->message = array('type' => 'danger', 'message' => $res['message']); + } + break; case "importlocally": $processed = $this->importLocalCertificates(); if(!empty($processed)) { @@ -203,6 +219,9 @@ public function doConfigPageInit($page){ } $removeDstRootCaX3 = ($_POST['removeDstRootCaX3'] ? true : false); + $acmeUrl = !empty($_POST['acme_url']) ? trim($_POST['acme_url']) : ''; + $acmeCaBundle = !empty($_POST['acme_ca']) ? trim($_POST['acme_ca']) : ''; + $acmeInsecure = (!empty($_POST['acme_insecure'])) ? true : false; if(!empty($cert)) { $additional = array( "C" => $_POST['C'], @@ -211,6 +230,11 @@ public function doConfigPageInit($page){ "removeDstRootCaX3" => $removeDstRootCaX3, ); if (!empty($san)) {$additional['san'] = $san;} + if ($acmeUrl !== '') { + $additional['acmeUrl'] = $acmeUrl; + $additional['acmeCaBundle'] = $acmeCaBundle; + $additional['acmeInsecure'] = $acmeInsecure; + } $removeDstRootCaX3 = ($_POST['removeDstRootCaX3'] ? true : false); // check cert expiration $cert = $this->getCertificateDetails($_POST['cid']); @@ -232,7 +256,10 @@ public function doConfigPageInit($page){ "challengetype" => "http", // https will not work. "email" => $_POST['email'], "san" => $san, - "removeDstRootCaX3" => $removeDstRootCaX3 + "removeDstRootCaX3" => $removeDstRootCaX3, + "acmeUrl" => $acmeUrl, + "acmeCaBundle" => $acmeCaBundle, + "acmeInsecure" => $acmeInsecure ), false, true); } catch(Exception $e) { $lelog = trim(ob_get_contents()); @@ -292,6 +319,9 @@ public function doConfigPageInit($page){ $description .= ", " . implode(", ", $san); } $removeDstRootCaX3 = ($_POST['removeDstRootCaX3'] ? true : false); + $acmeUrl = !empty($_POST['acme_url']) ? trim($_POST['acme_url']) : ''; + $acmeCaBundle = !empty($_POST['acme_ca']) ? trim($_POST['acme_ca']) : ''; + $acmeInsecure = (!empty($_POST['acme_insecure'])) ? true : false; $additional = array( "C" => $_POST['C'], "ST" => $_POST['ST'], @@ -299,6 +329,11 @@ public function doConfigPageInit($page){ "removeDstRootCaX3" => $removeDstRootCaX3, ); if (!empty($san)) {$additional['san'] = $san;} + if ($acmeUrl !== '') { + $additional['acmeUrl'] = $acmeUrl; + $additional['acmeCaBundle'] = $acmeCaBundle; + $additional['acmeInsecure'] = $acmeInsecure; + } ob_start(); try{ if($this->checkCertificateName($host)) { @@ -311,6 +346,9 @@ public function doConfigPageInit($page){ "email" => $_POST['email'], "san" => $san, "removeDstRootCaX3" => $removeDstRootCaX3, + "acmeUrl" => $acmeUrl, + "acmeCaBundle" => $acmeCaBundle, + "acmeInsecure" => $acmeInsecure, )); $this->saveCertificate(null, $host, $description, 'le', $additional); } catch(Exception $e) { @@ -538,6 +576,10 @@ public function myShowPage($view=''){ } } break; + case 'systemcas': + $systemcas = $this->getSystemCAs(); + echo load_view(__DIR__.'/views/systemcas.php',array('systemcas' => $systemcas, 'message' => $this->message)); + break; default: $certs = $this->getAllManagedCertificates(); $csr = $this->checkCSRexists(); @@ -621,6 +663,9 @@ public function checkUpdateCertificates($force = false) { "email" => $cert['additional']['email'], "san" => $cert['additional']['san'], "removeDstRootCaX3" => $cert['additional']['removeDstRootCaX3'], + "acmeUrl" => $cert['additional']['acmeUrl'] ?? '', + "acmeCaBundle" => $cert['additional']['acmeCaBundle'] ?? '', + "acmeInsecure" => $cert['additional']['acmeInsecure'] ?? false, ); $this->updateLE($cert['info']['crt']['subject']['CN'], $settings, false, $force); @@ -664,6 +709,9 @@ public function checkUpdateCertificates($force = false) { "email" => $cert['additional']['email'], "san" => $cert['additional']['san'], "removeDstRootCaX3" => $cert['additional']['removeDstRootCaX3'], + "acmeUrl" => $cert['additional']['acmeUrl'] ?? '', + "acmeCaBundle" => $cert['additional']['acmeCaBundle'] ?? '', + "acmeInsecure" => $cert['additional']['acmeInsecure'] ?? false, ); $this->updateLE($cert['info']['crt']['subject']['CN'], $settings, false, $force); @@ -736,6 +784,217 @@ public function getCABundle() { return CaBundle::getSystemCaRootBundlePath(); } + /** + * Render an openssl DN array (subject/issuer) as a readable string. + * @param array $dn + * @return string + */ + private function dnToString($dn) { + $parts = array(); + foreach ((array)$dn as $k => $v) { + if (is_array($v)) { $v = implode('+', $v); } + $parts[] = $k . '=' . $v; + } + return implode(', ', $parts); + } + + /** + * Detect which distribution trust-store family this server uses. + * @return string 'debian', 'rhel', or '' when it can't be determined + */ + private function detectTrustStoreFamily() { + if (is_file('/etc/redhat-release')) { return 'rhel'; } + if (is_file('/etc/debian_version')) { return 'debian'; } + // Fall back to the presence of the trust tooling / anchor directories. + $hasRhel = is_dir('/etc/pki/ca-trust/source/anchors') || (function_exists('fpbx_which') && fpbx_which('update-ca-trust')); + $hasDebian = is_dir('/usr/local/share/ca-certificates') || (function_exists('fpbx_which') && fpbx_which('update-ca-certificates')); + if ($hasRhel && !$hasDebian) { return 'rhel'; } + if ($hasDebian && !$hasRhel) { return 'debian'; } + return ''; // unknown or ambiguous - show everything + } + + /** + * Enumerate the CA certificates trusted by this server. + * + * Reads the active system CA bundle (the one PHP/cURL resolve to) plus the + * common distribution bundle files and custom trust-anchor directories, then + * returns the parsed certificates. This is primarily a helper for operators + * configuring a private/self-hosted ACME server: it lets them confirm the + * ACME server's CA is already trusted and locate a CA bundle path to use in + * the "ACME Server CA Bundle" field. + * + * @return array array('sources' => array(...), 'cas' => array(...)) + */ + public function getSystemCAs() { + $candidates = array(); + + // Whatever PHP/cURL currently resolve to (file or directory). + $primary = $this->getCABundle(); + if (!empty($primary)) { + $candidates[$primary] = _('Active system CA bundle (used by PHP/cURL)'); + } + + // Common distribution bundle files and custom anchor directories, each + // tagged with the distro family it belongs to ('any' = generic). + $known = array( + '/etc/ssl/certs/ca-certificates.crt' => array('label' => _('Debian/Ubuntu system bundle'), 'family' => 'debian'), + '/etc/pki/tls/certs/ca-bundle.crt' => array('label' => _('RHEL/CentOS system bundle'), 'family' => 'rhel'), + '/etc/ssl/cert.pem' => array('label' => _('OpenSSL default bundle'), 'family' => 'any'), + '/usr/local/share/ca-certificates' => array('label' => _('Custom CAs (Debian/Ubuntu anchors)'), 'family' => 'debian'), + '/etc/pki/ca-trust/source/anchors' => array('label' => _('Custom CAs (RHEL/CentOS anchors)'), 'family' => 'rhel'), + ); + // Only list stores that match the detected distro; show everything when unknown. + $family = $this->detectTrustStoreFamily(); + foreach ($known as $path => $meta) { + if (isset($candidates[$path])) { continue; } + if ($family !== '' && $meta['family'] !== 'any' && $meta['family'] !== $family) { continue; } + $candidates[$path] = $meta['label']; + } + + $sources = array(); + $cas = array(); + $seen = array(); // dedupe identical certs that appear in several stores + + foreach ($candidates as $path => $label) { + $files = array(); + if (is_dir($path)) { + foreach ((array)glob(rtrim($path, '/') . '/*') as $f) { + if (is_file($f)) { $files[] = $f; } + } + } elseif (is_file($path)) { + $files[] = $path; + } else { + $sources[] = array('path' => $path, 'label' => $label, 'exists' => false, 'count' => 0); + continue; + } + + $count = 0; + foreach ($files as $f) { + $contents = @file_get_contents($f); + if ($contents === false) { continue; } + foreach ($this->parseCaBundle($contents) as $pem) { + if (strpos($pem, 'BEGIN CERTIFICATE') === false) { continue; } + $info = @openssl_x509_parse($pem); + if (empty($info)) { continue; } + $fp = @openssl_x509_fingerprint($pem, 'sha1'); + if ($fp && isset($seen[$fp])) { continue; } + if ($fp) { $seen[$fp] = true; } + $count++; + $subject = $info['subject'] ?? array(); + $issuer = $info['issuer'] ?? array(); + $cas[] = array( + 'cn' => $subject['CN'] ?? ($subject['O'] ?? ($subject['OU'] ?? _('(unnamed)'))), + 'subject' => $this->dnToString($subject), + 'issuer' => $this->dnToString($issuer), + 'validFrom_time_t' => $info['validFrom_time_t'] ?? 0, + 'validTo_time_t' => $info['validTo_time_t'] ?? 0, + 'selfSigned' => ($subject == $issuer), + 'fingerprint' => $fp ? strtoupper(implode(':', str_split($fp, 2))) : '', + 'source' => $f, + ); + } + } + $sources[] = array('path' => $path, 'label' => $label, 'exists' => true, 'count' => $count); + } + + usort($cas, function ($a, $b) { return strcasecmp($a['cn'], $b['cn']); }); + + return array('sources' => $sources, 'cas' => $cas); + } + + /** + * Install a CA certificate into the system trust store. + * + * Validates the supplied PEM, stages it in the module's ca-staging directory + * and triggers the privileged "install-ca" hook (run as root by the Sysadmin + * incron runner) which copies it into the distribution trust anchors and + * refreshes the trust store. When already running as root (e.g. fwconsole) + * the hook is executed directly. Installation is confirmed by re-scanning the + * trust store for the certificate fingerprint. + * + * @param string $pem PEM encoded CA certificate + * @param string $friendlyName Optional friendly name used for the stored filename + * @return array array('status' => bool, 'message' => string[, 'warning' => string]) + */ + public function installSystemCA($pem, $friendlyName = '') { + $pem = trim((string)$pem); + if ($pem === '') { + return array('status' => false, 'message' => _('No certificate data provided')); + } + + $info = @openssl_x509_parse($pem); + if (empty($info)) { + return array('status' => false, 'message' => _('The supplied data is not a valid PEM certificate')); + } + $fp = @openssl_x509_fingerprint($pem, 'sha1'); + $cn = $info['subject']['CN'] ?? ($info['subject']['O'] ?? 'ca'); + // A trust anchor should be a CA; warn (but don't block) if it is not. + $isCa = !empty($info['extensions']['basicConstraints']) && stripos($info['extensions']['basicConstraints'], 'CA:TRUE') !== false; + + // Build a safe target basename. + $name = $friendlyName !== '' ? $friendlyName : $cn; + $name = preg_replace('/[^A-Za-z0-9._-]+/', '_', (string)$name); + $name = trim($name, '._-'); + if ($name === '') { $name = 'ca'; } + $base = 'certman-' . $name; + + // Stage the PEM where the root hook can read it. + $staging = __DIR__ . '/ca-staging'; + if (!is_dir($staging) && !@mkdir($staging, 0775, true)) { + return array('status' => false, 'message' => sprintf(_('Unable to create staging directory %s'), $staging)); + } + $crtFile = $staging . '/' . $base . '.crt'; + $resultFile = $staging . '/' . $base . '.result'; + @unlink($resultFile); + if (@file_put_contents($crtFile, $pem . "\n") === false) { + return array('status' => false, 'message' => _('Unable to stage the certificate for installation')); + } + + // Run the privileged install. + try { + if (function_exists('posix_geteuid') && posix_geteuid() === 0) { + // Already root (e.g. fwconsole) - run the hook directly. + exec(escapeshellarg(__DIR__ . '/hooks/install-ca') . ' 2>&1'); + } else { + $this->runHook('install-ca'); + // incron is asynchronous; wait for the hook to drop its result file. + $waited = 0; + while (!file_exists($resultFile) && $waited < 20) { + usleep(500000); + $waited++; + } + } + } catch (\Exception $e) { + @unlink($crtFile); + return array('status' => false, 'message' => sprintf(_('Unable to run the privileged install hook: %s. The Sysadmin module is required to install CAs into the system trust store from the web interface.'), $e->getMessage())); + } + + $hookResult = file_exists($resultFile) ? trim((string)@file_get_contents($resultFile)) : ''; + @unlink($resultFile); + @unlink($crtFile); // the hook removes it on success; clean up just in case + + // Confirm the certificate is now present in the trust store. + $installed = false; + if ($fp) { + $target = strtoupper(implode(':', str_split($fp, 2))); + $sys = $this->getSystemCAs(); + foreach ($sys['cas'] as $c) { + if (!empty($c['fingerprint']) && $c['fingerprint'] === $target) { $installed = true; break; } + } + } + + if ($installed) { + $result = array('status' => true, 'message' => sprintf(_('CA "%s" was installed into the system trust store.'), $cn)); + if (!$isCa) { + $result['warning'] = _('Note: this certificate is not marked as a CA (basicConstraints CA:TRUE).'); + } + return $result; + } + + $detail = $hookResult !== '' ? $hookResult : _('the certificate was staged but could not be confirmed in the system trust store. Ensure the Sysadmin module is installed and up to date.'); + return array('status' => false, 'message' => sprintf(_('Could not confirm installation of CA "%s": %s'), $cn, $detail)); + } + /** * Parse CA bundle into an array * @param string $contents the contents of the bundle @@ -801,6 +1060,12 @@ public function updateLE($host, $settings = false, $staging = false, $force = fa $email = !empty($settings['email']) ? $settings['email'] : ''; $san = !empty($settings['san']) ? $settings['san'] : array(); $removeDstRootCaX3 = !empty($settings['removeDstRootCaX3']) ? $settings['removeDstRootCaX3'] : false; + // Custom / private ACME server (self-hosted Let's Encrypt compatible, http-01). + // When acmeUrl is set we point lescript at it instead of the public LE endpoint. + $acmeUrl = !empty($settings['acmeUrl']) ? trim($settings['acmeUrl']) : ''; + $acmeCaBundle = !empty($settings['acmeCaBundle']) ? trim($settings['acmeCaBundle']) : ''; + $acmeInsecure = !empty($settings['acmeInsecure']) ? true : false; + $useCustomAcme = ($acmeUrl !== ''); $location = $this->PKCS->getKeysLocation(); $logger = $this->FreePBX->Logger->monoLog; @@ -879,23 +1144,27 @@ public function updateLE($host, $settings = false, $staging = false, $force = fa //Now check freepbx.org // on failure, save error as hint and continue - try { - $pest = new \PestJSON('http://mirror1.freepbx.org'); - $pest->curl_opts[CURLOPT_FOLLOWLOCATION] = true; - $pest->curl_opts[CURLOPT_CONNECTTIMEOUT] = 10; - $pest->curl_opts[CURLOPT_TIMEOUT] = 30; - $thing = $pest->get('/lechecker.php', array('host' => $host, 'path' => $pathCheck, 'token' => $token, 'type' => $challengetype)); - if(empty($thing)) { - $lecheckerr = _("No valid response from http://mirror1.freepbx.org"); - } elseif(!$thing['status']) { - $lecheckerr = $thing['message']; + // Skipped for a custom/private ACME server: that public reachability + // probe is only meaningful for the public Let's Encrypt service. + if(!$useCustomAcme) { + try { + $pest = new \PestJSON('http://mirror1.freepbx.org'); + $pest->curl_opts[CURLOPT_FOLLOWLOCATION] = true; + $pest->curl_opts[CURLOPT_CONNECTTIMEOUT] = 10; + $pest->curl_opts[CURLOPT_TIMEOUT] = 30; + $thing = $pest->get('/lechecker.php', array('host' => $host, 'path' => $pathCheck, 'token' => $token, 'type' => $challengetype)); + if(empty($thing)) { + $lecheckerr = _("No valid response from http://mirror1.freepbx.org"); + } elseif(!$thing['status']) { + $lecheckerr = $thing['message']; + } + } catch(Exception $e) { + $lecheckerr = _("lechecker: ") . get_class($e) . " - " . trim(strip_tags($e->getMessage())); + } + if ($lecheckerr) { + print($lecheckerr . "\n"); + $hints[] = $lecheckerr; } - } catch(Exception $e) { - $lecheckerr = _("lechecker: ") . get_class($e) . " - " . trim(strip_tags($e->getMessage())); - } - if ($lecheckerr) { - print($lecheckerr . "\n"); - $hints[] = $lecheckerr; } @unlink($webroot.$pathCheck); } @@ -904,9 +1173,25 @@ public function updateLE($host, $settings = false, $staging = false, $force = fa if($needsgen) { $tokenpath = $webroot . "/.well-known/acme-challenge"; $prechallengefiles = glob($tokenpath .'/*'); // */ - $le = new \Analogic\ACME\Lescript($location, $webroot, $logger); - if($staging) { - $le->ca = 'https://acme-staging.api.letsencrypt.org'; + if($useCustomAcme) { + // Private ACME server: inject our own transport so lescript talks + // to the configured directory URL (and trusts a private CA if set). + require_once __DIR__ . '/Acme/AcmeHttpClient.php'; + $origin = preg_replace('~^(https?://[^/]+).*~', '$1', $acmeUrl); + $client = new \FreePBX\modules\Certman\AcmeHttpClient( + $origin, + $acmeUrl, + ($acmeCaBundle !== '' ? $acmeCaBundle : null), + $acmeInsecure + ); + $le = new \Analogic\ACME\Lescript($location, $webroot, $logger, $client); + $le->ca = $acmeUrl; + print(sprintf(_("Using custom ACME server: %s\n"), $acmeUrl)); + } else { + $le = new \Analogic\ACME\Lescript($location, $webroot, $logger); + if($staging) { + $le->ca = 'https://acme-staging.api.letsencrypt.org'; + } } $le->countryCode = $countryCode; $le->state = $state; diff --git a/Console/Certman.class.php b/Console/Certman.class.php index 63856392..f8608cf8 100644 --- a/Console/Certman.class.php +++ b/Console/Certman.class.php @@ -30,6 +30,7 @@ protected function configure(){ new InputOption('updateall', null, InputOption::VALUE_NONE, _('Check and Update all Certificates')), new InputOption('force', null, InputOption::VALUE_NONE, _('Force update, by pass 30 days expiry ')), new InputOption('import', null, InputOption::VALUE_NONE, sprintf(_('Import any unmanaged certificates in %s'),$loc)), + new InputOption('install-ca', null, InputOption::VALUE_REQUIRED, _('Install a CA certificate (path to a PEM file) into the system trust store')), // cert generation options new InputOption('generate', null, InputOption::VALUE_NONE, _('Generate Certificate')), @@ -39,6 +40,9 @@ protected function configure(){ new InputOption('state', null, InputOption::VALUE_REQUIRED, _('State/Provence/Region (LetsEncrypt Generation)')), new InputOption('email', null, InputOption::VALUE_REQUIRED, _("Owner's email (LetsEncrypt Generation)")), new InputOption('san', null, InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, _("Certificate Subject Alternative Name(s) (LetsEncrypt Generation)")), + new InputOption('acme-url', null, InputOption::VALUE_REQUIRED, _('Custom ACME directory URL for a private/self-hosted Let\'s Encrypt server (LetsEncrypt Generation). Empty uses the public service')), + new InputOption('acme-ca', null, InputOption::VALUE_REQUIRED, _('Path to the CA bundle that signed the custom ACME server certificate (LetsEncrypt Generation)')), + new InputOption('acme-insecure', null, InputOption::VALUE_NONE, _('Skip TLS verification of the custom ACME server (LetsEncrypt Generation)')), new InputOption('delete', null, InputOption::VALUE_REQUIRED, _('Delete certificate by id or hostname')), new InputOption('default', null, InputOption::VALUE_REQUIRED, _('Set default certificate by id or hostname')), @@ -50,6 +54,23 @@ protected function execute(InputInterface $input, OutputInterface $output){ $certman = \FreePBX::create()->Certman; $pkcs = \FreePBX::create()->PKCS; + if($installCa = $input->getOption('install-ca')) { + if(!is_file($installCa)) { + $output->writeln("".sprintf(_("File not found: %s"), $installCa).""); + exit(4); + } + $pem = file_get_contents($installCa); + $res = $certman->installSystemCA($pem, basename($installCa)); + if(!empty($res['status'])) { + $msg = $res['message']; + if(!empty($res['warning'])) { $msg .= ' ' . $res['warning']; } + $output->writeln("".$msg.""); + return 0; + } + $output->writeln("".$res['message'].""); + exit(4); + } + if($input->getOption('generate')) { $type = $input->getOption('type'); switch($type) { @@ -66,6 +87,9 @@ protected function execute(InputInterface $input, OutputInterface $output){ $description = $hostname; $san = array_unique(array_filter(array_map(function ($v) {return strtolower(trim($v));}, $input->getOption('san')))); $force = $input->getOption('force'); + $acmeUrl = trim((string)$input->getOption('acme-url')); + $acmeCaBundle = trim((string)$input->getOption('acme-ca')); + $acmeInsecure = (bool)$input->getOption('acme-insecure'); $cert = $certman->getCertificateDetailsByBasename($hostname); if (!($hostname && $country_code && $state && $email)) { @@ -88,6 +112,11 @@ protected function execute(InputInterface $input, OutputInterface $output){ "removeDstRootCaX3" => false, ); if (!empty($san)) {$additional['san'] = $san;} + if ($acmeUrl !== '') { + $additional['acmeUrl'] = $acmeUrl; + $additional['acmeCaBundle'] = $acmeCaBundle; + $additional['acmeInsecure'] = $acmeInsecure; + } if ($force) { $output->writeln("" . _("Forced update enabled !!!") . ""); @@ -110,6 +139,9 @@ protected function execute(InputInterface $input, OutputInterface $output){ "email" => $email, "san" => $san, "removeDstRootCaX3" => false, + "acmeUrl" => $acmeUrl, + "acmeCaBundle" => $acmeCaBundle, + "acmeInsecure" => $acmeInsecure, ); $le_result = $certman->updateLE($hostname, $settings, false, $force); diff --git a/assets/js/certman.js b/assets/js/certman.js index 346a7ef1..4f45b4a6 100644 --- a/assets/js/certman.js +++ b/assets/js/certman.js @@ -112,7 +112,7 @@ $(function() { var stop = false, type = $("#certtype").val(); $("form[name=frm_certman] input[type=\"text\"]").each( function(i, v) { - if($(this).attr("name") == "ST" || $(this).attr("name") == "L" || $(this).attr("name") == "OU") { + if($(this).attr("name") == "ST" || $(this).attr("name") == "L" || $(this).attr("name") == "OU" || $(this).attr("name") == "acme_url" || $(this).attr("name") == "acme_ca") { return true; } if ($(this).val() === "") { diff --git a/hooks/install-ca b/hooks/install-ca new file mode 100755 index 00000000..e162b432 --- /dev/null +++ b/hooks/install-ca @@ -0,0 +1,59 @@ +#!/bin/bash +# Certificate Manager - install CA certificate(s) into the system trust store. +# +# This hook is executed AS ROOT by the Sysadmin incron runner (see runHook()). +# The web UI, running as the unprivileged web user, stages one or more PEM +# certificates as *.crt files in the module's ca-staging directory; this hook +# copies each into the distribution trust anchors and refreshes the trust store. +# For every staged file it writes a .result file the web side reads back, +# then removes the staged input. + +MODDIR="$(cd "$(dirname "$0")/.." && pwd)" +STAGING="$MODDIR/ca-staging" + +[ -d "$STAGING" ] || exit 0 + +# Pick the distribution trust mechanism. +if command -v update-ca-trust >/dev/null 2>&1 && [ -d /etc/pki/ca-trust/source/anchors ]; then + DEST="/etc/pki/ca-trust/source/anchors" + MODE="rhel" +elif command -v update-ca-certificates >/dev/null 2>&1 && [ -d /usr/local/share/ca-certificates ]; then + DEST="/usr/local/share/ca-certificates" + MODE="debian" +else + MODE="none" +fi + +shopt -s nullglob +for f in "$STAGING"/*.crt; do + base="$(basename "${f%.crt}")" + result="${f%.crt}.result" + + if [ "$MODE" = "none" ]; then + echo "ERROR: no supported system trust store found (need update-ca-trust or update-ca-certificates)" > "$result" + rm -f "$f" + continue + fi + + # Make sure it really is a certificate before trusting it. + if ! openssl x509 -in "$f" -noout >/dev/null 2>&1; then + echo "ERROR: not a valid PEM certificate" > "$result" + rm -f "$f" + continue + fi + + if cp "$f" "$DEST/$base.crt" 2>/dev/null; then + chmod 0644 "$DEST/$base.crt" + if [ "$MODE" = "rhel" ]; then + update-ca-trust extract >/dev/null 2>&1 + else + update-ca-certificates >/dev/null 2>&1 + fi + echo "OK: installed as $DEST/$base.crt" > "$result" + else + echo "ERROR: could not write to $DEST" > "$result" + fi + rm -f "$f" +done + +exit 0 diff --git a/module.xml b/module.xml index dfa67080..82038fc5 100644 --- a/module.xml +++ b/module.xml @@ -2,7 +2,7 @@ certman Certificate Manager standard - 16.0.30 + 16.0.31 Sangoma Technologies Corporation AGPLv3+ http://www.gnu.org/licenses/agpl-3.0.txt @@ -14,6 +14,7 @@ https://wiki.freepbx.org/display/FPG/Certificate+Management+Module + *16.0.31* feat: Add private ACME server support, installed-CA viewer and web/CLI option to install a CA certificate into the system trust store (via privileged sysadmin hook). *16.0.30* Packaging of ver 16.0.30 *16.0.29* FREEI-114 certificate update notification change in dashboard *16.0.28* FREEPBX-24228 diff --git a/views/certgrid.php b/views/certgrid.php index 0056256a..ebc8b909 100644 --- a/views/certgrid.php +++ b/views/certgrid.php @@ -20,6 +20,7 @@ + diff --git a/views/le.php b/views/le.php index d7611a65..6b6ef320 100644 --- a/views/le.php +++ b/views/le.php @@ -139,6 +139,64 @@ + + +
+
+
+
+ + +
+
+ +
+
+
+ directory URL (e.g. step-ca: /acme/<provisioner>/directory, Pebble: /dir). Validation still uses the http-01 challenge on port 80.")?> +
+
+
+ +
+
+
+
+ + +
+
+ +
+
+
+ Installed CAs page and leave this field empty; (2) enter here the path to a PEM CA bundle that signs the ACME server certificate; or (3) enable Skip ACME Server TLS Verification below. Leave empty for the public Let's Encrypt service.")?> +
+
+
+ +
+
+
+
+ + +
+
+ > +
+
+
+ +
+
+
+ +
diff --git a/views/systemcas.php b/views/systemcas.php new file mode 100644 index 00000000..b1e934dd --- /dev/null +++ b/views/systemcas.php @@ -0,0 +1,150 @@ + +
+

+ + +
+ + +
+ + +
+ +
+ + +
+ +
+
+ +
+
+ +
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+
+
+ +
+
+ +
+ +

 

+
+ + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + +

 

+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + '._("Expired").''; } + ?> +
+ + From dfd399005931af09a45d6ba8fd8a7e5ffe7a0d04 Mon Sep 17 00:00:00 2001 From: Javier Pastor Date: Sun, 21 Jun 2026 10:38:14 +0200 Subject: [PATCH 2/2] fix: Harden CA install into the system trust store - Escape the certificate CN in result messages (it is rendered as HTML), preventing stored XSS from a crafted certificate subject. - Reject private keys, multi-certificate bundles and oversized input; only a single CA certificate may be installed at a time. - Trust the privileged hook's result: it now checks the exit code of update-ca-trust/update-ca-certificates, rolls back the anchor on failure, and the PHP side reports success only on a real "OK". - Restrict the root hook to the staged file via an explicit argument, wipe stale staged files before each run, and ignore staged files older than 5 minutes (defence in depth). - Include the certificate fingerprint in the stored filename to avoid collisions between different CAs sharing a Common Name. --- Certman.class.php | 47 ++++++++++++++++++++++++++++++++++++----------- hooks/install-ca | 44 ++++++++++++++++++++++++++++++++++++-------- 2 files changed, 72 insertions(+), 19 deletions(-) diff --git a/Certman.class.php b/Certman.class.php index 91cef7df..5dea113d 100644 --- a/Certman.class.php +++ b/Certman.class.php @@ -921,31 +921,51 @@ public function installSystemCA($pem, $friendlyName = '') { if ($pem === '') { return array('status' => false, 'message' => _('No certificate data provided')); } + if (strlen($pem) > 100000) { + return array('status' => false, 'message' => _('The supplied certificate data is too large.')); + } + // Never accept a private key here - we only install public CA certificates. + if (stripos($pem, 'PRIVATE KEY') !== false) { + return array('status' => false, 'message' => _('The supplied data contains a private key; provide only the CA certificate.')); + } + // Exactly one certificate, so the operator sees precisely what is trusted + // and we never trust extra certificates hidden in a bundle. + if (substr_count($pem, '-----BEGIN CERTIFICATE-----') !== 1) { + return array('status' => false, 'message' => _('Please provide exactly one CA certificate in PEM format.')); + } $info = @openssl_x509_parse($pem); if (empty($info)) { return array('status' => false, 'message' => _('The supplied data is not a valid PEM certificate')); } $fp = @openssl_x509_fingerprint($pem, 'sha1'); - $cn = $info['subject']['CN'] ?? ($info['subject']['O'] ?? 'ca'); + $rawCn = $info['subject']['CN'] ?? ($info['subject']['O'] ?? 'ca'); + // The CN comes from an attacker-controlled certificate and is rendered as + // HTML in the web message, so escape it for display. + $cn = htmlspecialchars((string)$rawCn, ENT_QUOTES); // A trust anchor should be a CA; warn (but don't block) if it is not. $isCa = !empty($info['extensions']['basicConstraints']) && stripos($info['extensions']['basicConstraints'], 'CA:TRUE') !== false; - // Build a safe target basename. - $name = $friendlyName !== '' ? $friendlyName : $cn; + // Build a safe, collision-resistant target basename (name + fingerprint). + $name = $friendlyName !== '' ? $friendlyName : $rawCn; $name = preg_replace('/[^A-Za-z0-9._-]+/', '_', (string)$name); $name = trim($name, '._-'); if ($name === '') { $name = 'ca'; } - $base = 'certman-' . $name; + $fpShort = $fp ? substr(preg_replace('/[^a-f0-9]/i', '', $fp), 0, 12) : ''; + $base = 'certman-' . $name . ($fpShort !== '' ? '-' . $fpShort : ''); // Stage the PEM where the root hook can read it. $staging = __DIR__ . '/ca-staging'; if (!is_dir($staging) && !@mkdir($staging, 0775, true)) { return array('status' => false, 'message' => sprintf(_('Unable to create staging directory %s'), $staging)); } + // Clean slate: drop any leftover staged files so the root hook only ever + // processes the certificate we are about to write. + foreach ((array)glob($staging . '/*.{crt,result}', GLOB_BRACE) as $stale) { + @unlink($stale); + } $crtFile = $staging . '/' . $base . '.crt'; $resultFile = $staging . '/' . $base . '.result'; - @unlink($resultFile); if (@file_put_contents($crtFile, $pem . "\n") === false) { return array('status' => false, 'message' => _('Unable to stage the certificate for installation')); } @@ -953,8 +973,8 @@ public function installSystemCA($pem, $friendlyName = '') { // Run the privileged install. try { if (function_exists('posix_geteuid') && posix_geteuid() === 0) { - // Already root (e.g. fwconsole) - run the hook directly. - exec(escapeshellarg(__DIR__ . '/hooks/install-ca') . ' 2>&1'); + // Already root (e.g. fwconsole) - run the hook directly, restricted to our file. + exec(escapeshellarg(__DIR__ . '/hooks/install-ca') . ' ' . escapeshellarg($base . '.crt') . ' 2>&1'); } else { $this->runHook('install-ca'); // incron is asynchronous; wait for the hook to drop its result file. @@ -973,9 +993,14 @@ public function installSystemCA($pem, $friendlyName = '') { @unlink($resultFile); @unlink($crtFile); // the hook removes it on success; clean up just in case - // Confirm the certificate is now present in the trust store. - $installed = false; - if ($fp) { + // The hook is authoritative: it reports "OK" only after update-ca-* actually + // succeeded (and rolls the anchor back otherwise). + $hookOk = ($hookResult !== '' && stripos($hookResult, 'OK') === 0); + + // Only when the hook left no result (e.g. it ran fully out-of-band) do we + // fall back to confirming the certificate is present in the trust store. + $installed = $hookOk; + if (!$installed && $hookResult === '' && $fp) { $target = strtoupper(implode(':', str_split($fp, 2))); $sys = $this->getSystemCAs(); foreach ($sys['cas'] as $c) { @@ -991,7 +1016,7 @@ public function installSystemCA($pem, $friendlyName = '') { return $result; } - $detail = $hookResult !== '' ? $hookResult : _('the certificate was staged but could not be confirmed in the system trust store. Ensure the Sysadmin module is installed and up to date.'); + $detail = $hookResult !== '' ? htmlspecialchars($hookResult, ENT_QUOTES) : _('the certificate was staged but could not be confirmed in the system trust store. Ensure the Sysadmin module is installed and up to date.'); return array('status' => false, 'message' => sprintf(_('Could not confirm installation of CA "%s": %s'), $cn, $detail)); } diff --git a/hooks/install-ca b/hooks/install-ca index e162b432..d3552eeb 100755 --- a/hooks/install-ca +++ b/hooks/install-ca @@ -2,14 +2,18 @@ # Certificate Manager - install CA certificate(s) into the system trust store. # # This hook is executed AS ROOT by the Sysadmin incron runner (see runHook()). -# The web UI, running as the unprivileged web user, stages one or more PEM -# certificates as *.crt files in the module's ca-staging directory; this hook -# copies each into the distribution trust anchors and refreshes the trust store. -# For every staged file it writes a .result file the web side reads back, -# then removes the staged input. +# The web UI, running as the unprivileged web user, stages a PEM certificate as a +# *.crt file in the module's ca-staging directory; this hook copies it into the +# distribution trust anchors and refreshes the trust store. For every staged file +# it writes a .result file the web side reads back, then removes the input. +# +# An optional argument restricts processing to a single staged file (basename +# only); without it, the whole directory is processed but stale files (planted +# and left behind) are ignored as a defence-in-depth measure. MODDIR="$(cd "$(dirname "$0")/.." && pwd)" STAGING="$MODDIR/ca-staging" +TARGET="$1" [ -d "$STAGING" ] || exit 0 @@ -24,11 +28,28 @@ else MODE="none" fi -shopt -s nullglob -for f in "$STAGING"/*.crt; do +# Determine which staged files to process. +if [ -n "$TARGET" ]; then + # basename() ensures the argument can never escape the staging directory. + files=("$STAGING/$(basename "$TARGET")") +else + shopt -s nullglob + files=("$STAGING"/*.crt) +fi + +for f in "${files[@]}"; do + [ -f "$f" ] || continue + [[ "$f" == *.crt ]] || continue base="$(basename "${f%.crt}")" result="${f%.crt}.result" + # Defence in depth: when processing the whole directory, ignore (and remove) + # stale staged files older than 5 minutes that may have been planted. + if [ -z "$TARGET" ] && [ -n "$(find "$f" -mmin +5 2>/dev/null)" ]; then + rm -f "$f" + continue + fi + if [ "$MODE" = "none" ]; then echo "ERROR: no supported system trust store found (need update-ca-trust or update-ca-certificates)" > "$result" rm -f "$f" @@ -49,7 +70,14 @@ for f in "$STAGING"/*.crt; do else update-ca-certificates >/dev/null 2>&1 fi - echo "OK: installed as $DEST/$base.crt" > "$result" + rc=$? + if [ "$rc" -eq 0 ]; then + echo "OK: installed as $DEST/$base.crt" > "$result" + else + # Roll back so we never leave an anchor that the trust store didn't accept. + rm -f "$DEST/$base.crt" + echo "ERROR: trust store update failed (exit $rc)" > "$result" + fi else echo "ERROR: could not write to $DEST" > "$result" fi