Merge pull request #2904 from jkellerer/ft-ssh-automation

security/acme-client: SSH remote command automation (#2757)
This commit is contained in:
Frank Wall
2022-05-01 18:02:47 +02:00
committed by GitHub
13 changed files with 789 additions and 166 deletions
@@ -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()) {
@@ -142,6 +142,49 @@
Leave blank to use default "{{name}}/fullchain.pem".</help>
<advanced>true</advanced>
</field>
<field>
<label>Required Parameters</label>
<type>header</type>
<style>method_table method_table_configd_remote_ssh</style>
</field>
<field>
<id>action.remote_ssh_host</id>
<label>SSH Host</label>
<type>text</type>
<help>IP address or hostname of the SSH server.</help>
</field>
<field>
<id>action.remote_ssh_port</id>
<label>SSH Port</label>
<type>text</type>
<help>SSH server port. Leave blank to use default "22".</help>
<advanced>true</advanced>
</field>
<field>
<id>action.remote_ssh_key</id>
<label>Host Key</label>
<type>text</type>
<help>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).</help>
</field>
<field>
<id>action.remote_ssh_user</id>
<label>Username</label>
<type>text</type>
<help>The username to login to the SSH server.</help>
</field>
<field>
<id>action.remote_ssh_identity_type</id>
<label>Identity Type</label>
<type>dropdown</type>
<help>The type of identify to present to the SSH server for authorization. Select 'none' to use default "ECDSA".</help>
</field>
<field>
<id>action.remote_ssh_command</id>
<label>Command</label>
<type>text</type>
<help>The command to execute on the SSH server.</help>
</field>
<field>
<label>Required Parameters</label>
<type>header</type>
@@ -0,0 +1,45 @@
<?php
/*
* Copyright (C) 2020-2022 Juergen Kellerer
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
* AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
namespace OPNsense\AcmeClient\LeAutomation;
use OPNsense\AcmeClient\LeAutomationInterface;
/**
* Run remote command on arbitrary hosts using SSH
* @package OPNsense\AcmeClient
*/
class ConfigdRemoteSsh extends Base implements LeAutomationInterface
{
public function prepare()
{
$command = 'acmeclient run-remote-ssh-command ' . $this->config->id;
$this->command = $command;
return true;
}
}
@@ -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)
@@ -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];
}
@@ -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();
}
}
@@ -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] = [];
}
@@ -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;
}
}
@@ -1113,6 +1113,7 @@
<configd_restart_nginx>Restart Nginx (OPNsense plugin)</configd_restart_nginx>
<configd_upload_highwinds>Upload certificate to Highwinds CDN</configd_upload_highwinds>
<configd_upload_sftp>Upload certificate via SFTP</configd_upload_sftp>
<configd_remote_ssh>Remote Command via SSH</configd_remote_ssh>
<acme_fritzbox>Upload certificate to FRITZ!Box router</acme_fritzbox>
<acme_synology_dsm>Upload certificate to Synology DSM</acme_synology_dsm>
<acme_unifi>Update local Unifi keystore</acme_unifi>
@@ -1205,6 +1206,43 @@
<ValidationMessage>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 '/'.</ValidationMessage>
</sftp_filename_fullchain>
<remote_ssh_host type="TextField">
<Required>N</Required>
<mask>/^.{1,255}$/u</mask>
<ValidationMessage>Should be a string between 1 and 255 characters.</ValidationMessage>
</remote_ssh_host>
<remote_ssh_host_key type="TextField">
<Required>N</Required>
<!-- Key format: (comment)? key-type :SPACE: key-base64 (:SPACE: comment)?
Reference: https://stackoverflow.com/a/475217 -->
<mask>/^.+?\s(?:[a-z0-9+\/]{4})*(?:[a-z0-9+\/]{2}==|[a-z0-9+\/]{3}=)?(?:\s.+?)?$/i</mask>
<ValidationMessage>Should be a valid public SSH host key (see "known_hosts").</ValidationMessage>
</remote_ssh_host_key>
<remote_ssh_port type="IntegerField">
<Required>N</Required>
<MinimumValue>1</MinimumValue>
<MaximumValue>49151</MaximumValue>
<default>22</default>
<ValidationMessage>Should be a valid port number between 1 and 49151.</ValidationMessage>
</remote_ssh_port>
<remote_ssh_user type="TextField">
<Required>N</Required>
<mask>/^.{1,128}$/u</mask>
<ValidationMessage>Should be a string between 1 and 128 characters.</ValidationMessage>
</remote_ssh_user>
<remote_ssh_identity_type type="OptionField">
<Required>N</Required>
<OptionValues>
<ecdsa>ECDSA</ecdsa>
<rsa>RSA</rsa>
<ed25519>ed25519</ed25519>
</OptionValues>
</remote_ssh_identity_type>
<remote_ssh_command type="TextField">
<Required>N</Required>
<mask>/^.{1,1024}$/u</mask>
<ValidationMessage>Should be a shell command between 1 and 1024 characters.</ValidationMessage>
</remote_ssh_command>
<!-- old value, should be removed in next major release -->
<configd type="ConfigdActionsField">
<filters>
@@ -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(
'<div class="message"></div>'
+ '<div class="detail-enabler" style="cursor: pointer"><i class="fa fa-plus-square"></i></div>'
@@ -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();
@@ -0,0 +1,355 @@
#!/usr/local/bin/php
<?php
/*
* Copyright (C) 2022 Juergen Kellerer
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice,
* this list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in the
* documentation and/or other materials provided with the distribution.
*
* THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES,
* INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY
* AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
* AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY,
* OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
const ABOUT = <<<TXT
This script implements remote command execution using SSH. It reuses
identities and "known_hosts" management from SFTP, located inside the
local configuration folder (see "upload_sftp.php" for details).
Primary purpose is to support the creation of automation tasks that trigger
actions after a certificate has been uploaded (requires that actions can
have an execution order).
In addition to automations, all operations can also be triggered manually
using simple CLI commands.
See: EXAMPLES & actions_acmeclient.conf
TXT;
// Commands & help
const COMMANDS = [
"run-command" => [
"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 = <<<TXT
- Show the public key used to communicate with the SSH server
./run_remote_ssh.php --log --identity-type=ecdsa show-identity
- Test connectivity with host
./run_remote_ssh.php --log --host=sshpserver --user=name test-connection
- Run a command at the specific server
./run_remote_ssh.php --log --host=sshserver --user=name --run='/bin/sh -c "pwd && ls -la"'
- Load settings from automation with ID and run the command
./run_remote_ssh.php --log --automation-id=ID
TXT;
// Connection test
const CONNECTION_TEST_RESULT = 'OpnSense_ACME_SSH_Connected';
const CONNECTION_TEST_COMMAND = 'echo "' . CONNECTION_TEST_RESULT . '"';
const CONNECTION_EXECUTE_TIMEOUT = 60 * 7; // Max seconds that a command may run
// Exit codes
const EXITCODE_SUCCESS = 0;
const EXITCODE_ERROR = 1;
const EXITCODE_ERROR_UNKNOWN_COMMAND = 254;
// Optional imports
@include_once("config.inc");
@include_once("certs.inc");
@include_once("util.inc");
// Optional autoloader (for local dev environment)
if (!function_exists("log_error")) {
spl_autoload_register(function ($class_name) {
require_once(__DIR__ . "/../../../mvc/app/library/" . str_replace("\\", "/", $class_name) . ".php");
});
}
// Importing classes
use OPNsense\AcmeClient\Process;
use OPNsense\AcmeClient\SSHKeys;
use OPNsense\AcmeClient\Utils;
// Implementing logic
function commandShowIdentity(array &$options): int
{
$identity_type = trim(($options["identity-type"] ?? "")) ?: SSHKeys::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 {
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
);
@@ -86,13 +86,6 @@ const COMMANDS = [
],
];
const STATIC_OPTIONS = <<<TXT
-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)
TXT;
const EXAMPLES = <<<TXT
- Show the public key used to communicate with the SFTP server
./upload_sftp.php --log --identity-type=ecdsa show-identity
@@ -150,9 +143,9 @@ use OPNsense\AcmeClient\Utils;
// Implementing logic
function commandShowIdentity(array &$options): int
{
$identity_type = trim(($options["identity-type"] ?: SSHKeys::DEFAULT_IDENTITY_TYPE));
$source_ip = trim(($options["source-ip"] ?: ""));
$host = trim(($options["host"] ?: ""));
$identity_type = trim(($options["identity-type"] ?? "")) ?: SSHKeys::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)) {
@@ -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
);
@@ -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: