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..f33d53c399 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 (#5621) + 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..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 @@ -174,6 +174,103 @@ text The command to execute on the SSH server. + + + header + + + + action.jetkvm_host + + text + 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 + + 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. 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 + 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..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 @@ -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,75 @@ /^.{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 + reboot + /^.{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: