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: