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 2634c404d..441eb6fb0 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 @@ -101,6 +101,32 @@ class ActionsController extends ApiMutableModelControllerBase return ["status" => "unavailable"]; } + public function sshGetIdentityAction() + { + $result = ["status" => "unavailable"]; + + if ($response = $this->callBackend(["show-remote-ssh-identity"], ["remote_ssh_identity_type", "remote_ssh_host"])) { + $result["status"] = "ok"; + $result["identity"] = $response; + } + + return $result; + } + + public function sshTestConnectionAction() + { + if ( + $response = $this->callBackend( + ["test-remote-ssh-connection"], + ["remote_ssh_host", "remote_ssh_host_key", "remote_ssh_port", "remote_ssh_user", "remote_ssh_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 b46f45519..d3ecd4664 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 @@ -142,6 +142,49 @@ Leave blank to use default "{{name}}/fullchain.pem". true + + + header + + + + action.remote_ssh_host + + text + IP address or hostname of the SSH server. + + + action.remote_ssh_port + + text + SSH server port. Leave blank to use default "22". + true + + + action.remote_ssh_key + + text + SSH server host key, formatted as in 'known_hosts'. + Leave blank to auto accept host key on first connect (not as secure as specifying it). + + + action.remote_ssh_user + + text + The username to login to the SSH server. + + + action.remote_ssh_identity_type + + dropdown + The type of identify to present to the SSH server for authorization. Select 'none' to use default "ECDSA". + + + action.remote_ssh_command + + text + The command to execute on the SSH server. + header diff --git a/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/LeAutomation/ConfigdRemoteSsh.php b/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/LeAutomation/ConfigdRemoteSsh.php new file mode 100644 index 000000000..e924e2cc2 --- /dev/null +++ b/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/LeAutomation/ConfigdRemoteSsh.php @@ -0,0 +1,45 @@ +config->id; + $this->command = $command; + return true; + } +} diff --git a/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/Process.php b/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/Process.php index 24c966707..59ed4808f 100644 --- a/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/Process.php +++ b/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/Process.php @@ -115,21 +115,49 @@ class Process } } - public function get($timeout = 5, $max_length = 8192, $ending = PHP_EOL) + private $linesBuffer = []; + + private function nextBufferedLine() { - $readables = array_filter($this->outputs, function ($stream) { - return is_resource($stream) && !feof($stream); - }); + return empty($this->linesBuffer) + ? false + : array_shift($this->linesBuffer); + } - $micros = intval(($timeout - floor($timeout)) * 1000000); - $can_read = !empty($readables) && stream_select($readables, $w = [], $e = [], $timeout, $micros); - $stream = array_reduce(($can_read ? $readables : []), function ($a, $b) { - return is_resource($a) && !feof($a) ? $a : $b; - }, null); + /** + * Returns one line from stdout or stdin as it gets available. May return 'false' when no line became available + * within the specified $timeout or when another stream events occurred that returned no new content. + * @param $timeout float timeout in seconds + * @param $max_length int max length of a single line + * @return false|string One line of stdout/err (merged) or false when no new line exists. + */ + public function get($timeout = 5, $max_length = 64 * 1024) + { + if (($line = $this->nextBufferedLine()) !== false) { + return $line; + } - return is_resource($stream) - ? stream_get_line($stream, $max_length, $ending) - : false; + $readables = array_filter($this->outputs, fn($stream) => is_resource($stream) && !feof($stream)); + $micros = intval(($timeout - floor($timeout)) * 1000000) + 100; + $timeout = floor($timeout); + $__ = null; + + $can_read = !empty($readables) + && stream_select($readables, $__, $__, $timeout, $micros) !== false; + + if ($can_read) { + foreach ($readables as $stream) { + $content = fread($stream, $max_length); + if ($content !== false) { + array_push($this->linesBuffer, ...preg_split('/\r\n|\n|\r/', $content)); + if (empty($this->linesBuffer[-1])) { + array_pop($this->linesBuffer); // remove trailing empty newline + } + } + } + } + + return $this->nextBufferedLine(); } public function put($data, $append = PHP_EOL) diff --git a/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/SSHKeys.php b/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/SSHKeys.php index 39c905bdd..3d91cef69 100644 --- a/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/SSHKeys.php +++ b/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/SSHKeys.php @@ -122,7 +122,7 @@ class SSHKeys // Check our current known_host file - $addKeyInfo = function (array &$key_list) { + $addKeyInfo = function (array $key_list) { foreach ($key_list as &$item) { $item["key_info"] = self::getHostKeyInfo($item["host_key"]); } @@ -182,7 +182,7 @@ class SSHKeys if ( empty($remote_host_keys) && $query_error - && $query_error["connection_refused"] + && ($query_error["connection_refused"] ?? false) && !$host_key && self::ALTERNATE_DEFAULT_KEY_TYPE != self::DEFAULT_KEY_TYPE ) { @@ -451,7 +451,7 @@ class SSHKeys { Utils::requireThat(in_array($identity_type, self::IDENTITY_TYPES), "Identity type '$identity_type' unknown."); - list($key_type, $key_size) = explode('_', $identity_type, 2); + list($key_type, $key_size) = explode('_', "{$identity_type}_", 2); if (!$key_size && self::DEFAULT_IDENTITY_KEY_BITS[$key_type] > 0) { $key_size = self::DEFAULT_IDENTITY_KEY_BITS[$key_type]; } diff --git a/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/SftpClient.php b/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/SftpClient.php index f9d2a0002..064b13403 100644 --- a/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/SftpClient.php +++ b/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/SftpClient.php @@ -173,7 +173,7 @@ class SftpClient $this->process = null; - if ($this->failed_status && $this->failed_status["connection_closed"]) { + if ($this->failed_status && ($this->failed_status["connection_closed"] ?? false)) { $this->clearError(); } } diff --git a/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/SftpUploader.php b/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/SftpUploader.php index bf6a466e3..353495880 100644 --- a/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/SftpUploader.php +++ b/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/SftpUploader.php @@ -236,9 +236,9 @@ class SftpUploader // Preparing upload $username = $connection["user"]; $remote_filename = basename((empty($file["target"]) ? $local_file : $file["target"])); - $remote_file = $remote_files[$remote_filename] ?: ["type" => "-", "owner" => $username]; + $remote_file = $remote_files[$remote_filename] ?? ["type" => "-", "owner" => $username]; $remote_is_file = $remote_file["type"] === "-"; - $remote_is_readonly = preg_match('/^[^wW]+$/', $remote_file["permissions"] ?: ""); + $remote_is_readonly = preg_match('/^[^wW]+$/', $remote_file["permissions"] ?? ""); // Check if a folder/socket/symlink, etc is in the way if (!$remote_is_file) { @@ -246,10 +246,10 @@ class SftpUploader return self::UPLOAD_ERROR_NO_OVERWRITE; } - $chgrp = $file["group"] ?: ""; + $chgrp = $file["group"] ?? ""; $chgrp = preg_match('/^\d+$/', $chgrp) ? (string)$chgrp : false; - $chmod = $file["mode"] ?: ""; + $chmod = $file["mode"] ?? ""; $chmod = preg_match('/^0\d{3}$/', $chmod) ? (string)$chmod : false; @@ -347,7 +347,7 @@ class SftpUploader Utils::log()->error("Failed uploading test file to detect ownership. Next uploads may fail as well.", $error); } else { // Get owner of the test file - $file_info = $this->sftp->ls()[$remote_test_file] ?: ["owner" => -1]; + $file_info = $this->sftp->ls()[$remote_test_file] ?? ["owner" => -1]; // Cleanup $this->sftp->rm($remote_test_file); $this->sftp->clearError(); @@ -370,7 +370,7 @@ class SftpUploader if ( isset($this->pending_files[$file]) && is_array($existing = $this->pending_files[$file]) - && $existing["delete_source"] === true + && ($existing["delete_source"] ?? false) === true ) { unlink($existing["source"]); } @@ -408,7 +408,7 @@ class SftpUploader } $index = $this->temporary_files_index; - if ($index <= 0 || !is_array($shared_temporary_files[$index])) { + if ($index <= 0 || !is_array($shared_temporary_files[$index] ?? null)) { $index = $this->temporary_files_index = ++$shared_temporary_files_index_sequence; $shared_temporary_files[$index] = []; } diff --git a/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/Utils.php b/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/Utils.php index f16d4082f..b5842d58f 100644 --- a/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/Utils.php +++ b/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/Utils.php @@ -142,4 +142,174 @@ class Utils return DIRECTORY_SEPARATOR . join(DIRECTORY_SEPARATOR, $path); } + + /** + * @return string the path where acme.sh config is stored or null if not available. + */ + public static function configPath(): string + { + static $paths = [ + '/var/etc/acme-client', + __DIR__ + ]; + foreach ($paths as $path) { + if (is_dir($path)) { + return $path; + } + } + return self::requireThat(false, "No config path"); + } + + /** + * @param string $automation_id the automation numeric id or UUID. + * @return mixed|null the automation action when found. + */ + public static function getAutomationActionById($automation_id) + { + $config = \OPNsense\Core\Config::getInstance()->object(); + $client = $config->OPNsense->AcmeClient; + + foreach ($client->actions->children() as $action) { + if ( + $automation_id === (string)$action->attributes()["uuid"] + || $automation_id === (string)$action->id + ) { + return $action; + } + } + + return null; + } + + /** + * Print CLI help. + * @see Utils::runCLIMain + * @param string $about about text + * @param string $examples examples text + * @param array $commands the commands + * @return void + */ + public static function printCLIHelp($about, $examples, $commands = []) + { + static $options = [ + "-h, --help Print commandline help", + "--log Enable log to stdout (instead of syslog)", + "--automation-id Read options from the action specified by id or uuid", + "--no-error Always exit with 0 (original exit codes are still logged)", + ]; + + echo $about . PHP_EOL + . "Usage: " . basename($GLOBALS["argv"][0]) . " [options] [--command=]COMMAND" . PHP_EOL + . PHP_EOL . join(PHP_EOL, $options) . PHP_EOL; + + foreach ($commands as $name => $cmd) { + echo PHP_EOL . "COMMAND \"$name\" {$cmd["description"]}" . PHP_EOL . "Options:" . PHP_EOL; + foreach ($cmd["options"] as $option) { + $option = preg_replace(['/^([^:]+)$/', '/(.+)::$/', '/(.+):$/'], ['[$1]', '[$1=value]', '$1=value'], "--$option"); + echo " $option" . PHP_EOL; + } + } + + echo PHP_EOL . "Examples:" . PHP_EOL + . preg_replace('/\r\n|\n|\r/', PHP_EOL, $examples) + . PHP_EOL . PHP_EOL; + } + + /** + * Helper that implements `main();` for a CLI application following the command design. + * + * `$commands` follows the format: + * ```php + * [ + * "command-name" => [ + * "description" => "...", + * "options" => ["arg1::", "arg2::", "arg3::"], + * "implementation" => "commandImplementationFunction", + * "default" => true | false, + * ], + * ] + * ``` + * + * @param callable $help method that display's CLI help. + * @param callable $optionsByActionId method that returns CLI args (assoc array) from an automation action id. + * @param array $commands the list of commands that the CLI application can execute. + * @param int $exit_success exit code for success + * @param int $exit_unknown_command exit code for no matching command + * @return void + */ + public static function runCLIMain(callable $help, callable $optionsByActionId, $commands = [], $exit_success = 0, $exit_unknown_command = 255) + { + global $argv; + $command = self::getSelectedCLICommand($commands); + $options = ["help", "log", "no-error"]; + + $has_automation_id = preg_match('/--automation-id=\S+/', join(" ", $argv)); + if ($has_automation_id) { + $options = array_merge($options, ["automation-id:", "certificates::"]); + } else { + $options = array_merge($options, $command["options"]); + } + + $index = 0; + if ($options = getopt("h", $options, $index)) { + if (isset($options["h"]) || isset($options["help"])) { + $help(); + } else { + if (isset($options["log"])) { + self::log(true)->info("Logging to stdout enabled"); + } + + $options = array_filter($options, function ($value) { + return !is_string($value) + || (!empty($value = trim($value)) && $value !== "__default_value"); + }); + + if (isset($options["automation-id"])) { + if (is_array($config = $optionsByActionId($options["automation-id"]))) { + $options = array_merge($config, $options); + } else { + self::log()->error("No usable config found for automation-id {$options["automation-id"]}"); + exit(1); + } + } + + if (is_callable($runner = $command["implementation"])) { + $code = $runner($options); + + if ($code != $exit_success) { + self::log()->error("Command execution failed, exit code $code. Last input was: " . json_encode($options, JSON_UNESCAPED_SLASHES)); + } + + exit(isset($options["no-error"]) ? $exit_success : $code); + } else { + exit($exit_unknown_command); + } + } + } else { + if (count($argv) < 2) { + $help(); + } else { + $cmd = join(" ", $argv); + self::log()->error("Parsing of '$cmd' failed at argument '{$argv[$index]}'"); + } + exit(1); + } + } + + private static function getSelectedCLICommand($commands = []) + { + $default = null; + $command = null; + $parsed_args = getopt("", ["command::"]); + foreach ($commands as $name => $cmd) { + if (in_array($name, $GLOBALS["argv"]) || ($parsed_args["command"] ?? "") === $name) { + $command = $cmd; + } + if (($cmd["default"] ?? false) === true) { + $default = $cmd; + } + } + + return $command ?? $default; + } } 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 7d195f02e..8a9c50de4 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 @@ -1113,6 +1113,7 @@ Restart Nginx (OPNsense plugin) Upload certificate to Highwinds CDN Upload certificate via SFTP + Remote Command via SSH Upload certificate to FRITZ!Box router Upload certificate to Synology DSM Update local Unifi keystore @@ -1205,6 +1206,43 @@ Should be a string between 1 and 255 characters. Characters are limited to [a-z], [0-9] and [{}@./-_%] and the string must neither begin nor end with '/'. + + 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 + 49151 + 22 + Should be a valid port number between 1 and 49151. + + + N + /^.{1,128}$/u + Should be a string between 1 and 128 characters. + + + N + + ECDSA + RSA + ed25519 + + + + N + /^.{1,1024}$/u + Should be a shell command between 1 and 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 7f258eafe..9474a0613 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 @@ -92,17 +92,21 @@ POSSIBILITY OF SUCH DAMAGE. .hide(); } - // SFTP - Identity show button - (function ($identityType) { + // SFTP/SSH - Identity show button + [ + {selector: '#action\\.sftp_identity_type', group: "configd_upload_sftp", action: "sftpGetIdentity"}, + {selector: '#action\\.remote_ssh_identity_type', group: "configd_remote_ssh", action: "sshGetIdentity"}, + ].forEach(function(config) { + var $identityType = $(config.selector); var identityDiv = makeStatusDiv($identityType); - makeButton("{{ lang._('Show Identity') }}", "configd_upload_sftp", "btn-info") + makeButton("{{ lang._('Show Identity') }}", config.group, "btn-info") .click(function () { identityDiv.hide(); var button = $(this); button.prop('disabled', true).find(".fa-spinner").show(); - ajaxCall("/api/acmeclient/actions/sftpGetIdentity", getFormData("DialogAction").action, function (data, status) { + ajaxCall("/api/acmeclient/actions/" + config.action, getFormData("DialogAction").action, function (data, status) { button.prop('disabled', false).find(".fa-spinner").hide(); if (status === "success" && data.status === "ok") { @@ -117,10 +121,15 @@ POSSIBILITY OF SUCH DAMAGE. $identityType.change(function() { identityDiv.hide(); }); - })($('#action\\.sftp_identity_type')); + }); + + // SFTP/SSH - Connection test button + [ + {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.') }}"}, + ].forEach(function(config) { + var $user = $(config.selector); - // SFTP - Connection test button - (function ($user) { var statusDiv = makeStatusDiv($user, 'alert-success').html( '
' + '
' @@ -145,13 +154,13 @@ POSSIBILITY OF SUCH DAMAGE. {msg: "{{ lang._('Test failed, see details.') }}"}, ]; - makeButton("{{ lang._('Test Connection') }}", "configd_upload_sftp") + makeButton("{{ lang._('Test Connection') }}", config.group) .click(function () { statusDiv.hide(); var button = $(this); button.prop('disabled', true).find(".fa-spinner").show(); - ajaxCall("/api/acmeclient/actions/sftpTestConnection", getFormData("DialogAction").action, function (data, status) { + ajaxCall("/api/acmeclient/actions/" + config.action, getFormData("DialogAction").action, function (data, status) { button.prop('disabled', false).find(".fa-spinner").hide(); var message = "", @@ -161,7 +170,7 @@ POSSIBILITY OF SUCH DAMAGE. if (status === "success") { if (data.success === true) { statusClass = "alert-success"; - message = "{{ lang._('Connection and upload test succeeded.') }}" + message = config.success } else { detail = JSON.stringify(data, null, ' ').replace(/\\"/g, "'"); @@ -188,7 +197,7 @@ POSSIBILITY OF SUCH DAMAGE. statusDiv.removeClass("alert-success alert-warning").addClass(statusClass).show(); }); }); - })($('#action\\.sftp_user')); + }); // Eagerly hiding method tables to avoid contents popping up when opening the dialog for the first time. $(".method_table").hide(); diff --git a/security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/run_remote_ssh.php b/security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/run_remote_ssh.php new file mode 100755 index 000000000..3f045347b --- /dev/null +++ b/security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/run_remote_ssh.php @@ -0,0 +1,355 @@ +#!/usr/local/bin/php + [ + "description" => "runs the a command on the specified target host", + "options" => [ + "host::", "port::", "host-key::", "user::", "identity-type::", "run::"], + "implementation" => "commandRunRemote", + "default" => true, + ], + + "test-connection" => [ + "description" => "connects to the host 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 { + Utils::log()->error("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 = runRemoteCommand($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 commandRunRemote(array &$options): int +{ + if (empty($options["run"])) { + Utils::log()->error("SSH: Command is empty, nothing to do."); + return EXITCODE_ERROR; + } + + $lines = runRemoteCommand($options, $error); + if (!$error) { + $host = $options["host"] . (($port = ($options["port"] ?? false)) ? ":$port" : ""); + Utils::log()->info("SSH [$host]> {$options["run"]}:" . PHP_EOL . join(PHP_EOL, $lines)); + return EXITCODE_SUCCESS; + } + + return EXITCODE_ERROR; +} + +function runRemoteCommand(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 = $options["port"] ?? 22; + $username = $options["user"] ?? false; + $command = $options["run"] ?? ""; + + list($ok, $cmd) = buildSSHArguments($ssh_keys, $host, $username, $identity_type, $host_key, $port); + if ($ok) { + if (empty($command)) { + $error = ["no_command" => true]; + } else { + $cmd[] = $command; + } + } else { + $error = $cmd; + $error["connect_failed"] = true; + return null; + } + + $result = []; + $exit_code = null; + $expected_error = null; + + if ($process = Process::open($cmd)) { + $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; + Utils::log()->error("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))) { + Utils::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) { + Utils::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 { + Utils::log()->error("Failed adding 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) +{ + Utils::log()->info("Reading options from automation: $automation_id"); + + if (is_object($action = Utils::getAutomationActionById($automation_id))) { + if ($action->enabled && "configd_remote_ssh" === (string)$action->type) { + return [ + "host" => trim((string)$action->remote_ssh_host), + "host-key" => trim((string)$action->remote_ssh_host_key), + "port" => trim((string)$action->remote_ssh_port), + "identity-type" => trim((string)$action->remote_ssh_identity_type), + "user" => trim((string)$action->remote_ssh_user), + "run" => trim((string)$action->remote_ssh_command), + ]; + } else { + Utils::log()->error("Ignoring disabled or invalid automation '$automation_id'"); + } + } else { + Utils::log()->error("No upload automation found with uuid = '$automation_id'"); + } + + return false; +} + +function configPath(): string +{ + if (($path = Utils::configPath())) { + return $path . DIRECTORY_SEPARATOR . "sftp-config"; // shared with sftp to have the same identities + } + die("Failed detecting config path"); +} + +function requireThat($expression, $message) +{ + try { + Utils::requireThat($message, $message); + } catch (\AssertionError $e) { + exit(EXITCODE_ERROR); + } + return $expression; +} + +// Running the main script +Utils::runCLIMain( + "help", + "getOptionsById", + COMMANDS, + EXITCODE_SUCCESS, + EXITCODE_ERROR_UNKNOWN_COMMAND +); diff --git a/security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/upload_sftp.php b/security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/upload_sftp.php index c3cbd385e..5a11b3d60 100755 --- a/security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/upload_sftp.php +++ b/security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/upload_sftp.php @@ -86,13 +86,6 @@ const COMMANDS = [ ], ]; -const STATIC_OPTIONS = <<getIdentity($identity_type)) && is_readable($id_file)) { @@ -190,7 +183,7 @@ function commandTestConnection(array &$options): int $uploader = new SftpUploader($sftp); - $chgrp = $options["chgrp"] ?: false; + $chgrp = ($options["chgrp"] ?? "") ?: false; $chmod = isset($options["chmod"]) ? ($options["chmod"] ?: DEFAULT_CERT_MODE) : false; $filename = $uploader->addContent("upload-test", "", 0, $chmod, $chgrp); @@ -275,7 +268,7 @@ function uploadCertificatesToHost(array $options): int $sftp = connectWithServer($options, $error); if ($sftp === null) { Utils::log()->error("Aborting after connect failure."); - return $error["connect_failed"] + return ($error["connect_failed"] ?? false) ? EXITCODE_ERROR : EXITCODE_ERROR_NO_PERMISSION; } @@ -321,10 +314,10 @@ function uploadCertificatesToHost(array $options): int function connectWithServer(array $options, &$error): ?SftpClient { - $identity_type = trim(($options["identity-type"] ?: SSHKeys::DEFAULT_IDENTITY_TYPE)); - $host = trim(($options["host"] ?: "")); - $host_key = ($options["host-key"] ?: ""); - $port = $options["port"] ?: 22; + $identity_type = trim(($options["identity-type"] ?? "")) ?: SSHKeys::DEFAULT_IDENTITY_TYPE; + $host = trim(($options["host"] ?? "")); + $host_key = ($options["host-key"] ?? ""); + $port = $options["port"] ?? 22; $username = $options["user"]; $sftp = new SftpClient(configPath(), $identity_type); @@ -336,7 +329,7 @@ function connectWithServer(array $options, &$error): ?SftpClient } // Apply start path (if one was specified, defaults to home dir) - if (($remote_path = $options["remote-path"])) { + if (!empty($remote_path = ($options["remote-path"] ?? ""))) { if ($err = $sftp->cd($remote_path)->lastError()) { $error = $err; $error["change_home_dir_failed"] = true; @@ -350,55 +343,7 @@ function connectWithServer(array $options, &$error): ?SftpClient function help() { - echo ABOUT . PHP_EOL - . "Usage: " . basename($GLOBALS["argv"][0]) . " [options] [--command=]COMMAND" . PHP_EOL - . PHP_EOL . STATIC_OPTIONS . PHP_EOL; - - foreach (COMMANDS as $name => $cmd) { - echo PHP_EOL . "COMMAND \"$name\" {$cmd["description"]}" . PHP_EOL . "Options:" . PHP_EOL; - foreach ($cmd["options"] as $option) { - $option = preg_replace(['/^([^:]+)$/', '/(.+)::$/', '/(.+):$/'], ['[$1]', '[$1=value]', '$1=value'], "--$option"); - echo " $option" . PHP_EOL; - } - } - - echo PHP_EOL . "Examples:" . PHP_EOL - . str_replace('/\r\n|\n|\r/g', PHP_EOL, EXAMPLES) - . PHP_EOL . PHP_EOL; -} - -function getCommand() -{ - $default = null; - $command = null; - $parsed_args = getopt("", ["command::"]); - foreach (COMMANDS as $name => $cmd) { - if (in_array($name, $GLOBALS["argv"]) || $parsed_args["command"] === $name) { - $command = $cmd; - } - if ($cmd["default"] === true) { - $default = $cmd; - } - } - - return $command ?: $default; -} - -function getActionById($automation_id) -{ - $config = OPNsense\Core\Config::getInstance()->object(); - $client = $config->OPNsense->AcmeClient; - - foreach ($client->actions->children() as $action) { - if ( - $automation_id === (string)$action->attributes()["uuid"] - || $automation_id === (string)$action->id - ) { - return $action; - } - } - - return null; + Utils::printCLIHelp(ABOUT, EXAMPLES, COMMANDS); } function getOptionsById($automation_id, $silent = false) @@ -407,7 +352,7 @@ function getOptionsById($automation_id, $silent = false) Utils::log()->info("Reading options from automation: $automation_id"); } - if (is_object($action = getActionById($automation_id))) { + if (is_object($action = Utils::getAutomationActionById($automation_id))) { if ($action->enabled && "configd_upload_sftp" === (string)$action->type) { return [ "host" => trim((string)$action->sftp_host), @@ -439,7 +384,7 @@ function addFilesToUpload(array $options, SftpUploader &$uploader) { $chmod = isset($options["chmod"]) ? ($options["chmod"] ?: DEFAULT_CERT_MODE) : false; $chmod_key = isset($options["chmod-key"]) ? ($options["chmod-key"] ?: DEFAULT_KEY_MODE) : false; - $chgrp = $options["chgrp"] ?: false; + $chgrp = ($options["chgrp"] ?? "") ?: false; if (isset($options["certificates"])) { $cert_ids = preg_split('/[,;\s]+/', $options["certificates"] ?: "", 0, PREG_SPLIT_NO_EMPTY); @@ -571,7 +516,7 @@ function findCertificates(array $certificate_ids_or_names, $load_content = true) return $result; } -function exportCertificates(array $cert_refids) +function exportCertificates(array $cert_refids): array { $result = []; $config = OPNsense\Core\Config::getInstance()->object(); @@ -597,72 +542,12 @@ function exportCertificates(array $cert_refids) function configPath(): string { - static $paths = [ - '/var/etc/acme-client', - __DIR__ - ]; - foreach ($paths as $path) { - if (is_dir($path)) { - return $path . DIRECTORY_SEPARATOR . 'sftp-config'; - } + if (($path = Utils::configPath())) { + return $path . DIRECTORY_SEPARATOR . "sftp-config"; } die("Failed detecting config path"); } -function main() -{ - global $argv; - $command = getCommand(); - $options = ["help", "log", "no-error"]; - - $has_automation_id = preg_match('/--automation-id=\S+/', join(" ", $argv)); - if ($has_automation_id) { - $options = array_merge($options, ["automation-id:", "certificates::"]); - } else { - $options = array_merge($options, $command["options"]); - } - - $index = 0; - if ($options = getopt("h", $options, $index)) { - if (isset($options["h"]) || isset($options["help"])) { - help(); - } else { - if (isset($options["log"])) { - Utils::log(true)->info("Logging to stdout enabled"); - } - - $options = array_filter($options, function ($value) { - return !is_string($value) - || (!empty($value = trim($value)) && $value !== "__default_value"); - }); - - if (isset($options["automation-id"])) { - $options = array_merge(getOptionsById($options["automation-id"]), $options); - } - - if (is_callable($runner = $command["implementation"])) { - $code = $runner($options); - - if ($code != EXITCODE_SUCCESS) { - Utils::log()->error("Command execution failed, exit code $code. Last input was: " . json_encode($options, JSON_UNESCAPED_SLASHES)); - } - - exit(isset($options["no-error"]) ? EXITCODE_SUCCESS : $code); - } else { - exit(EXITCODE_ERROR_UNKNOWN_COMMAND); - } - } - } else { - if (count($argv) < 2) { - help(); - } else { - $cmd = join(" ", $argv); - Utils::log()->error("Parsing of '$cmd' failed at argument '{$argv[$index]}'"); - } - exit(1); - } -} - function requireThat($expression, $message) { try { @@ -674,4 +559,10 @@ function requireThat($expression, $message) } // Running the main script -main(); +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 7af68eaa4..4faf4b687 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 @@ -120,6 +120,24 @@ parameters:--identity-type=%s --host=%s show-identity type:script_output message:prints the public key used to connect to sftp server +[run-remote-ssh-command] +command:/usr/local/opnsense/scripts/OPNsense/AcmeClient/run_remote_ssh.php +parameters:--automation-id=%s +type:script +message:running a command on the ssh server + +[test-remote-ssh-connection] +command:/usr/local/opnsense/scripts/OPNsense/AcmeClient/run_remote_ssh.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 ssh server + +[show-remote-ssh-identity] +command:/usr/local/opnsense/scripts/OPNsense/AcmeClient/run_remote_ssh.php +parameters:--identity-type=%s --host=%s show-identity +type:script_output +message:prints the public key used to connect to ssh server + [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: