diff --git a/www/caddy/Makefile b/www/caddy/Makefile index 852d97abb..56926373f 100644 --- a/www/caddy/Makefile +++ b/www/caddy/Makefile @@ -1,5 +1,5 @@ PLUGIN_NAME= caddy -PLUGIN_VERSION= 1.5.7 +PLUGIN_VERSION= 1.6.0 PLUGIN_DEPENDS= caddy-custom PLUGIN_COMMENT= Easy to configure Reverse Proxy with Automatic HTTPS and Dynamic DNS PLUGIN_MAINTAINER= cedrik@pischem.com diff --git a/www/caddy/pkg-descr b/www/caddy/pkg-descr index 90d1ce1b1..59d58f317 100644 --- a/www/caddy/pkg-descr +++ b/www/caddy/pkg-descr @@ -17,7 +17,6 @@ Main features of this plugin: * Access Lists to restrict access based on static networks * Basic Auth to restrict access by username and password * Syslog-ng integration and HTTP Access Log -* NTLM Transport * Header manipulation with header_up and header_down * Simple load balancing with passive health check @@ -26,6 +25,16 @@ DOC: https://docs.opnsense.org/manual/how-tos/caddy.html Plugin Changelog ================ +1.6.0 + +* Add: New Dashboard widgets for 24.7, showing domain status and certificate validity status. +* Fix: Caddyfile template fixed for IPv6 addresses and custom port. +* Add: forward_auth directive with Authelia as Authz Provider. +* Add: Default HTTP and HTTPS ports can be changed in general settings. +* Add: Introduce HTTP version to handler. HTTP/1.1, HTTP/2 and HTTP/3 can be chosen. +* Change: NTLM is now deprecated, though the option will stay for a while longer. +* Change: Option "tls_trusted_ca_certs" is now "tls_trust_pool". + 1.5.7 * Build: Update to Caddy v2.8.4 + caddy-dns plugins updated to latest upstream versions diff --git a/www/caddy/src/opnsense/mvc/app/controllers/OPNsense/Caddy/Api/DiagnosticsController.php b/www/caddy/src/opnsense/mvc/app/controllers/OPNsense/Caddy/Api/DiagnosticsController.php index d0cc2005b..bda64cb5e 100644 --- a/www/caddy/src/opnsense/mvc/app/controllers/OPNsense/Caddy/Api/DiagnosticsController.php +++ b/www/caddy/src/opnsense/mvc/app/controllers/OPNsense/Caddy/Api/DiagnosticsController.php @@ -82,4 +82,24 @@ class DiagnosticsController extends ApiMutableModelControllerBase // Return the response as an array which gets automatically encoded to JSON return ["status" => "success", "content" => $responseArray['content']]; } + + /** + * Fetch the hostnames, validity and expiration dates of automatic certificates as JSON. Consumed by Caddy widget. + */ + public function certificateAction() + { + $backend = new Backend(); + $response = $backend->configdRun('caddy certificate'); + + // Decode JSON to PHP array + $responseArray = json_decode($response, true); + + // Since errors are handled by the caddy_diagnostics script and returned as json, check for an error key in the response + if (isset($responseArray['error'])) { + return ["status" => "failed", "message" => $responseArray['message']]; + } + + // Return the response as an array which gets automatically encoded to JSON + return ["status" => "success", "content" => $responseArray]; + } } diff --git a/www/caddy/src/opnsense/scripts/OPNsense/Caddy/caddy_diagnostics.py b/www/caddy/src/opnsense/scripts/OPNsense/Caddy/caddy_diagnostics.py index 243a0555d..f42fd6065 100755 --- a/www/caddy/src/opnsense/scripts/OPNsense/Caddy/caddy_diagnostics.py +++ b/www/caddy/src/opnsense/scripts/OPNsense/Caddy/caddy_diagnostics.py @@ -28,6 +28,10 @@ import sys import json +import os +import subprocess +import asyncio +from datetime import datetime # Function to show the Caddy configuration from a JSON file def show_caddy_config(): @@ -61,12 +65,83 @@ def show_caddyfile(): except Exception as e: print(json.dumps({"error": "General Error", "message": str(e)})) +# Function to extract certificate information using openssl command +async def extract_certificate_info(cert_path): + try: + # Execute the openssl command to get the expiration date with a timeout + result = await asyncio.wait_for( + asyncio.create_subprocess_exec( + 'openssl', 'x509', '-in', cert_path, '-noout', '-enddate', + stdout=subprocess.PIPE, stderr=subprocess.PIPE), + timeout=10) # Make sure tasks are cleaned up if they hang + + stdout, stderr = await result.communicate() + + # Check for errors in the execution + if result.returncode != 0: + error_message = stderr.decode().strip() + raise RuntimeError(f"Subprocess failed with error: {error_message}") + + # Decode output and process the information + expiration_date_str = stdout.decode().strip().split('=')[1] + + # Convert expiration date string to datetime object + expiration_date = datetime.strptime(expiration_date_str, "%b %d %H:%M:%S %Y GMT") + + # Determine the current date + now = datetime.now() + + # Calculate remaining days until expiration + remaining_days = (expiration_date - now).days + remaining_days = max(remaining_days, 0) # Ensure non-negative days + + # Extract the hostname from the filename + hostname = os.path.basename(cert_path).replace('.crt', '').lower() + + return {'hostname': hostname, 'expiration_date': expiration_date_str, 'remaining_days': remaining_days} + except asyncio.TimeoutError as e: + # Handle timeout specific errors + raise RuntimeError(f"Timeout occurred while processing {cert_path}: {str(e)}") + except Exception as e: + raise RuntimeError(f"Error extracting certificate info for {cert_path}: {str(e)}") + +# Function to find certificates and create tasks to extract info +async def find_certificates(base_dir): + tasks = [] + for root, dirs, files in os.walk(base_dir): + # Skip any directories named 'temp' + dirs[:] = [d for d in dirs if d != 'temp'] + for file in files: + if file.endswith('.crt'): + cert_path = os.path.join(root, file) + task = asyncio.create_task(extract_certificate_info(cert_path)) + tasks.append(task) + + if not tasks: + raise RuntimeError("No certificates were found in the specified directory.") + + results = await asyncio.gather(*tasks, return_exceptions=True) + return [result for result in results if not isinstance(result, Exception)] + +# Function to show certificates, processing all found in the given directory +async def show_certificates(): + # Function to show certificates, processing all found in the given directory + base_dir = '/var/db/caddy/data/caddy/certificates' + try: + certificates_data = await find_certificates(base_dir) + if certificates_data: + print(json.dumps(certificates_data)) + else: + raise RuntimeError("No valid certificate data found.") + except Exception as e: + print(json.dumps({"error": "General Error", "message": str(e)})) + # Action handler def perform_action(action): actions = { "config": show_caddy_config, - "caddyfile": show_caddyfile - # Additional actions can be added here in the same format. + "caddyfile": show_caddyfile, + "certificate": lambda: asyncio.run(show_certificates()) } action_func = actions.get(action) diff --git a/www/caddy/src/opnsense/service/conf/actions.d/actions_caddy.conf b/www/caddy/src/opnsense/service/conf/actions.d/actions_caddy.conf index 0d768ce42..be0997b93 100644 --- a/www/caddy/src/opnsense/service/conf/actions.d/actions_caddy.conf +++ b/www/caddy/src/opnsense/service/conf/actions.d/actions_caddy.conf @@ -47,3 +47,9 @@ command:/usr/local/opnsense/scripts/OPNsense/Caddy/caddy_diagnostics.py caddyfil parameters: type:script_output message:Request Caddyfile + +[certificate] +command:/usr/local/opnsense/scripts/OPNsense/Caddy/caddy_diagnostics.py certificate +parameters: +type:script_output +message:Check validity of automatic certificates diff --git a/www/caddy/src/opnsense/www/js/widgets/CaddyCertificate.js b/www/caddy/src/opnsense/www/js/widgets/CaddyCertificate.js new file mode 100644 index 000000000..e06511117 --- /dev/null +++ b/www/caddy/src/opnsense/www/js/widgets/CaddyCertificate.js @@ -0,0 +1,121 @@ +/* + * Copyright (C) 2024 Cedrik Pischem + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, + * INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + * AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + * AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, + * OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +import BaseTableWidget from "./BaseTableWidget.js"; + +export default class CaddyCertificate extends BaseTableWidget { + constructor() { + super(); + this.resizeHandles = "e, w"; + this.tickTimeout = 30000; + } + + getGridOptions() { + return { + // trigger overflow-y:scroll after 650px height + sizeToContent: 650 + }; + } + + getMarkup() { + let $container = $('
'); + let $caddyCertificateTable = this.createTable('caddyCertificateTable', { + headerPosition: 'none' + }); + + $container.append($caddyCertificateTable); + return $container; + } + + async onWidgetTick() { + try { + // Check if Caddy is enabled + const caddyStatus = await ajaxGet('/api/caddy/reverse_proxy/get', {}); + if (!caddyStatus.caddy.general || caddyStatus.caddy.general.enabled === "0") { + this.displayError(`${this.translations.unconfigured}`); + return; + } + + // Fetch the certificate details + const response = await ajaxGet('/api/caddy/diagnostics/certificate', {}); + if (response.status !== "success") { + this.displayError(`${this.translations.nocerts}`); + return; + } + + // Process certificates if the response is successful + this.processCertificates(response.content); + } catch (error) { + this.displayError(`${this.translations.error}`); + } + } + + // Utility function to display errors within the widget + displayError(message) { + const $error = $(``); + $('#caddyCertificateTable').empty().append($error); + } + + processCertificates(certificates) { + if (!this.dataChanged('certificates', certificates)) { + return; + } + + let rows = certificates.map(certificate => { + let colorClass = 'text-success'; + if (certificate.remaining_days === 0) { + colorClass = 'text-danger'; + } else if (certificate.remaining_days < 14) { + colorClass = 'text-warning'; + } + + let statusText = certificate.remaining_days === 0 ? this.translations.expired : + this.translations.valid; + + let row = ` +