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 48682f8b3..a9be0c84e 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
@@ -31,6 +31,7 @@ namespace OPNsense\AcmeClient\Api;
use \OPNsense\Base\ApiMutableModelControllerBase;
use \OPNsense\Base\UIModelGrid;
+use \OPNsense\Core\Backend;
use \OPNsense\Core\Config;
use \OPNsense\AcmeClient\AcmeClient;
@@ -73,4 +74,54 @@ class ActionsController extends ApiMutableModelControllerBase
{
return $this->searchBase('actions.action', array('enabled', 'name', 'description'), 'name');
}
+
+ public function sftpGetIdentityAction()
+ {
+ $result = ["status" => "unavailable"];
+
+ if ($response = $this->callBackend(["show-sftp-identity"], ["sftp_identity_type", "sftp_host"])) {
+ $result["status"] = "ok";
+ $result["identity"] = $response;
+ }
+
+ return $result;
+ }
+
+ public function sftpTestConnectionAction()
+ {
+ if ($response = $this->callBackend(
+ ["test-sftp-connection"],
+ ["sftp_host", "sftp_host_key", "sftp_port", "sftp_user", "sftp_identity_type", "sftp_remote_path"])) {
+
+ return $response;
+ }
+
+ return ["status" => "unavailable"];
+ }
+
+ private function callBackend(array $command, array $arguments = [])
+ {
+ if ($this->request->isPost()) {
+ $backend = new Backend();
+
+ foreach ($arguments as $name) {
+ $command[] = $this->request->getPost($name);
+ }
+
+ $command = array_map(function ($value) {
+ return escapeshellarg(empty($value = trim($value)) ? "__default_value" : $value);
+ }, $command);
+
+ if ($result = trim($backend->configdRun("acmeclient " . join(" ", $command)))) {
+ if (preg_match('/^\[.+\]$/ms', $result) || preg_match('/^\{.+\}$/ms', $result)) {
+ try {
+ $result = json_decode($result, true, 64, JSON_THROW_ON_ERROR);
+ } catch (\Exception $ignored) {/*pass as is when json parsing fails*/}
+ }
+ return $result;
+ }
+ }
+
+ return false;
+ }
}
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 54669fa74..c9c4f3b42 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
@@ -40,6 +40,68 @@
text
Access token for Highwinds API.
+
+
+ header
+
+
+
+ action.sftp_host
+
+ text
+ IP address or hostname of the SFTP server.
+
+
+ action.sftp_port
+
+ text
+ SFTP server port. Leave blank to use default "22".
+
+
+ action.sftp_host_key
+
+ text
+ SFTP 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.sftp_user
+
+ text
+ The username to login to the SFTP server.
+
+
+ action.sftp_identity_type
+
+ dropdown
+ The type of identify to present to the SFTP server for authorization. Select 'none' to use default "ECDSA".
+
+
+ action.sftp_remote_path
+
+ text
+ Path on the SFTP server to change to after login.
+ The path can be absolute or relative to home and must exist.
+ Leave blank to not change path after login.
+
+
+ action.sftp_chmod
+
+ text
+ Unix permission to apply to uploaded public keys. Leave blank to use default "0440".
+
+
+ action.sftp_chmod_key
+
+ text
+ Unix permission to apply to uploaded private keys. Leave blank to use default "0400".
+
+
+ action.sftp_chgrp
+
+ text
+ Unix group id to apply to all uploaded files. Leave blank to not change the group.
+
header
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 e66b9d041..6879922c5 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
@@ -738,6 +738,7 @@
Restart HAProxy (OPNsense plugin)
Restart Nginx (OPNsense plugin)
Upload certificate to Highwinds CDN
+ Upload certificate via SFTP
System or Plugin Command (select below)
@@ -751,6 +752,58 @@
/^.{1,1024}$/u
Should be a string 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
+ 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,512}$/u
+ Should be a string between 1 and 512 characters.
+
+
+ N
+ /^[0-9]+$/u
+ Should be a numeric value.
+
+
+ N
+ /^0[0-9]{3}$/u
+ A unix permission, 4 digits (e.g. 0440).
+
+
+ N
+ /^0[0-9]{3}$/u
+ A unix permission, 4 digits (e.g. 0400).
+
/^(?!.*(Let\'s\ Encrypt|acme|[fF]irmware))([\S\s]{1,255})/
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 dcb794f7e..b6b3f8843 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
@@ -60,7 +60,135 @@ POSSIBILITY OF SUCH DAMAGE.
$(".method_table_"+$(this).val()).show();
});
$("#action\\.type").change();
- })
+ });
+
+ // Helpers for extra buttons and status divs
+ function makeButton(label, buttonGroup, buttonClass) {
+ var button = $('');
+ button.addClass(buttonClass || "btn-primary");
+ $('.fa-spinner', button).hide();
+ $('.btn-text', button).html(label);
+
+ var targetContainer = $("#DialogAction .modal-footer"),
+ targetId = "method_table_" + buttonGroup,
+ target = $("." + targetId, targetContainer);
+
+ if (!target.is('span')) {
+ target = $('')
+ .addClass(targetId)
+ .prependTo(targetContainer)
+ .hide();
+ }
+
+ return button.appendTo(target);
+ }
+
+ function makeStatusDiv(anchor, statusClass) {
+ return $('')
+ .appendTo($(anchor).closest("table").find("thead th[colspan=3]").first())
+ .addClass(statusClass || 'alert-info')
+ .hide();
+ }
+
+ // SFTP - Identity show button
+ (function ($identityType) {
+ var identityDiv = makeStatusDiv($identityType);
+
+ makeButton("{{ lang._('Show Identity') }}", "upload_sftp", "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) {
+ button.prop('disabled', false).find(".fa-spinner").hide();
+
+ if (status === "success" && data.status === "ok")
+ identityDiv.text(data.identity).show();
+ else
+ identityDiv.text("{{ lang._('Failed loading identity') }}").show();
+ });
+ });
+
+ // Hide when input changes that influences the identity.
+ $identityType.change(function() {
+ identityDiv.hide();
+ });
+ })($('#action\\.sftp_identity_type'));
+
+ // SFTP - Connection test button
+ (function ($user) {
+ var statusDiv = makeStatusDiv($user, 'alert-success').html(
+ ''
+ + '
'
+ + '');
+
+ statusDiv.find(".detail-enabler").click(function() {
+ $(".detail", statusDiv).show();
+ $(this).hide();
+ });
+
+ var errors = [
+ {cond: ["connect_failed", "invalid_parameters"], msg: "{{ lang._('Host or username not specified.') }}"},
+ {cond: ["connect_failed", "host_not_trusted"], msg: "{{ lang._('Host cannot be trusted.') }}"},
+ {cond: ["connect_failed", "host_not_resolved"], msg: "{{ lang._('Failed to resolve hostname.') }}"},
+ {cond: ["connect_failed", "connection_refused"], msg: "{{ lang._('Connection to host refused.') }}"},
+ {cond: ["connect_failed", "network_timeout"], msg: "{{ lang._('Connection timed out.') }}"},
+ {cond: ["connect_failed", "network_unreachable"], msg: "{{ lang._('Host not reachable.') }}"},
+ {cond: ["connect_failed", "permission_denied"], msg: "{{ lang._('Host does not permit a connection for the specified user & identity.') }}"},
+ {cond: ["connect_failed"], msg: "{{ lang._('Failed to connect to host.') }}"},
+ {cond: ["change_home_dir_failed"], msg: "{{ lang._('Failed to change the remote path.') }}"},
+ {cond: ["permission_denied"], msg: "{{ lang._('Uploads are not allowed to the specified remote path.') }}"},
+ {msg: "{{ lang._('Test failed, see details.') }}"},
+ ];
+
+ makeButton("{{ lang._('Test Connection') }}", "upload_sftp")
+ .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) {
+ button.prop('disabled', false).find(".fa-spinner").hide();
+
+ var message = "", detail = "", statusClass = "alert-warning";
+
+ if (status === "success") {
+ if (data.success === true) {
+ statusClass = "alert-success";
+ message = "{{ lang._('Connection and upload test succeeded.') }}"
+ } else {
+ detail = JSON.stringify(data, null, ' ');
+
+ for (var i = 0; i < errors.length; i++) {
+ var error = errors[i],
+ matching = (error.cond || []).filter(function (condition) {
+ return data[condition] === true;
+ });
+
+ if (matching.length === error.cond.length) {
+ message = error.msg;
+ break;
+ }
+ }
+ }
+ } else {
+ message = "{{ lang._('Test not possible. Failed to talk to firewall backend.') }}";
+ }
+
+ $(".message", statusDiv).html(message);
+ $(".detail", statusDiv).text(detail).hide();
+ $(".detail-enabler", statusDiv).toggle(detail !== "");
+
+ 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/certhelper.php b/security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/certhelper.php
index c561c3bcd..7d4266995 100755
--- a/security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/certhelper.php
+++ b/security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/certhelper.php
@@ -1290,6 +1290,9 @@ function run_restart_actions($certlist, $modelObj)
case 'upload_highwinds':
$response = $backend->configdRun("acmeclient upload_highwinds ${cert_id} ${action_id}");
break;
+ case 'upload_sftp':
+ $response = $backend->configdRun("acmeclient upload-sftp ${cert_id} ${action_id}");
+ break;
case 'configd':
// Make sure a configd command was specified.
if (empty((string)$action->configd)) {
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
new file mode 100644
index 000000000..03691ca72
--- /dev/null
+++ b/security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/upload_sftp.php
@@ -0,0 +1,1454 @@
+#!/usr/local/bin/php
+ [
+ "description" => "transfers certificates to the specified target host",
+ "options" => [
+ "host::", "port::", "host-key::", "user::", "identity-type::", "remote-path::",
+ "certificates::", "files::", "chgrp::", "chmod::", "chmod-key::"],
+ "implementation" => "commandUpload",
+ "default" => true,
+ ],
+
+ "test-connection" => [
+ "description" => "connects to the host and returns results as JSON",
+ "options" => [
+ "host:", "port::", "host-key::", "user:", "remote-path::", "identity-type::"],
+ "implementation" => "commandTestConnection",
+ ],
+
+ "show-identity" => [
+ "description" => "prints the ssh client identity (publickey)",
+ "options" => ["identity-type::", "source-ip::", "host::", "unrestricted"],
+ "implementation" => "commandShowIdentity",
+ ],
+];
+
+const STATIC_OPTIONS = << 4096, "ecdsa" => 521];
+const IDENTITY_TYPES = ["rsa", "rsa_2048", "rsa_4096", "rsa_8192", "ecdsa", "ecdsa_256", "ecdsa_384", "ecdsa_521", "ed25519"];
+const DEFAULT_IDENTITY_TYPE = "ecdsa";
+
+const EXITCODE_SUCCESS = 0;
+const EXITCODE_ERROR = 1;
+const EXITCODE_ERROR_NO_PERMISSION = 2;
+const EXITCODE_ERROR_NOTHING_TO_UPLOAD = 4;
+const EXITCODE_ERROR_UNKNOWN_COMMAND = 255;
+
+// Optional imports
+@include_once("config.inc");
+@include_once("certs.inc");
+@include_once("util.inc");
+
+// --------------------------------------------------------------------------------------------------------------------
+// Main script logic
+
+function commandShowIdentity(array &$options): int
+{
+ $identity_type = trim(($options["identity-type"] ?: DEFAULT_IDENTITY_TYPE));
+ $source_ip = trim(($options["source-ip"] ?: ""));
+ $host = trim(($options["host"] ?: ""));
+
+ $keys = new SSHKeys(configPath());
+ if (($id_file = $keys->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 {
+ logger()->error("Failed getting identity. See log output for details.");
+ }
+ return EXITCODE_ERROR;
+}
+
+function commandTestConnection(array &$options): int
+{
+ $result = ["actions" => ["connecting"]];
+
+ // Testing connection
+ $sftp = connectWithServer($options, $error);
+ if ($result["success"] = ($sftp !== null)) {
+ $result["actions"][] = "connected";
+ $result["remote"] = [
+ "address" => $sftp->remote_address,
+ "path" => $sftp->pwd(),
+ ];
+ } else {
+ $result = array_merge($result, $error);
+ }
+
+ // Testing file upload
+ if ($result["success"]) {
+ $result["actions"][] = "upload-testing";
+ $file = temporaryFile();
+ $filename = "." . basename($file);
+
+ if ($error = $sftp->put($file, $filename)->lastError(3)) {
+ $result["success"] = false;
+ $result = array_merge($result, $error);
+ } else if ($error = $sftp->rm($filename)->lastError(3)) {
+ logger()->error("Failed removing upload test file '$filename'. Cause: " . json_encode($error, JSON_UNESCAPED_SLASHES));
+ }
+
+ if ($result["success"]) $result["actions"][] = "upload-tested";
+ }
+
+ 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"])) {
+ // Includes host, upload all certs to the same host.
+ 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 if (isset($options["host"])) {
+ return uploadCertificatesToHost($options);
+
+ } else {
+ logger()->error("No work to do, neither --host nor --certificates is present.");
+ return EXITCODE_ERROR_NOTHING_TO_UPLOAD;
+ }
+}
+
+function uploadCertificatesToHost(array $options): int
+{
+ $sftp = connectWithServer($options, $error);
+ if ($sftp === null) {
+ logger()->error("Aborting after connect failure.");
+ return $error["connect_failed"] ? EXITCODE_ERROR : EXITCODE_ERROR_NO_PERMISSION;
+ }
+
+ $username = $options["user"];
+
+ $remote_home = $sftp->pwd();
+ $remote_files = [];
+ $remote_path = ".";
+
+ $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;
+
+ // Collecting files to upload (sorted by target to reduce remote directory changes)
+ $files_to_upload = getFilesToUpload($options);
+ usort($files_to_upload, function (&$a, &$b) {
+ return $a["target"] <=> $b["target"];
+ });
+
+ // Uploading the files
+ foreach ($files_to_upload as $file) {
+ // Checking if source is valid.
+ if (!is_file($file["source"]) && is_readable($file["source"])) {
+ logger()->error("Skipping {$file["source"]}, it is not a file or not readable.");
+ continue;
+ }
+
+ // Changing remote directory if required.
+ if (($dir = dirname($file["target"])) !== $remote_path) {
+ $target_dir = $sftp->resolve($dir, $remote_home);
+
+ $error = $sftp->cd($target_dir)->lastError();
+ if ($error["file_not_found"]) {
+ logger()->info("Creating remote directory: $target_dir");
+ $sftp->clearError()
+ ->mkdir($target_dir)
+ ->cd($target_dir);
+ }
+
+ if ($error = $sftp->lastError()) {
+ logger()->error("Failed to cd into '$dir'. Cause: " . json_encode($error, JSON_UNESCAPED_SLASHES));
+ return EXITCODE_ERROR;
+ } else {
+ $remote_path = $dir;
+ $remote_files = $sftp->ls();
+ if ($error = $sftp->lastError()) {
+ logger()->error("Failed listing remote files. Cause: " . json_encode($error, JSON_UNESCAPED_SLASHES));
+ return EXITCODE_ERROR;
+ }
+ }
+ }
+
+ // Preparing copy
+ $remote_filename = basename($file["target"]);
+ $remote_file = $remote_files[$remote_filename] ?: ["type" => "-", "owner" => $username];
+ $remote_is_file = $remote_file["type"] === "-";
+ $remote_is_readonly = preg_match('/^-r-.r-.+$/', $remote_file["permissions"] ?: "");
+
+ // Check if a folder/socket/symlink, etc is in the way
+ if (!$remote_is_file) {
+ logger()->error("Skipping file '{$file["source"]}' as there is a non-file in the way at '{$file["target"]}'.");
+ continue;
+ }
+
+ $file_chmod = $file["is_key"] ? $chmod_key : $chmod;
+ $file_chmod = preg_match('/^0[0-9]{3}$/', $file_chmod) ? (string)$file_chmod : false;
+ $permission_change_retry_allowed = $file_chmod
+ && $remote_file["owner"] === $username
+ && isset($remote_files[$remote_filename]);
+
+ // Initial upload when permissions are properly set.
+ if (!$remote_is_readonly) {
+ if ($error = $sftp->put($file["source"], $remote_filename)->lastError()) {
+ logger()->error("Failed uploading file '{$file["source"]}' to '{$file["target"]}'. Cause: " . json_encode($error, JSON_UNESCAPED_SLASHES));
+
+ if ($error["permission_denied"] !== true)
+ $permission_change_retry_allowed = false;
+
+ if ($permission_change_retry_allowed)
+ logger()->info("Retrying file '{$file["source"]}' to '{$file["target"]}' with adjusted permissions.");
+ } else {
+ $permission_change_retry_allowed = false;
+ }
+ }
+
+ // Second upload when initial failed or was skipped due to write protection (only possible if we have chmod defined to reset permissions later).
+ if ($permission_change_retry_allowed) {
+ $sftp->chmod($remote_filename, '0600');
+ if ($error = $sftp->put($file["source"], $remote_filename)->lastError()) {
+ logger()->error("Failed uploading file '{$file["source"]}' to '{$file["target"]}'. Cause: " . json_encode($error, JSON_UNESCAPED_SLASHES));
+ return EXITCODE_ERROR_NO_PERMISSION;
+ }
+ } else if ($remote_is_readonly) {
+ logger()->error("Failed uploading file '{$file["source"]}' to '{$file["target"]}'. Existing file is write protected and --chmod[-key] options are missing.");
+ return EXITCODE_ERROR_NO_PERMISSION;
+ }
+
+ // Applying Chmod / chgrp if requested.
+ if ($file_chmod) {
+ if ($error = $sftp->chmod($remote_filename, $file_chmod)->lastError())
+ logger()->error("Failed chmod ($file_chmod) for '{$file["target"]}'. Cause: " . json_encode($error, JSON_UNESCAPED_SLASHES));
+ }
+
+ if (!empty($chgrp)) {
+ if ($error = $sftp->chgrp($remote_filename, $chgrp)->lastError())
+ logger()->error("Failed chgrp ($chgrp) for '{$file["target"]}'. Cause: " . json_encode($error, JSON_UNESCAPED_SLASHES));
+ }
+
+ $sftp->clearError();
+ }
+
+ return EXITCODE_SUCCESS;
+}
+
+function connectWithServer(array $options, &$error): ?SftpClient
+{
+ $identity_type = trim(($options["identity-type"] ?: 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);
+
+ if (!$sftp->connect($host, $username, $host_key, $port)) {
+ $error = $sftp->lastError();
+ $error["connect_failed"] = true;
+ return null;
+ }
+
+ // Apply start path (if one was specified, defaults to home dir)
+ if (($remote_path = $options["remote-path"])) {
+ if ($err = $sftp->cd($remote_path)->lastError()) {
+ $error = $err;
+ $error["change_home_dir_failed"] = true;
+ logger()->error("Failed cd into '{$remote_path}'. Cause: " . json_encode($err, JSON_UNESCAPED_SLASHES));
+ return null;
+ }
+ }
+
+ return $sftp;
+}
+
+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;
+}
+
+function getOptionsById($automation_id, $silent = false)
+{
+ if (!$silent) logger()->info("Reading options from automation: $automation_id");
+
+ if (is_object($action = getActionById($automation_id))) {
+ if ($action->enabled && "upload_sftp" === (string)$action->type) {
+ return [
+ "host" => trim((string)$action->sftp_host),
+ "host-key" => trim((string)$action->sftp_host_key),
+ "port" => trim((string)$action->sftp_port),
+ "identity-type" => trim((string)$action->sftp_identity_type),
+ "user" => trim((string)$action->sftp_user),
+ "remote-path" => trim((string)$action->sftp_remote_path),
+ "chgrp" => trim((string)$action->sftp_chgrp),
+ "chmod" => trim((string)$action->sftp_chmod),
+ "chmod-key" => trim((string)$action->sftp_chmod_key),
+ ];
+ } else if (!$silent) {
+ logger()->error("Ignoring disabled or invalid automation '$automation_id'");
+ }
+ } else {
+ logger()->error("No upload automation found with uuid = '$automation_id'");
+ }
+
+ return false;
+}
+
+function getFilesToUpload(array $options)
+{
+ $files = [];
+ $cert_ids = preg_split('/[,;\s]+/', $options["certificates"] ?: "", 0, PREG_SPLIT_NO_EMPTY);
+
+ if (class_exists("OPNsense\\Core\\Config")) {
+ foreach (findCertificates($cert_ids) as $cert) {
+ if (isset($cert["content"])) {
+ foreach ($cert["content"] as $name => $content) {
+ $source = temporaryFile();
+ $ok = file_put_contents($source, $content);
+
+ if (!$ok) {
+ logger()->error("Ignoring upload for cert '{$cert["name"]}', since the content cannot be prepared or is empty.");
+ continue;
+ }
+
+ if (($time = intval($cert["updated"])) && $time > 0)
+ touch($source, $time);
+
+ // Sanitize user input (allow unicode chars, numbers and some special characters)
+ $context_path = preg_replace('/[^\w\d_\-@.]+/uim', "-", $cert["name"]);
+
+ $file = ["source" => $source, "target" => "{$context_path}/{$name}.pem"];
+ $file["is_key"] = ($name === "key");
+ $files[] = $file;
+ }
+ } else {
+ logger()->error("Ignoring upload for cert '{$cert["name"]}', since it is not available in trust storage.");
+ }
+ }
+ } else {
+ // Allow to specify "--files" only if we're not running in opnsense environment (for development only).
+ if (isset($options["files"])) {
+ foreach (preg_split('/[,;\s]+/', $options["files"] ?: "", 0, PREG_SPLIT_NO_EMPTY) as $file) {
+ $files[] = ["source" => $file, "target" => "upload-test/" . basename($file)];
+ };
+ }
+ }
+
+ if (empty($files))
+ logger()->error("Didn't find any certificates to upload (cert-ids: " . (empty($cert_ids) ? "all" : join(", ", $cert_ids)) . ").");
+
+ return $files;
+}
+
+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))
+ logger()->error("Certificate '{$name}' (id: $id) is disabled, skipping it.");
+
+ continue;
+ }
+
+ $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)
+{
+ $result = [];
+ $config = OPNsense\Core\Config::getInstance()->object();
+ foreach ($config->cert as $cert) {
+ $refid = (string)$cert->refid;
+ $item = [];
+ if (in_array($refid, $cert_refids)) {
+ $item["cert"] = str_replace(["\n\n", "\r"], ["\n", ""], base64_decode($cert->crt));
+ $item["key"] = str_replace(["\n\n", "\r"], ["\n", ""], base64_decode($cert->prv));
+ // check if a CA is linked
+ if (!empty((string)$cert->caref)) {
+ $cert = (array)$cert;
+ $item["ca"] = ca_chain($cert);
+ }
+ $result[$refid] = $item;
+ }
+ }
+
+ return $result;
+}
+
+function configPath(): string
+{
+ static $paths = [
+ '/var/etc/acme-client',
+ __DIR__
+ ];
+ foreach ($paths as $path) {
+ if (is_dir($path)) 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"]))
+ logger(true)->info("Logging to stdout enabled");
+
+ register_shutdown_function(function () {
+ temporaryFile(true);
+ });
+
+ $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($options, getOptionsById($options["automation-id"]));
+
+ if (is_callable($runner = $command["implementation"])) {
+ $code = $runner($options);
+ if ($code != EXITCODE_SUCCESS) {
+ logger()->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);
+ logger()->error("Parsing of '$cmd' failed at argument '{$argv[$index]}'");
+ }
+ exit(1);
+ }
+}
+
+
+// --------------------------------------------------------------------------------------------------------------------
+// Utility functions
+
+interface ILogger
+{
+ function info($message);
+
+ function error($message);
+}
+
+function &logger($reconfigure_to_stdout = false): ILogger
+{
+ static $logger;
+ if (!$logger || $reconfigure_to_stdout) {
+ if (!$reconfigure_to_stdout && function_exists("log_error")) {
+ $logger = new class implements ILogger
+ {
+ function info($message) { syslog(SYSLOG_INFO_LEVEL, basename(__FILE__) . ": INFO: $message"); }
+
+ function error($message) { log_error($message); }
+ };
+ } else {
+ $logger = new class implements ILogger
+ {
+ function info($message) { echo "INFO: {$message}" . PHP_EOL; }
+
+ function error($message) { echo "ERROR: {$message}" . PHP_EOL; }
+ };
+ }
+ }
+ return $logger;
+}
+
+function requireThat($expression, $message)
+{
+ if (!$expression) {
+ logger()->error("FATAL: $message");
+ exit(EXITCODE_ERROR);
+ }
+}
+
+function resolvePath($file, $base = ".")
+{
+ if (!$base || $base[0] != DIRECTORY_SEPARATOR)
+ $base = realpath(($base ?: "."));
+
+ $path = [];
+ $combined_path = ((!empty($file) && $file[0] == DIRECTORY_SEPARATOR) ? $file : $base . DIRECTORY_SEPARATOR . $file);
+ foreach (explode(DIRECTORY_SEPARATOR, $combined_path) as $part) {
+ if (empty($part) || $part === '.')
+ continue;
+ if ($part !== '..')
+ array_push($path, $part);
+ else if (!empty($path))
+ array_pop($path);
+ else
+ return false;
+ }
+
+ return DIRECTORY_SEPARATOR . join(DIRECTORY_SEPARATOR, $path);
+}
+
+function temporaryFile($delete_all = false)
+{
+ static $__temporary_files = [];
+ if ($delete_all) {
+ foreach ($__temporary_files as $file)
+ unlink($file);
+ $__temporary_files = [];
+ } else {
+ if ($file = tempnam(sys_get_temp_dir(), "sftp-upload-")) {
+ $file = realpath($file);
+ $__temporary_files[] = $file;
+ requireThat(chmod($file, 0600), "failed setting user-only permissions on '$file'.");
+ return $file;
+ };
+ }
+ return false;
+}
+
+
+// --------------------------------------------------------------------------------------------------------------------
+// Classes
+
+
+/**
+ * Wrapper around the 'sftp' commandline client.
+ */
+class SftpClient
+{
+ public $remote_address;
+ private $identity_type;
+
+ /* @var false|array */
+ private $failed_status;
+ /* @var SSHKeys */
+ private $ssh_keys;
+ /* @var null|Process */
+ private $process = null;
+
+ public function __construct($config_path, $identity_type = DEFAULT_IDENTITY_TYPE)
+ {
+ $this->ssh_keys = new SSHKeys($config_path);
+ $this->identity_type = $identity_type;
+ }
+
+ public function __destruct()
+ {
+ $this->close();
+ }
+
+ public function connect($host, $username, $host_key = "", $port = 22)
+ {
+ if (empty(trim($host)) || empty(trim($username))) {
+ $this->failed_status = ["invalid_parameters" => true];
+ logger()->error("Failed connecting to '$host'. Hostname or username is missing.");
+ return false;
+ }
+
+ $trust = $this->ssh_keys->trustHost($host, $host_key, $port);
+ if ($trust["ok"] !== true) {
+ logger()->error("Failed establishing trust in '$host'; Cause: {$trust["error"]}");
+ unset($trust["ok"]);
+ $this->failed_status = array_merge($trust, ["host_not_trusted" => true]);
+ return false;
+ } else {
+ $host = $trust["host"];
+ }
+
+ // Building sftp command.
+ $cmd = [
+ "sftp",
+ "-P", $port,
+ "-oUser=$username",
+ "-oUserKnownHostsFile={$this->ssh_keys->knownHostsFile()}",
+ ];
+
+ // Handle client side identity
+ $identity = $this->ssh_keys->getIdentity($this->identity_type, true);
+ if (is_file($identity) && is_readable($identity)) {
+ array_push($cmd,
+ "-i", $identity,
+ "-oPreferredAuthentications=publickey");
+ } else {
+ logger()->error("Failed adding client identity ($identity). Connect will likely fail.");
+ }
+
+ // Adding the host
+ array_push($cmd, "$host");
+
+ // Creating the sftp process
+ if ($this->process = Process::open($cmd)) {
+ $this->processAvailableInput(120, 1);
+ if ($error = $this->lastError()) {
+ logger()->error("Failed connecting to '$host' (user: '$username'). Cause: " . json_encode($error));
+ return false;
+ }
+ $this->remote_address = strpos($host, ':') ? "[$host]:$port" : "$host:$port";
+ return true;
+ }
+ return false;
+ }
+
+ private function processAvailableInput(float $timeout = 0, $expected_lines = 0, Callable $lines_consumer = null)
+ {
+ requireThat($this->process !== null, "SFTP: process not connected");
+
+ 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'],
+ ["file_not_found", /* => */ '/.*(no such|not found).*/i'],
+ ["failure", /* => */ '/.*(error|failure|you must supply).*/i'],
+ ];
+
+ while (($line = $this->process->get($timeout)) !== false) {
+ if (--$expected_lines <= 0)
+ $timeout = 0;
+
+ foreach ($expected_errors as $ee) {
+ if (preg_match($ee[1], $line)) {
+ $this->failed_status = [$ee[0] => true, "error" => trim($line)];
+ break;
+ }
+ }
+
+ $hide = ($lines_consumer && $lines_consumer($line) === true);
+ if (!$hide)
+ logger()->info("SFTP: " . rtrim($line));
+ }
+ }
+
+ public function close()
+ {
+ if (($p = $this->process) !== null) {
+ $p->put("exit");
+ $p->closeInput();
+
+ $this->processAvailableInput(1.5);
+ $p->close();
+
+ $this->process = null;
+
+ if ($this->failed_status && $this->failed_status["connection_closed"])
+ $this->clearError();
+ }
+ }
+
+ public function lastError($timeout = 0.5)
+ {
+ if ($this->failed_status === false)
+ $this->processAvailableInput($timeout);
+ return $this->failed_status;
+ }
+
+ public function clearError()
+ {
+ $this->failed_status = false;
+ return $this;
+ }
+
+ public function ls()
+ {
+ $files = [];
+ $this->processAvailableInput();
+ $this->process->put("ls -la");
+
+ $regex = '/^([bcdlsp\-][rwx\-]{9}[+@]?)\s+[0-9]+\s+([^\s]+)\s+([^\s]+)\s+([0-9]+)\s+(\w+\s+[0-9]+\s+[0-9:]+)\s+(.+)$/';
+ $this->processAvailableInput(30, 2, function ($line) use (&$files, $regex) {
+ if (preg_match($regex, $line, $matches)) {
+ $files[trim($matches[6])] = [
+ "type" => $matches[1][0],
+ "permissions" => $matches[1],
+ "owner" => $matches[2],
+ "group" => $matches[3],
+ "size" => intval($matches[4]),
+ "mtime" => strtotime($matches[5])
+ ];
+ return true;
+ }
+ return false;
+ });
+
+ return $files;
+ }
+
+ public function pwd()
+ {
+ $remote_path = false;
+ $this->processAvailableInput();
+ $this->process->put("pwd");
+ $this->processAvailableInput(30, 2, function ($line) use (&$remote_path) {
+ if (preg_match('/^.+directory:\s(.+)$/i', $line, $matches))
+ $remote_path = trim($matches[1]);
+ });
+ return $remote_path;
+ }
+
+ public function resolve($remote_path, $remote_pwd = null)
+ {
+ if (($pwd = ($remote_pwd ?: $this->pwd())) !== false) {
+ $remote_path = resolvePath($remote_path, str_replace("/", DIRECTORY_SEPARATOR, $pwd));
+ $remote_path = str_replace("\\", "/", $remote_path);
+ return $remote_path;
+ }
+ return false;
+ }
+
+ public function get($remote_file, $local_file = "")
+ {
+ $this->processAvailableInput();
+ $this->process->put("get " . escapeshellarg($remote_file) . " " . (empty($local_file) ? "" : escapeshellarg($local_file)));
+ $this->processAvailableInput(30, 2);
+ return $this;
+ }
+
+ public function put($local_file, $remote_file = "")
+ {
+ if (is_file($local_file)) {
+ $this->processAvailableInput();
+ $this->process->put("put -p " . escapeshellarg($local_file) . " " . (empty($remote_file) ? "" : escapeshellarg($remote_file)));
+ $this->processAvailableInput(30, 2);
+ } else {
+ logger()->info("put: File $local_file doesn't exist.");
+ $this->failed_status = ["file_not_found" => true, "error" => $local_file];
+ }
+
+ return $this;
+ }
+
+ public function mkdir($remote_path)
+ {
+ if (($remote_path = $this->resolve($remote_path)) !== false) {
+ $this->process->put("mkdir " . escapeshellarg($remote_path));
+ $this->processAvailableInput(30, 1);
+ }
+ return $this;
+ }
+
+ public function cd($remote_path)
+ {
+ if (($remote_path = $this->resolve($remote_path)) !== false) {
+ $this->clearError();
+ $this->process->put("cd " . escapeshellarg($remote_path));
+
+ $this->processAvailableInput(30, 1);
+ $error = $this->lastError();
+ $pwd = false;
+
+ if ($error || $remote_path !== ($pwd = $this->pwd())) {
+ $this->failed_status = array_merge(($error ?: []), [
+ "failure" => true,
+ "error" => "Failed changing path to '$remote_path' (pwd: '$pwd'); Cause: {$error["error"]}"
+ ]);
+ }
+ }
+ return $this;
+ }
+
+ public function chmod($remote_file, $mode)
+ {
+ $this->processAvailableInput();
+ $this->process->put("chmod " . escapeshellarg($mode) . " " . escapeshellarg($remote_file));
+ $this->processAvailableInput(30, 2);
+ return $this;
+ }
+
+ public function chgrp($remote_file, $group_id)
+ {
+ $this->processAvailableInput();
+ $this->process->put("chgrp " . escapeshellarg($group_id) . " " . escapeshellarg($remote_file));
+ $this->processAvailableInput(30, 2);
+ return $this;
+ }
+
+ public function rm($remote_file)
+ {
+ $this->processAvailableInput();
+ $this->process->put("rm " . escapeshellarg($remote_file));
+ $this->processAvailableInput(30, 2);
+ return $this;
+ }
+}
+
+/**
+ * Utility class for managing SSH host (known_hosts) and identity keys to be used in {@see SftpClient}.
+ */
+class SSHKeys
+{
+ private $config_path;
+ private $known_hosts_file;
+
+ public function __construct($config_path)
+ {
+ if (!is_dir($config_path)) {
+ $dir_created = mkdir($config_path, CONFIG_PATH_CREATE_MODE, true);
+ requireThat($dir_created, "Failed creating directory '$config_path' with permission " . CONFIG_PATH_CREATE_MODE);
+ }
+
+ $this->config_path = realpath($config_path);
+ $this->known_hosts_file = resolvePath("known_hosts", $this->config_path);
+ }
+
+ public function knownHostsFile()
+ {
+ if (!is_file($this->known_hosts_file)) {
+ $file_created =
+ touch($this->known_hosts_file)
+ && chmod($this->known_hosts_file, KNOWN_HOSTS_FILE_CREATE_MODE);
+
+ requireThat($file_created, "Failed creating file '{$this->known_hosts_file}' with permission " . KNOWN_HOSTS_FILE_CREATE_MODE);
+ }
+
+ return $this->known_hosts_file;
+ }
+
+ public function trustHost($host, $host_key = "", $port = 22, $no_modification_allowed = false): array
+ {
+ requireThat(!empty(trim($host)), "Hostname must not be empty.");
+
+ // Convert the specified host_key to a data structure that can be compared
+ if (empty($host_key = trim($host_key))) {
+ $host_key = false;
+ } else {
+ $host_key = self::getHostKeyInfo($host_key);
+ if ($host_key === false)
+ return ["ok" => false, "error" => "Invalid host_key specified."];
+ }
+
+
+ // Check our current known_host file
+ $addKeyInfo = function (array &$key_list) {
+ foreach ($key_list as &$item) {
+ $item["key_info"] = self::getHostKeyInfo($item["host_key"]);
+ }
+ return array_filter($key_list, function (&$item) {
+ return $item["key_info"] !== false;
+ });
+ };
+
+ $known_keys = $addKeyInfo($this->getKnownHostKey($host));
+
+ // Find known_hosts item with same hostname
+ $known_by_host = array_reduce($known_keys, function ($found, $key) use ($host) {
+ return (!$found && !empty(trim($key["host"])) && strcasecmp(trim($host), trim($key["host"])) == 0)
+ ? $key
+ : $found;
+ }, false);
+
+ // Find known_hosts item with same public host-key
+ $known_by_key = array_reduce($known_keys, function ($found, $key) use ($host_key) {
+ return (!$found && $host_key && $host_key === $key["key_info"])
+ ? $key
+ : $found;
+ }, false);
+
+
+ // Updating $host and $host_key from known_hosts and check if we need to update known_hosts.
+ if ($host_key === false && $known_by_host) {
+ logger()->info("No host key specified, using existing known_hosts entry for '$host'");
+ $host_key = $known_by_host["key_info"];
+ }
+
+ $is_key_known = false;
+ if ($known_by_host && $host_key && $host_key === $known_by_host["key_info"]) {
+ $is_key_known = true;
+ } else if ($known_by_key) {
+ if (strcasecmp(trim($host), trim($known_by_key["host"])) != 0) {
+ logger()->info("Host key is in known_hosts but hostname differs. Changing '$host' to '{$known_by_key["host"]}'.");
+ $host = $known_by_key["host"];
+ }
+ $is_key_known = true;
+ }
+
+
+ // Check if we don't have a matching known_hosts entry and add or update it as required.
+ if (!$is_key_known && !$no_modification_allowed) {
+ $key_type = $host_key ? $host_key["key_type"] : DEFAULT_KEY_TYPE;
+
+ $remote_host_keys = $addKeyInfo($this->queryHostKey($host, $key_type, $port));
+ $matching_remote_host_keys = array_filter($remote_host_keys, function ($key) use ($host_key) {
+ return $key["key_info"] !== false && (!$host_key || $host_key === $key["key_info"]);
+ });
+
+ if (!empty($matching_remote_host_keys)) {
+ if ($known_by_host) {
+ logger()->info("Removing known_hosts entry with differing key for '{$known_by_host["host"]}' as it is in the way.");
+ $this->removeKnownHost($known_by_host["host"]);
+ }
+
+ foreach ($matching_remote_host_keys as $key) {
+ logger()->info("Adding known_hosts entry: " . json_encode($key["key_info"], JSON_UNESCAPED_SLASHES));
+ $ok = file_put_contents($this->knownHostsFile(), $key["host_key"] . PHP_EOL, FILE_APPEND);
+ if (!$ok)
+ logger()->error("Failed adding known_hosts entry {$key["host_key"]}");
+ }
+
+ // Verify that known_hosts contains the correct keys after adding them (using recursion).
+ return $this->trustHost($host, $matching_remote_host_keys[0]["host_key"], $port, true);
+
+ } else {
+ if (empty($remote_host_keys)) {
+ $msg = "No connection to '$host'; Failed querying host key from server.";
+ } else {
+ $msg = "Key mismatch for '$host'; "
+ . "The expected key (" . json_encode($host_key) . ") was not found in (" . json_encode($remote_host_keys) . ")";
+ }
+ return ["ok" => false, "error" => $msg];
+ }
+ }
+
+
+ if ($is_key_known) {
+ return ["ok" => true, "host" => $host, "key_info" => $host_key];
+ } else {
+ return ["ok" => false, "error" => "Host unknown and remote key cannot be queried."];
+ }
+ }
+
+ public static function getHostSearchList($host)
+ {
+ $search_list = [($host = strtolower($host))];
+
+ // Add IP-address to search list (IPv4 only)
+ $has_ip = ($ip = gethostbyname($host))
+ && ($ip !== $host || preg_match('/^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/', $ip));
+
+ if ($has_ip)
+ $search_list[] = strtolower($ip);
+
+ // Add FQDN to search list if reverse lookup provides a valid one.
+ $has_fqdn = $has_ip
+ && ($reverse_fqdn = gethostbyaddr($ip))
+ && $reverse_fqdn !== $ip
+ && gethostbyname($reverse_fqdn) === $ip;
+
+ if ($has_fqdn && isset($reverse_fqdn))
+ $search_list[] = strtolower($reverse_fqdn);
+
+ // Build unique search list (dedup list)
+ $search_list = array_filter($search_list, function ($value, $index) use (&$search_list) {
+ return !empty(trim($value)) && array_search($value, $search_list) == $index;
+ }, ARRAY_FILTER_USE_BOTH);
+
+ return $search_list;
+ }
+
+ public static function queryHostKey($host, $key_type = DEFAULT_KEY_TYPE, $port = 22)
+ {
+ $keys = [];
+ $failed = false;
+ $names = join(",", self::getHostSearchList($host));
+
+ if (!empty($names) && ($p = Process::open(["ssh-keyscan", "-p", $port, "-t", $key_type, $names]))) {
+ $lines = [];
+ while (($line = $p->get(60)) !== false) {
+ $line = trim($line);
+ if (empty($line) || $line[0] == "#")
+ continue;
+
+ if (preg_match('/.*(connect|write|broken|no route|not known).*/i', $line))
+ $failed = true;
+
+ $lines[] = $line;
+ }
+
+ if ($p->close() == 0 && !$failed) {
+ foreach ($lines as $line) {
+ $keys[] = ["host_key" => $line];
+ }
+ } else {
+ logger()->error("Failed querying public keys for [$names] ($host / $key_type). "
+ . "Exit code: {$p->exitCode} (failed flag: $failed) ; " . PHP_EOL
+ . "ssh-keyscan: " . join(PHP_EOL . "ssh-keyscan: ", $lines));
+ }
+ }
+
+ if (empty($keys))
+ logger()->info("Couldn't fetch public host key ($key_type) from $host");
+
+ return $keys;
+ }
+
+
+ public function getKnownHostKey($host)
+ {
+ $keys = [];
+ foreach (self::getHostSearchList($host) as $name_or_ip) {
+ if ($p = Process::open(["ssh-keygen", "-F", $name_or_ip, "-f", $this->knownHostsFile()])) {
+ $lines = [];
+ while (($line = $p->get()) !== false) {
+ $line = trim($line);
+ if (empty($line) || $line[0] == "#")
+ continue;
+
+ $lines[] = $line;
+ }
+
+ if ($p->close() == 0) {
+ $keys[] = ["host" => $name_or_ip, "host_key" => $lines[0]];
+ } else if ($p->exitCode != 1 /* 1 == NOT_FOUND */) {
+ logger()->error("Failed querying known hosts for $name_or_ip ($host). Return code was: {$p->exitCode}"
+ . " ; " . PHP_EOL . join(PHP_EOL, $lines));
+ }
+ }
+ }
+
+ if (empty($keys))
+ logger()->info("Didn't find $host in known_hosts");
+
+ return $keys;
+ }
+
+ public function removeKnownHost($host)
+ {
+ $ok = false;
+ if ($p = Process::open(["ssh-keygen", "-R", $host, "-f", $this->knownHostsFile()])) {
+ $ok = $p->close() === 0;
+ if (!$ok)
+ logger()->error("Failed removing known hosts for $host. Return code was: {$p->exitCode}");
+ }
+ return $ok;
+ }
+
+ public static function getHostKeyInfo($host_key)
+ {
+ if ($p = Process::open(["ssh-keygen", "-l", "-f", "-"])) {
+ $p->put($host_key);
+ $p->closeInput();
+
+ if (($hash = $p->get()) && preg_match('/^([0-9]+) (.+?) .+? \(([^()]+)\)$/', $hash, $matches)) {
+ return ["hash" => $matches[2], "key_type" => $matches[3], "key_length" => $matches[1]];
+ } else {
+ logger()->error("Unsupported hash type: $hash");
+ }
+ }
+
+ logger()->error("Failed getting hash for host_key");
+ return false;
+ }
+
+ // Returns the path to the public identity key file, generating it if missing (optionally returns the private key path)
+ public function getIdentity($identity_type = DEFAULT_IDENTITY_TYPE, $private = false)
+ {
+ requireThat(in_array($identity_type, IDENTITY_TYPES), "Identity type $identity_type unknown.");
+
+ list($key_type, $key_size) = explode('_', $identity_type, 2);
+ if (!$key_size && DEFAULT_IDENTITY_KEY_BITS[$key_type] > 0)
+ $key_size = DEFAULT_IDENTITY_KEY_BITS[$key_type];
+
+ $identity_path = "{$this->config_path}/id.{$identity_type}";
+
+ if (!file_exists($identity_path)) {
+ $generate_key = [
+ "ssh-keygen", "-v",
+ "-f", $identity_path,
+ "-t", $key_type,
+ "-N", "",
+ ];
+
+ if (intval($key_size) > 0)
+ array_push($generate_key, "-b", $key_size);
+
+ if ($p = Process::open($generate_key)) {
+ while (($line = $p->get(10)) !== false) {
+ logger()->info("SSH keygen: $line");
+ }
+
+ requireThat($p->close() == 0,
+ "Failed generating identity $identity_path: Error code: {$p->exitCode}" . PHP_EOL
+ . "Command: " . join(" ", $generate_key));
+ }
+ }
+
+ return $private ? $identity_path : "{$identity_path}.pub";
+ }
+
+ public static function getIdentityRestrictions($host = "", $source_ip = "")
+ {
+ $restrictions = ['restrict', 'command="internal-sftp"'];
+
+ $restrict_ip = empty(trim($source_ip))
+ ? (empty(trim($host)) ? false : self::getOutgoingIpFor($host))
+ : $source_ip;
+
+ if ($restrict_ip)
+ $restrictions[] = 'from="' . $restrict_ip . '"';
+
+ return join(",", $restrictions);
+ }
+
+ public static function getOutgoingIpFor($host)
+ {
+ $ip = gethostbyname($host);
+ $interface = null;
+
+ if ($p = Process::open(["route", "-n", "get", $ip])) {
+ while (($line = $p->get(10)) !== false)
+ if (preg_match('/\s*interface:\s*([^\s]+).*$/', $line, $matches)) {
+ $interface = $matches[1];
+ }
+ }
+
+ if ($interface && $p = Process::open(["ifconfig", $interface, "inet"])) {
+ while (($line = $p->get(10)) !== false)
+ if (preg_match('/\s*inet\s+([^\s]+)\s+netmask.*/', $line, $matches)) {
+ return $matches[1];
+ }
+ }
+
+ return false;
+ }
+}
+
+/**
+ * Utility class to execute shell processes and handle their IO.
+ */
+class Process
+{
+ private $handle;
+ private $inputs;
+ private $outputs;
+
+ public $exitCode = null;
+
+ // Starts the specified process and returns an object to manage it.
+ public static function open(array $cmd, $cwd = null, $env = null): Process
+ {
+ global $__process_terminate_hook, $__open_processes;
+ if (!is_array($__open_processes))
+ $__open_processes = [];
+
+ // Ensure we never leave zombies around: Hooking into script shutdown and kill processes that are still running.
+ if (!$__process_terminate_hook) {
+ register_shutdown_function($__process_terminate_hook = function () {
+ global $__open_processes;
+ foreach ($__open_processes as $handle) {
+ if (is_resource($handle)) {
+ logger()->error("Terminating process: " . json_encode(proc_get_status($handle)));
+ @proc_terminate($handle);
+ }
+ }
+ });
+ }
+
+ $p = new Process($cmd, $cwd, $env);
+ return $p->isRunning() ? $p : null;
+ }
+
+ public function __construct($cmd, $cwd = null, $env = null)
+ {
+ $cmd = join(" ", array_map(function ($v) {
+ return escapeshellarg($v);
+ }, $cmd));
+
+ $spec = [0 => ["pipe", "r"], 1 => ["pipe", "w"], 2 => ["pipe", "w"]];
+ $this->handle = proc_open($cmd, $spec, $pipes, $cwd, $env);
+
+ if (is_resource($this->handle)) {
+ $this->outputs = $pipes;
+ $this->inputs = [array_shift($this->outputs)];
+
+ foreach ($this->outputs as $stream)
+ stream_set_blocking($stream, false);
+
+ global $__open_processes;
+ $__open_processes[] = $this->handle;
+ } else {
+ logger()->error("Failed opening '$cmd' in '$cwd'");
+ }
+ }
+
+ public function __destruct()
+ {
+ $this->close();
+ if ($this->isRunning())
+ $this->close(true);
+ }
+
+ public function get($timeout = 5, $max_length = 8192, $ending = PHP_EOL)
+ {
+ $readables = array_filter($this->outputs, function ($stream) {
+ return is_resource($stream) && !feof($stream);
+ });
+
+ $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);
+
+ return is_resource($stream)
+ ? stream_get_line($stream, $max_length, $ending)
+ : false;
+ }
+
+ public function put($data, $append = PHP_EOL)
+ {
+ if ($this->isRunning() && is_resource($stdin = $this->inputs[0]) && !feof($stdin)) {
+ fwrite($stdin, $data);
+ if ($append)
+ fwrite($stdin, $append);
+ }
+ }
+
+ public function closeInput()
+ {
+ if (!feof($stdin = $this->inputs[0])) fclose($stdin);
+ }
+
+ public function close($force = false)
+ {
+ global $__open_processes;
+
+ // Read up-to 10k remaining lines from STDOUT/ERR to release locks before closing.
+ for ($i = 0; ($line = $this->get(0)) && $i < 10000; $i++) {
+ logger()->error("WARN: process: $line");
+ };
+
+ if ($this->isRunning())
+ $this->exitCode = ($force ? proc_terminate($this->handle) : proc_close($this->handle));
+
+ if (!$this->isRunning() && in_array($this->handle, $__open_processes))
+ $__open_processes = array_diff($__open_processes, [$this->handle]);
+
+ return $this->exitCode;
+ }
+
+ public function isRunning()
+ {
+ $status = is_resource($this->handle) ? proc_get_status($this->handle) : false;
+ if (is_array($status)) {
+ if (!$this->exitCode && $this->exitCode !== 0 && !$status["running"])
+ $this->exitCode = $status["exitcode"];
+ return $status["running"];
+ }
+ return false;
+ }
+}
+
+
+// Calling main if we have been called via CLI
+if (isset($GLOBALS["argc"])) main();
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 99858b1aa..0637f185b 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
@@ -84,6 +84,24 @@ parameters:-c %s -a %s
type:script
message:uploading a certificate to highwinds
+[upload-sftp]
+command:/usr/local/opnsense/scripts/OPNsense/AcmeClient/upload_sftp.php
+parameters:--certificates=%s --automation-id=%s
+type:script
+message:uploading a certificate to sftp server
+
+[test-sftp-connection]
+command:/usr/local/opnsense/scripts/OPNsense/AcmeClient/upload_sftp.php
+parameters:--host=%s --host-key=%s --port=%s --user=%s --identity-type=%s --remote-path=%s --no-error test-connection
+type:script_output
+message:testing connection to sftp server
+
+[show-sftp-identity]
+command:/usr/local/opnsense/scripts/OPNsense/AcmeClient/upload_sftp.php
+parameters:--identity-type=%s --host=%s show-identity
+type:script_output
+message:prints the public key used to connect to sftp 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: