From 4a4f694dfe1b1923c485cbbf250eefca44945729 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 11 Aug 2026 00:51:12 +0000 Subject: [PATCH 1/3] security/acme-client: add automation to upload certificate to JetKVM via SSH Adds a new "Run Command" automation type, "Upload certificate to JetKVM (SSH)" (configd_upload_jetkvm), that deploys a certificate's fullchain and private key to a JetKVM KVM-over-IP device over SSH, plus an optional post-upload command (e.g. to restart/reload a service). The automation reuses the plugin's existing SSH key management (OPNsense\AcmeClient\SSHKeys) and shares its identity/known_hosts store with the existing "Upload certificate via SFTP" and "Remote Command via SSH" automations, matching JetKVM's SSH access model (key-based auth only, enabled via Developer Mode in its web UI). Files are written via a plain SSH exec session (no scp/sftp-server dependency on the device side), since JetKVM's minimal userspace is not guaranteed to include either. Partially validated against a real JetKVM device over SSH (read-only reconnaissance plus writes to throwaway filenames only; the device's real certificate files were deliberately never touched or overwritten): - Corrected the deployed filenames: JetKVM's "Custom" TLS mode reads "user-defined.crt" / "user-defined.key" from /userdata/jetkvm/tls, not "fullchain.pem" / "privkey.pem" as originally guessed. The storage directory itself was confirmed correct. - The cert and key are now staged under temporary filenames in the same directory, chmod'ed, and only "mv"-ed into their final names (an atomic rename) once both are fully written, rather than truncating the live files in place via "cat >". This prevents a dropped SSH connection or a failed write from leaving the device with a truncated or mismatched cert/key pair for its own HTTPS listener. The full staged-write-then-rename sequence was validated end-to-end against the device using throwaway filenames. - Confirmed JetKVM has no hot-reload for a "Custom" certificate: its own certificate-apply script performs a full device reboot. The post-upload command field's help text now says this explicitly; the field itself is still left blank by default since a reboot briefly drops any active KVM-over-IP session. NOT confirmed by this testing, since the device's real certificate files were left untouched: that a certificate written to these paths is actually served after a reboot, and whether JetKVM's "Custom" TLS mode needs to be selected once via the web UI before it will pick up files dropped at this path (plausible given the existing user-defined.crt/.key on the test device were several months stale next to a more recently refreshed default cert pair, suggesting "Custom" mode was not the active mode there). A real end-to-end test (deploy, reboot, verify the served certificate in a browser) is still needed before merging. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017prPdJCJSQvs1DVJqaGVGe --- security/acme-client/Makefile | 2 +- security/acme-client/pkg-descr | 5 + .../AcmeClient/Api/ActionsController.php | 26 + .../AcmeClient/forms/dialogAction.xml | 94 +++ .../LeAutomation/ConfigdUploadJetkvm.php | 45 ++ .../models/OPNsense/AcmeClient/AcmeClient.xml | 69 ++ .../views/OPNsense/AcmeClient/actions.volt | 2 + .../OPNsense/AcmeClient/upload_jetkvm.php | 653 ++++++++++++++++++ .../conf/actions.d/actions_acmeclient.conf | 18 + 9 files changed, 913 insertions(+), 1 deletion(-) create mode 100644 security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/LeAutomation/ConfigdUploadJetkvm.php create mode 100755 security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/upload_jetkvm.php diff --git a/security/acme-client/Makefile b/security/acme-client/Makefile index fc4c3f7f36..0526ebb59b 100644 --- a/security/acme-client/Makefile +++ b/security/acme-client/Makefile @@ -1,5 +1,5 @@ PLUGIN_NAME= acme-client -PLUGIN_VERSION= 4.16 +PLUGIN_VERSION= 4.17 PLUGIN_REVISION= 1 PLUGIN_COMMENT= ACME Client PLUGIN_MAINTAINER= opnsense@moov.de diff --git a/security/acme-client/pkg-descr b/security/acme-client/pkg-descr index 49a81e7f02..6c690eaad1 100644 --- a/security/acme-client/pkg-descr +++ b/security/acme-client/pkg-descr @@ -8,6 +8,11 @@ WWW: https://github.com/acmesh-official/acme.sh Plugin Changelog ================ +4.17 + +Added: +* new automation to upload certificate to JetKVM via SSH (#XXXX) + 4.16 Added: diff --git a/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/Api/ActionsController.php b/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/Api/ActionsController.php index 761f8f511c..31edf2407c 100644 --- a/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/Api/ActionsController.php +++ b/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/Api/ActionsController.php @@ -126,6 +126,32 @@ public function sshTestConnectionAction() return ["status" => "unavailable"]; } + public function jetkvmGetIdentityAction() + { + $result = ["status" => "unavailable"]; + + if ($response = $this->callBackend(["show-jetkvm-identity"], ["jetkvm_identity_type", "jetkvm_host"])) { + $result["status"] = "ok"; + $result["identity"] = $response; + } + + return $result; + } + + public function jetkvmTestConnectionAction() + { + if ( + $response = $this->callBackend( + ["test-jetkvm-connection"], + ["jetkvm_host", "jetkvm_host_key", "jetkvm_port", "jetkvm_user", "jetkvm_identity_type"] + ) + ) { + return $response; + } + + return ["status" => "unavailable"]; + } + private function callBackend(array $command, array $arguments = []) { if ($this->request->isPost()) { diff --git a/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/forms/dialogAction.xml b/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/forms/dialogAction.xml index b03e6f89c7..7ae0d1680a 100644 --- a/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/forms/dialogAction.xml +++ b/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/forms/dialogAction.xml @@ -174,6 +174,100 @@ text The command to execute on the SSH server. + + + header + + + + action.jetkvm_host + + text + IP address or hostname of the JetKVM device. + + + action.jetkvm_port + + text + SSH server port on the JetKVM device. Leave blank to use default "22". + true + + + action.jetkvm_host_key + + text + JetKVM SSH host key, formatted as in 'known_hosts'. + Leave blank to auto accept the host key on first connect (not as secure as specifying it). + + + action.jetkvm_user + + text + The username to login to the JetKVM device via SSH. JetKVM only supports the "root" account for SSH access. Leave blank to use default "root". + + + action.jetkvm_identity_type + + dropdown + The type of identity to present to the JetKVM device for authorization. Select 'none' to use default "ECDSA". + JetKVM only supports key-based SSH authentication (password logins are disabled), so the public key shown by + "Show Identity" must be added to the device's "Developer Mode" SSH key field (Settings > Advanced) before this + automation can connect. + + + action.jetkvm_remote_path + + text + Directory on the JetKVM device that the certificate and private key are copied into. Leave blank to use + default "/userdata/jetkvm/tls", confirmed as the storage location used by JetKVM's "Custom" TLS mode. + This is not part of JetKVM's stable/documented API and may change in a future firmware version, so + re-verify it if uploads stop being picked up after a JetKVM update. + true + + + action.jetkvm_filename_cert + + text + Filename used for the uploaded certificate (fullchain). Leave blank to use default "user-defined.crt", + confirmed as the filename JetKVM's "Custom" TLS mode reads from the remote path above (other filenames + such as "jetkvm.crt" back JetKVM's other, non-custom TLS modes and are not read by "Custom" mode). + true + + + action.jetkvm_filename_key + + text + Filename used for the uploaded private key. Leave blank to use default "user-defined.key", confirmed + as the filename JetKVM's "Custom" TLS mode reads from the remote path above. + true + + + action.jetkvm_chmod_cert + + text + Unix permission to apply to the uploaded certificate file. Leave blank to use default "0644". + true + + + action.jetkvm_chmod_key + + text + Unix permission to apply to the uploaded private key file. Leave blank to use default "0600". + true + + + action.jetkvm_restart_command + + text + Optional command executed on the JetKVM device via the same SSH connection after the certificate and key + have been uploaded. Confirmed against a real device: JetKVM does not hot-reload a "Custom" certificate, + and its own certificate-apply script performs a full device reboot ("reboot") to pick one up, which + will briefly drop any active KVM-over-IP session. This is why the field is left blank by default + instead of defaulting to "reboot" automatically; set it to "reboot" explicitly if you want the new + certificate applied right after upload, or leave it blank to apply/verify manually at a convenient + time. + true + header diff --git a/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/LeAutomation/ConfigdUploadJetkvm.php b/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/LeAutomation/ConfigdUploadJetkvm.php new file mode 100644 index 0000000000..f04af0ba9c --- /dev/null +++ b/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/LeAutomation/ConfigdUploadJetkvm.php @@ -0,0 +1,45 @@ +cert_id . ' ' . $this->config->id; + $this->command = $command; + return true; + } +} diff --git a/security/acme-client/src/opnsense/mvc/app/models/OPNsense/AcmeClient/AcmeClient.xml b/security/acme-client/src/opnsense/mvc/app/models/OPNsense/AcmeClient/AcmeClient.xml index 04b9296164..30b4ec24b6 100644 --- a/security/acme-client/src/opnsense/mvc/app/models/OPNsense/AcmeClient/AcmeClient.xml +++ b/security/acme-client/src/opnsense/mvc/app/models/OPNsense/AcmeClient/AcmeClient.xml @@ -1426,6 +1426,7 @@ Reload Caddy (OPNsense plugin) Upload certificate via SFTP Remote Command via SSH + Upload certificate to JetKVM (SSH) Upload certificate to FRITZ!Box router Upload certificate to Palo Alto Networks Firewall Upload certificate to Proxmox Backup Server @@ -1557,6 +1558,74 @@ /^.{1,1024}$/u Should be a shell command between 1 and 1024 characters. + + N + /^.{1,255}$/u + Should be a string between 1 and 255 characters. + + + N + + /^.+?\s(?:[a-z0-9+\/]{4})*(?:[a-z0-9+\/]{2}==|[a-z0-9+\/]{3}=)?(?:\s.+?)?$/i + Should be a valid public SSH host key (see "known_hosts"). + + + N + 1 + 65535 + 22 + Should be a valid port number between 1 and 65535. + + + N + root + /^.{1,128}$/u + Should be a string between 1 and 128 characters. + + + N + + ECDSA + RSA + ed25519 + + + + N + /userdata/jetkvm/tls + /^.{1,512}$/u + Should be a string between 1 and 512 characters. + + + N + user-defined.crt + /^[\w\d_\-@.]{1,255}$/ui + Should be a plain filename (no path) between 1 and 255 characters. + Characters are limited to [a-z], [0-9] and [@._-]. + + + N + user-defined.key + /^[\w\d_\-@.]{1,255}$/ui + Should be a plain filename (no path) between 1 and 255 characters. + Characters are limited to [a-z], [0-9] and [@._-]. + + + N + /^0[0-9]{3}$/u + A unix permission, 4 digits (e.g. 0644). + + + N + /^0[0-9]{3}$/u + A unix permission, 4 digits (e.g. 0600). + + + N + /^.{0,1024}$/u + Should be a shell command up to 1024 characters. + diff --git a/security/acme-client/src/opnsense/mvc/app/views/OPNsense/AcmeClient/actions.volt b/security/acme-client/src/opnsense/mvc/app/views/OPNsense/AcmeClient/actions.volt index 2b57076f1b..f3368d1eaf 100644 --- a/security/acme-client/src/opnsense/mvc/app/views/OPNsense/AcmeClient/actions.volt +++ b/security/acme-client/src/opnsense/mvc/app/views/OPNsense/AcmeClient/actions.volt @@ -95,6 +95,7 @@ POSSIBILITY OF SUCH DAMAGE. [ {selector: '#action\\.sftp_identity_type', group: "configd_upload_sftp", action: "sftpGetIdentity"}, {selector: '#action\\.remote_ssh_identity_type', group: "configd_remote_ssh", action: "sshGetIdentity"}, + {selector: '#action\\.jetkvm_identity_type', group: "configd_upload_jetkvm", action: "jetkvmGetIdentity"}, ].forEach(function(config) { var $identityType = $(config.selector); var identityDiv = makeStatusDiv($identityType); @@ -126,6 +127,7 @@ POSSIBILITY OF SUCH DAMAGE. [ {selector: '#action\\.sftp_user', group: "configd_upload_sftp", action: "sftpTestConnection", success: "{{ lang._('Connection and upload test succeeded.') }}"}, {selector: '#action\\.remote_ssh_user', group: "configd_remote_ssh", action: "sshTestConnection", success: "{{ lang._('Connection test succeeded.') }}"}, + {selector: '#action\\.jetkvm_user', group: "configd_upload_jetkvm", action: "jetkvmTestConnection", success: "{{ lang._('Connection test succeeded.') }}"}, ].forEach(function(config) { var $user = $(config.selector); diff --git a/security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/upload_jetkvm.php b/security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/upload_jetkvm.php new file mode 100755 index 0000000000..33153d73f3 --- /dev/null +++ b/security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/upload_jetkvm.php @@ -0,0 +1,653 @@ +#!/usr/local/bin/php +, no + password logins) once "Developer Mode" and a SSH public key have been + configured in its web UI (Settings > Advanced). The identity managed by + this plugin can be reused for that purpose; see "show-identity". + + Since JetKVM does not (yet) expose a documented CLI/API to apply a new + TLS certificate without using its web UI, the certificate and key are + written to a configurable directory on the device (defaulting to + "/userdata/jetkvm/tls", JetKVM's documented storage location for its + "Custom" TLS mode at the time of writing) via a plain SSH exec session + (no scp/sftp-server binary is assumed to exist on the device). An + optional post-upload command may be configured to reload/restart + whatever is needed to pick up the new files; this is device/firmware + specific and left blank by default. + + In addition to automations, all operations can also be triggered + manually using simple CLI commands. + + See: EXAMPLES & actions_acmeclient.conf + +TXT; + +// Commands & help +const COMMANDS = [ + "upload" => [ + "description" => "transfers a certificate and key to the specified JetKVM device", + "options" => [ + "host::", "port::", "host-key::", "user::", "identity-type::", "remote-path::", + "certificates::", "cert-name::", "key-name::", "chmod-cert::", "chmod-key::", + "restart-command::"], + "implementation" => "commandUpload", + "default" => true, + ], + + "test-connection" => [ + "description" => "connects to the device and returns results as JSON", + "options" => ["host:", "port::", "host-key::", "user:", "identity-type::"], + "implementation" => "commandTestConnection", + ], + + "show-identity" => [ + "description" => "prints the ssh client identity (publickey)", + "options" => ["identity-type::", "source-ip::", "host::", "unrestricted"], + "implementation" => "commandShowIdentity", + ], +]; + +const EXAMPLES = <<getIdentity($identity_type)) && is_readable($id_file)) { + if ( + !isset($options["unrestricted"]) + && ($restrictions = SSHKeys::getIdentityRestrictions($host, $source_ip, "")) + ) { + echo "$restrictions "; + } + + echo file_get_contents($id_file); + return EXITCODE_SUCCESS; + } else { + LeUtils::log_error("JetKVM failed getting identity. See log output for details."); + } + return EXITCODE_ERROR; +} + +function commandTestConnection(array &$options): int +{ + $result = ["actions" => ["connecting"], "success" => false]; + + $options["run"] = CONNECTION_TEST_COMMAND; + $lines = runOnJetKVM($options, $error); + + if (!$error) { + $result["actions"][] = "connected"; + if (($result["success"] = in_array(CONNECTION_TEST_RESULT, $lines))) { + $result["actions"][] = "echo-tested"; + } + } else { + $result = array_merge($result, ($error ?: [])); + } + + echo json_encode($result, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL; + + return $result["success"] ? EXITCODE_SUCCESS : EXITCODE_ERROR; +} + +function commandUpload(array &$options): int +{ + if (isset($options["certificates"])) { + if (isset($options["host"])) { + return uploadCertificatesToHost($options); + } else { + // Find the actions associated with the given certs. + $tasks = []; + $cert_ids = preg_split('/[,;\s]+/', $options["certificates"] ?: "", 0, PREG_SPLIT_NO_EMPTY); + foreach (findCertificates($cert_ids, false) as $id => $cert) { + foreach ($cert["automations"] as $action_id) { + if (!isset($tasks[$action_id])) { + $tasks[$action_id] = []; + } + $tasks[$action_id][] = $id; + } + } + + $result = 0; + foreach ($tasks as $action_id => $cert_list) { + if (!empty($cert_list) && ($task_options = getOptionsById($action_id, true))) { + $task_options = array_merge($options, $task_options, ["certificates" => join(",", $cert_list)]); + $result = uploadCertificatesToHost($task_options); + if ($result != EXITCODE_SUCCESS) { + break; + } + } + } + + return $result; + } + } else { + LeUtils::log_error("No work to do, '--certificates' is required."); + return EXITCODE_ERROR_NOTHING_TO_UPLOAD; + } +} + +function uploadCertificatesToHost(array $options): int +{ + $cert_ids = preg_split('/[,;\s]+/', $options["certificates"] ?: "", 0, PREG_SPLIT_NO_EMPTY); + $certificates = findCertificates($cert_ids); + + if (empty($certificates)) { + LeUtils::log_error("Could not find any certificates for JetKVM upload (cert-ids: " . (empty($cert_ids) ? "*all*" : join(", ", $cert_ids)) . ")."); + return EXITCODE_ERROR_NOTHING_TO_UPLOAD; + } + + $remote_path = trim(($options["remote-path"] ?? "")) ?: DEFAULT_REMOTE_PATH; + $cert_name = trim(($options["cert-name"] ?? "")) ?: DEFAULT_CERT_NAME; + $key_name = trim(($options["key-name"] ?? "")) ?: DEFAULT_KEY_NAME; + $chmod_cert = trim(($options["chmod-cert"] ?? "")) ?: DEFAULT_CERT_MODE; + $chmod_key = trim(($options["chmod-key"] ?? "")) ?: DEFAULT_KEY_MODE; + $restart_command = trim(($options["restart-command"] ?? "")); + + $result = EXITCODE_SUCCESS; + + // A JetKVM device can only hold a single active TLS certificate, so when + // multiple certificates are routed to the same automation, only one can + // be deployed. Only consider certificates that are actually usable + // (i.e. their content could be resolved from trust storage), then pick + // the most recently updated one among those. + $usable_certificates = array_filter($certificates, function ($item) { + return isset($item["content"]) + && !empty(trim($item["content"]["fullchain"] ?? ($item["content"]["cert"] ?? ""))) + && !empty(trim($item["content"]["key"] ?? "")); + }); + + if (empty($usable_certificates)) { + LeUtils::log_error( + "Ignoring JetKVM upload, none of the matched certificates (" + . join(", ", array_map(fn($c) => $c["name"], $certificates)) + . ") have usable certificate/key content in trust storage." + ); + return EXITCODE_ERROR_NOTHING_TO_UPLOAD; + } + + $cert = array_reduce($usable_certificates, function ($carry, $item) { + return ($carry === null || $item["updated"] > $carry["updated"]) ? $item : $carry; + }, null); + + if (count($certificates) > 1) { + LeUtils::log_debug( + "JetKVM upload received multiple certificates, deploying only the most recently updated (usable) one: " + . $cert["name"] + ); + } + + $cert_content = $cert["content"]["fullchain"] ?? ($cert["content"]["cert"] ?? ""); + $key_content = $cert["content"]["key"] ?? ""; + + if (($script = buildRemoteScript( + $remote_path, + $cert_name, + $cert_content, + $chmod_cert, + $key_name, + $key_content, + $chmod_key, + $restart_command + )) === null) { + LeUtils::log_error("Ignoring JetKVM upload for cert '{$cert["name"]}', remote path or filenames are invalid."); + return EXITCODE_ERROR; + } + + $options["run"] = $script; + runOnJetKVM($options, $error); + + if ($error) { + LeUtils::log_error("JetKVM upload failed for cert '{$cert["name"]}'", $error); + return ($error["connect_failed"] ?? false) ? EXITCODE_ERROR_NO_PERMISSION : EXITCODE_ERROR; + } + + LeUtils::log("JetKVM upload succeeded for cert '{$cert["name"]}' (deployed to {$options["host"]}:{$remote_path})."); + + return $result; +} + +/** + * Builds a POSIX shell script that writes the certificate and key to the + * device and applies the requested permissions, followed by an optional + * restart/reload command. The script is fed to the remote shell via stdin, + * so it never needs to be passed as (length limited/escaped) argv. + * + * Both files are staged under temporary names in the same directory and + * only "mv"-ed into their final names (an atomic rename on the same + * filesystem) once both have been fully written and chmod'ed. This keeps + * a dropped connection or a failed write (e.g. disk full) from ever + * leaving the device with a truncated or mismatched cert/key pair, + * since the existing files are only touched by the two final "mv" calls, + * right next to each other at the end of the script. + */ +function buildRemoteScript( + string $remote_path, + string $cert_filename, + string $cert_content, + string $chmod_cert, + string $key_filename, + string $key_content, + string $chmod_key, + string $restart_command +): ?string { + $remote_path = rtrim(trim($remote_path), '/'); + + // Filenames are always written directly below $remote_path; strip any + // directory components (e.g. "../../etc/passwd") so a crafted filename + // can never escape the configured remote directory. + $cert_filename = basename(trim($cert_filename)); + $key_filename = basename(trim($key_filename)); + + $invalid_filename = fn($name) => empty($name) || $name === '.' || $name === '..'; + + if (empty($remote_path) || $invalid_filename($cert_filename) || $invalid_filename($key_filename)) { + LeUtils::log_error("JetKVM remote path and filenames must not be empty (path='$remote_path', cert='$cert_filename', key='$key_filename')."); + return null; + } + + $cert_target = escapeshellarg($remote_path . '/' . $cert_filename); + $key_target = escapeshellarg($remote_path . '/' . $key_filename); + + // Random, hard to guess markers to delimit heredocs; PEM content will + // never coincidentally match these. The same random suffix also names + // the temporary staging files, so concurrent runs can't collide. + $run_id = bin2hex(random_bytes(16)); + $cert_marker = 'ACME_JETKVM_CERT_' . $run_id; + $key_marker = 'ACME_JETKVM_KEY_' . $run_id; + $cert_tmp_target = escapeshellarg($remote_path . '/.' . $cert_filename . '.tmp.' . $run_id); + $key_tmp_target = escapeshellarg($remote_path . '/.' . $key_filename . '.tmp.' . $run_id); + + $lines = [ + '#!/bin/sh', + 'set -e', + 'umask 077', + 'mkdir -p ' . escapeshellarg($remote_path), + "cat > $cert_tmp_target <<'{$cert_marker}'", + rtrim($cert_content, "\r\n"), + $cert_marker, + 'chmod ' . escapeshellarg($chmod_cert) . " $cert_tmp_target", + "cat > $key_tmp_target <<'{$key_marker}'", + rtrim($key_content, "\r\n"), + $key_marker, + 'chmod ' . escapeshellarg($chmod_key) . " $key_tmp_target", + "mv $cert_tmp_target $cert_target", + "mv $key_tmp_target $key_target", + ]; + + if (trim($restart_command) !== '') { + $lines[] = trim($restart_command); + } + + return join("\n", $lines) . "\n"; +} + +/** + * Connects to the JetKVM device and runs the given shell script/command + * (passed as $options["run"]) via a plain "ssh ... sh" exec session, piping + * the script through stdin. Re-uses the shared identity/known_hosts store. + */ +function runOnJetKVM(array $options, &$error): ?array +{ + static $expected_errors = [ + ["host_not_resolved", /* -> */ '/.*not resolve.*/i'], + ["host_not_trusted", /* -> */ '/.*IDENTIFICATION HAS CHANGED.*/i'], + ["connection_refused", /* -> */ '/.*connection refused.*/i'], + ["connection_closed", /* -> */ '/.*connection closed.*/i'], + ["network_timeout", /* -> */ '/.*timed out.*/i'], + ["network_unreachable", /* -> */ '/.*network.+unreachable.*/i'], + ["permission_denied", /* -> */ '/.*permission denied.*/i'], + ["failure", /* -> */ '/.*(error|failure|you must supply).*/i'], + ]; + + $ssh_keys = new SSHKeys(configPath()); + + $identity_type = trim(($options["identity-type"] ?? "")); + $host = trim(($options["host"] ?? "")); + $host_key = ($options["host-key"] ?? ""); + $port = !empty($options["port"]) ? $options["port"] : SSHKeys::DEFAULT_PORT; + $username = trim(($options["user"] ?? "")) ?: DEFAULT_USER; + $script = $options["run"] ?? ""; + + list($ok, $cmd) = buildSSHArguments($ssh_keys, $host, $username, $identity_type, $host_key, $port); + if (!$ok) { + $error = $cmd; + $error["connect_failed"] = true; + return null; + } + + if (empty($script)) { + $error = ["no_command" => true]; + return null; + } + + // Run "sh" on the remote side and feed it the script via stdin, rather + // than passing it as a single (length limited, quoting-sensitive) + // command-line argument. + $cmd[] = "sh"; + + $result = []; + $exit_code = null; + $expected_error = null; + + if ($process = Process::open($cmd)) { + $process->put($script, ""); + $process->closeInput(); + + $lines = 0; + $start = time(); + $mustClose = fn($lines) => (time() - $start) > CONNECTION_EXECUTE_TIMEOUT || $lines > 10000; + + while ($process->isRunning() && !$mustClose($lines)) { + for (; ($line = $process->get()) !== false && !$mustClose($lines); $lines++) { + if (!$expected_error) { + foreach ($expected_errors as $ee) { + if (preg_match($ee[1], $line)) { + if ($ee[0] !== "connection_closed") { + $expected_error = [$ee[0] => true, "error" => trim($line)]; + } + break; + } + } + } + $result[] = $line; + } + } + $exit_code = $process->close(); + $ok = $exit_code === 0; + } else { + $ok = false; + } + + if (!$ok) { + $cl = join(" ", array_map(fn($v) => escapeshellarg($v), $cmd)); + $error = array_merge(($expected_error ?? []), [ + "result" => $result, + "exit_code" => $exit_code + ]); + $error["connect_failed"] = $exit_code == 255; + LeUtils::log_error("JetKVM SSH failed with '$exit_code': $cl", $error); + } + + return $result; +} + +function buildSSHArguments(SSHKeys $ssh_keys, $host, $username, $identity_type = "", $host_key = "", $port = SSHKeys::DEFAULT_PORT): array +{ + if (empty(trim($host)) || empty(trim($username))) { + LeUtils::log_error("Failed connecting to '$host'. Hostname or username is missing."); + return [false, ["invalid_parameters" => true]]; + } + + if (empty($identity_type)) { + $identity_type = SSHKeys::DEFAULT_IDENTITY_TYPE; + } + + $trust = $ssh_keys->trustHost($host, $host_key, $port); + if ($trust["ok"] !== true) { + LeUtils::log_error("Failed establishing trust in '$host'; Cause: {$trust["error"]}"); + unset($trust["ok"]); + return [false, array_merge($trust, ["host_not_trusted" => true])]; + } else { + $host = $trust["host"]; + } + + // Building ssh command. + $cmd = [ + "ssh", + "-p", $port, + "-oUser=$username", + "-oUserKnownHostsFile={$ssh_keys->knownHostsFile()}", + ]; + + // Handle client side identity + $identity = $ssh_keys->getIdentity($identity_type, true); + if (is_file($identity) && is_readable($identity)) { + array_push( + $cmd, + "-i", + $identity, + "-oPreferredAuthentications=publickey" + ); + } else { + LeUtils::log_error("Failed adding SSH client identity ($identity). Connect will likely fail."); + } + + // Adding the host + $cmd[] = "$host"; + + return [true, $cmd]; +} + +function help() +{ + Utils::printCLIHelp(ABOUT, EXAMPLES, COMMANDS); +} + +function getOptionsById($automation_id, $silent = false) +{ + if (!$silent) { + LeUtils::log_debug("Reading options from automation: $automation_id"); + } + + if (is_object($action = Utils::getAutomationActionById($automation_id))) { + if ($action->enabled && "configd_upload_jetkvm" === (string)$action->type) { + return [ + "host" => trim((string)$action->jetkvm_host), + "host-key" => trim((string)$action->jetkvm_host_key), + "port" => trim((string)$action->jetkvm_port), + "identity-type" => trim((string)$action->jetkvm_identity_type), + "user" => trim((string)$action->jetkvm_user), + "remote-path" => trim((string)$action->jetkvm_remote_path), + "cert-name" => trim((string)$action->jetkvm_filename_cert), + "key-name" => trim((string)$action->jetkvm_filename_key), + "chmod-cert" => trim((string)$action->jetkvm_chmod_cert), + "chmod-key" => trim((string)$action->jetkvm_chmod_key), + "restart-command" => trim((string)$action->jetkvm_restart_command), + "certificates" => "", // defaults to all (= empty), may be overridden via CLI + ]; + } elseif (!$silent) { + LeUtils::log_error("JetKVM ignoring disabled or invalid automation '$automation_id'"); + } + } else { + LeUtils::log_error("No JetKVM upload automation found with uuid = '$automation_id'"); + } + + return false; +} + +function findCertificates(array $certificate_ids_or_names, $load_content = true): array +{ + if (!class_exists("OPNsense\\Core\\Config")) { + return []; + } + + $config = OPNsense\Core\Config::getInstance()->object(); + $client = $config->OPNsense->AcmeClient; + + $result = []; + $refids = []; + + foreach ($client->certificates->children() as $cert) { + $item = []; + $id = (string)$cert->id; + $name = (string)$cert->name; + + if ( + empty($certificate_ids_or_names) + || in_array($id, $certificate_ids_or_names) + || in_array($name, $certificate_ids_or_names) + ) { + if ($cert->enabled == 0) { + if (!empty($certificate_ids_or_names)) { + LeUtils::log_error("Certificate '{$name}' (id: $id) is disabled, skipping JetKVM upload."); + } + + continue; + } + + $item["id"] = $id; + $item["name"] = $name; + $item["updated"] = intval($cert->lastUpdate); + $item["automations"] = preg_split('/[\s,]+/', $cert->restartActions); + if (isset($cert->certRefId)) { + $refids[] = $item['content_id'] = (string)$cert->certRefId; + } + + $result[$id] = $item; + } + } + + if ($load_content && ($certificates = exportCertificates($refids))) { + foreach ($result as &$cert_info) { + $id = $cert_info["content_id"]; + if (isset($certificates[$id])) { + $cert_info["content"] = $certificates[$id]; + } + } + } + + return $result; +} + +function exportCertificates(array $cert_refids): array +{ + $result = []; + $certModel = new Cert(); + foreach ($certModel->cert->iterateItems() as $cert) { + $refid = (string)$cert->refid; + $item = []; + if (in_array($refid, $cert_refids)) { + $_tmp = CertStore::getCertificate($refid); + $item["cert"] = $_tmp["crt"]; + $item["key"] = $_tmp["prv"]; + // check if a CA is linked + if (!empty((string)$cert->caref)) { + $item['ca'] = $_tmp['ca']['crt']; + + // combine files to export a fullchain.pem + $item["fullchain"] = $item["cert"] . $item["ca"]; + } + $result[$refid] = $item; + } + } + + return $result; +} + +function configPath(): string +{ + if (($path = Utils::configPath())) { + // shared with sftp/remote-ssh to reuse the same identities & known_hosts + return $path . DIRECTORY_SEPARATOR . "sftp-config"; + } + die("Failed detecting config path"); +} + +// Running the main script +Utils::runCLIMain( + "help", + "getOptionsById", + COMMANDS, + EXITCODE_SUCCESS, + EXITCODE_ERROR_UNKNOWN_COMMAND +); diff --git a/security/acme-client/src/opnsense/service/conf/actions.d/actions_acmeclient.conf b/security/acme-client/src/opnsense/service/conf/actions.d/actions_acmeclient.conf index c58df0bef8..df13a5be67 100644 --- a/security/acme-client/src/opnsense/service/conf/actions.d/actions_acmeclient.conf +++ b/security/acme-client/src/opnsense/service/conf/actions.d/actions_acmeclient.conf @@ -127,6 +127,24 @@ parameters:--identity-type=%s --host=%s show-identity type:script_output message:prints the public key used to connect to ssh server +[upload-jetkvm] +command:/usr/local/opnsense/scripts/OPNsense/AcmeClient/upload_jetkvm.php +parameters:--certificates=%s --automation-id=%s +type:script +message:uploading a certificate to a JetKVM device + +[test-jetkvm-connection] +command:/usr/local/opnsense/scripts/OPNsense/AcmeClient/upload_jetkvm.php +parameters:--host=%s --host-key=%s --port=%s --user=%s --identity-type=%s --no-error test-connection +type:script_output +message:testing connection to JetKVM device + +[show-jetkvm-identity] +command:/usr/local/opnsense/scripts/OPNsense/AcmeClient/upload_jetkvm.php +parameters:--identity-type=%s --host=%s show-identity +type:script_output +message:prints the public key used to connect to a JetKVM device + [reset-acme-client] command:/usr/bin/find /var/etc/acme-client/home /var/etc/acme-client/configs /var/etc/acme-client/certs /var/etc/acme-client/keys /var/etc/acme-client/accounts -type f -delete parameters: From 25f130c5eeaa9370277553e8d7e6a45480f1a350 Mon Sep 17 00:00:00 2001 From: daemonhorn Date: Tue, 11 Aug 2026 19:17:41 -0400 Subject: [PATCH 2/3] security/acme-client: fill in PR number in changelog Replaces the (#XXXX) placeholder now that GitHub assigned #5621 to this PR, per the plugin's changelog convention. Co-Authored-By: Claude Sonnet 5 --- security/acme-client/pkg-descr | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/security/acme-client/pkg-descr b/security/acme-client/pkg-descr index 6c690eaad1..f33d53c399 100644 --- a/security/acme-client/pkg-descr +++ b/security/acme-client/pkg-descr @@ -11,7 +11,7 @@ Plugin Changelog 4.17 Added: -* new automation to upload certificate to JetKVM via SSH (#XXXX) +* new automation to upload certificate to JetKVM via SSH (#5621) 4.16 From 02c6b832af94a4eed093fc4c0630d64b062e07ed Mon Sep 17 00:00:00 2001 From: daemonhorn Date: Tue, 11 Aug 2026 20:11:25 -0400 Subject: [PATCH 3/3] security/acme-client: default JetKVM restart to reboot, document Custom mode prerequisite Two fixes driven by real-hardware production testing (the automation running unattended as part of cron-driven ACME renewal, typically overnight): - Default the post-upload command to "reboot" instead of blank. Leaving it blank by default meant a renewed certificate never actually got applied without a human manually rebooting the device afterward, defeating the point of automating it. JetKVM devices are rebooted overnight by cron-driven renewals anyway, when an active KVM-over-IP session is unlikely, so defaulting to "reboot" is the better tradeoff for this automation's actual use case. The field can still be cleared to apply/verify manually instead. - Document, in the "JetKVM Host" field's help text, that the device must already have "HTTPS Mode" set to "Custom" in its own web UI before this automation is attached. This automation only writes the cert/key files (and optionally reboots); it does not switch the device's HTTPS mode. This was previously flagged as an open question in this PR's history; real-hardware testing has now confirmed it's required. The same real-hardware test also confirms the other open question from this PR's history: after uploading to user-defined.crt/.key and rebooting, the JetKVM device does serve the new certificate. Co-Authored-By: Claude Sonnet 5 --- .../OPNsense/AcmeClient/forms/dialogAction.xml | 13 ++++++++----- .../app/models/OPNsense/AcmeClient/AcmeClient.xml | 1 + 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/forms/dialogAction.xml b/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/forms/dialogAction.xml index 7ae0d1680a..f2b8d08a61 100644 --- a/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/forms/dialogAction.xml +++ b/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/forms/dialogAction.xml @@ -183,7 +183,10 @@ action.jetkvm_host text - IP address or hostname of the JetKVM device. + IP address or hostname of the JetKVM device. Requires the JetKVM device to already have "HTTPS Mode" + set to "Custom" in its own web UI (Settings > Network) before this automation is attached -- it + only writes the certificate/key files and (optionally) reboots the device; it does not switch HTTPS + mode for you. Uploads will not take effect until that mode is selected on the device itself. action.jetkvm_port @@ -262,10 +265,10 @@ Optional command executed on the JetKVM device via the same SSH connection after the certificate and key have been uploaded. Confirmed against a real device: JetKVM does not hot-reload a "Custom" certificate, and its own certificate-apply script performs a full device reboot ("reboot") to pick one up, which - will briefly drop any active KVM-over-IP session. This is why the field is left blank by default - instead of defaulting to "reboot" automatically; set it to "reboot" explicitly if you want the new - certificate applied right after upload, or leave it blank to apply/verify manually at a convenient - time. + will briefly drop any active KVM-over-IP session. Defaults to "reboot" so a certificate renewed by an + unattended ACME cron run (typically overnight, when a session is unlikely to be active) is actually + applied without needing a human to follow up — clear the field if you'd rather apply/verify the new + certificate manually at a convenient time instead. true diff --git a/security/acme-client/src/opnsense/mvc/app/models/OPNsense/AcmeClient/AcmeClient.xml b/security/acme-client/src/opnsense/mvc/app/models/OPNsense/AcmeClient/AcmeClient.xml index 30b4ec24b6..f61b1a2a75 100644 --- a/security/acme-client/src/opnsense/mvc/app/models/OPNsense/AcmeClient/AcmeClient.xml +++ b/security/acme-client/src/opnsense/mvc/app/models/OPNsense/AcmeClient/AcmeClient.xml @@ -1623,6 +1623,7 @@ N + reboot /^.{0,1024}$/u Should be a shell command up to 1024 characters.