Merge pull request #1455 from jkellerer/ft-acmeclient-sftp-upload

AcmeClient support cert upload via sftp
This commit is contained in:
Frank Wall
2019-09-10 21:31:52 +02:00
committed by GitHub
13 changed files with 2604 additions and 2 deletions
@@ -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", "sftp_chmod", "sftp_chgrp"])) {
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;
}
}
@@ -40,6 +40,99 @@
<type>text</type>
<help>Access token for Highwinds API.</help>
</field>
<field>
<label>Required Parameters</label>
<type>header</type>
<style>method_table method_table_upload_sftp</style>
</field>
<field>
<id>action.sftp_host</id>
<label>SFTP Host</label>
<type>text</type>
<help>IP address or hostname of the SFTP server.</help>
</field>
<field>
<id>action.sftp_port</id>
<label>SFTP Port</label>
<type>text</type>
<help>SFTP server port. Leave blank to use default "22".</help>
<advanced>true</advanced>
</field>
<field>
<id>action.sftp_host_key</id>
<label>Host Key</label>
<type>text</type>
<help>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).</help>
</field>
<field>
<id>action.sftp_user</id>
<label>Username</label>
<type>text</type>
<help>The username to login to the SFTP server.</help>
</field>
<field>
<id>action.sftp_identity_type</id>
<label>Identity Type</label>
<type>dropdown</type>
<help>The type of identify to present to the SFTP server for authorization. Select 'none' to use default "ECDSA".</help>
</field>
<field>
<id>action.sftp_remote_path</id>
<label>Remote Path</label>
<type>text</type>
<help>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.</help>
</field>
<field>
<id>action.sftp_chmod</id>
<label>Permission (Public Keys)</label>
<type>text</type>
<help>Unix permission to apply to uploaded public keys. Leave blank to use default "0440".</help>
<advanced>true</advanced>
</field>
<field>
<id>action.sftp_chmod_key</id>
<label>Permission (Private Keys)</label>
<type>text</type>
<help>Unix permission to apply to uploaded private keys. Leave blank to use default "0400".</help>
<advanced>true</advanced>
</field>
<field>
<id>action.sftp_chgrp</id>
<label>Group</label>
<type>text</type>
<help>Unix group id to apply to all uploaded files. Leave blank to not change the group.</help>
<advanced>true</advanced>
</field>
<field>
<id>action.sftp_filename_cert</id>
<label>Naming "cert.pem"</label>
<type>text</type>
<help>Name template for the public certificate.
Placeholders "{{name}}" and "%s" are replaced by the name of the certificate being uploaded.
Leave blank to use default "{{name}}/cert.pem".</help>
<advanced>true</advanced>
</field>
<field>
<id>action.sftp_filename_key</id>
<label>Naming "key.pem"</label>
<type>text</type>
<help>Name template for the certificate's private key.
Placeholders "{{name}}" and "%s" are replaced by the name of the certificate being uploaded.
Leave blank to use default "{{name}}/key.pem".</help>
<advanced>true</advanced>
</field>
<field>
<id>action.sftp_filename_ca</id>
<label>Naming "ca.pem"</label>
<type>text</type>
<help>Name template for the public certificate chain file.
Placeholders "{{name}}" and "%s" are replaced by the name of the certificate being uploaded.
Leave blank to use default "{{name}}/ca.pem".</help>
<advanced>true</advanced>
</field>
<field>
<label>Required Parameters</label>
<type>header</type>
@@ -0,0 +1,180 @@
<?php
/*
* Copyright (C) 2019 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;
/**
* Utility class to execute shell processes and handle their IO.
* @package OPNsense\AcmeClient
*/
class Process
{
private $handle;
private $inputs;
private $outputs;
public $exitCode = null;
/**
* Starts the specified process and returns an object to manage it.
* @param array $cmd the command to run.
* @param null $cwd the working directory or null to use current.
* @param null $env the environment or null to use current.
* @return Process|null A process instance or null when startup failed.
*/
public static function open(array $cmd, $cwd = null, $env = null): ?Process
{
$p = new Process($cmd, $cwd, $env);
return $p->isRunning() ? $p : null;
}
private static function manageOpenedProcess($process_handle, $release = false)
{
static $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.
register_shutdown_function(function () use (&$open_processes) {
foreach ($open_processes as $handle) {
if (is_resource($handle)) {
Utils::log()->error("Terminating process: " . json_encode(proc_get_status($handle)));
@proc_terminate($handle);
}
}
$open_processes = [];
});
}
if ($process_handle) {
if ($release) {
if (in_array($process_handle, $open_processes))
$open_processes = array_diff($open_processes, [$process_handle]);
} else {
$open_processes[] = $process_handle;
}
}
}
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);
self::manageOpenedProcess($this->handle);
} else {
Utils::log()->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)
{
// Read up-to 10k remaining lines from STDOUT/ERR to release locks before closing.
for ($i = 0; ($line = $this->get(0)) && $i < 10000; $i++) {
Utils::log()->error("WARN: process: $line");
}
if ($this->isRunning()) {
$this->exitCode = $force
? proc_terminate($this->handle)
: proc_close($this->handle);
}
if (!$this->isRunning()) {
self::manageOpenedProcess($this->handle, true);
}
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;
}
}
@@ -0,0 +1,335 @@
<?php
/*
* Copyright (C) 2019 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;
/**
* Wrapper around the 'sftp' commandline client.
* @package OPNsense\AcmeClient
*/
class SftpClient
{
private const CONNECT_REPLY_TIMEOUT = 120;
private const COMMAND_REPLY_TIMEOUT = 30;
private $connection_info = [];
private $identity_type;
/* @var false|array */
private $failed_status;
/* @var SSHKeys */
private $ssh_keys;
/* @var null|Process */
private $process = null;
/* @var null|string */
private $pwd = null;
public function __construct($config_path, $identity_type = SSHKeys::DEFAULT_IDENTITY_TYPE)
{
$this->ssh_keys = new SSHKeys($config_path);
$this->identity_type = $identity_type;
}
public function __destruct()
{
$this->close();
}
public function connected(): ?array
{
return $this->process && $this->process->isRunning()
? $this->connection_info
: null;
}
public function connect($host, $username, $host_key = "", $port = SSHKeys::DEFAULT_PORT)
{
if (empty(trim($host)) || empty(trim($username))) {
$this->failed_status = ["invalid_parameters" => true];
Utils::log()->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) {
Utils::log()->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 {
Utils::log()->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(self::CONNECT_REPLY_TIMEOUT, 1, null, 0.75);
if (($error = $this->lastError()) || !$this->process->isRunning()) {
Utils::log()->error("Failed connecting to '$host' (user: '$username')", $error);
return false;
}
$this->connection_info = ["host" => $host, "port" => $port, "user" => $username];
return true;
}
return false;
}
private function processAvailableInput(float $timeout = 0, $expected_lines = 0, Callable $lines_consumer = null, $remaining_timeout = 0)
{
Utils::requireThat($this->process !== null, "SFTP: process not connected");
// Error list matching output from "sftp". Keep names in sync with similar list in SSHKeys.
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) {
foreach ($expected_errors as $ee) {
if (preg_match($ee[1], $line)) {
if (!$this->failed_status || $ee[0] !== "connection_closed")
$this->failed_status = [$ee[0] => true, "error" => trim($line)];
break;
}
}
$consumed = ($lines_consumer && $lines_consumer($line) === true);
if (!$consumed)
Utils::log()->info("SFTP: " . rtrim($line));
if (!$lines_consumer || $consumed) {
if (--$expected_lines <= 0) $timeout = $remaining_timeout;
}
}
}
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(self::COMMAND_REPLY_TIMEOUT, 2, function ($line) use (&$files, $regex) {
if (preg_match($regex, $line, $matches)) {
$filename = trim(stripcslashes($matches[6])); // decodes octal UTF-8 sequences
$files[$filename] = [
"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;
}, 1);
return $files;
}
public function pwd()
{
if ($this->pwd === null) {
$remote_path = false;
$this->processAvailableInput();
$this->process->put("pwd");
$this->processAvailableInput(self::COMMAND_REPLY_TIMEOUT, 1, function ($line) use (&$remote_path) {
if (preg_match('/^.+directory:\s(.+)$/i', $line, $matches)) {
$remote_path = trim(stripcslashes($matches[1]));
return true;
}
return false;
});
$this->pwd = $remote_path;
}
return $this->pwd;
}
/**
* Returns the absolute remote path for the specified file.
* @param string $remote_path the relative path.
* @param string|null $remote_pwd the remote base directory. Omit to use current.
* @return bool|string An absolute path.
*/
public function resolve(string $remote_path, ?string $remote_pwd = null)
{
if (($pwd = ($remote_pwd ?: $this->pwd())) !== false) {
$remote_path = Utils::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(self::COMMAND_REPLY_TIMEOUT, 2);
return $this;
}
public function put($local_file, $remote_file = "", $preserve = true)
{
if (is_file($local_file)) {
$this->processAvailableInput();
$this->process->put("put " . ($preserve ? "-p " : "")
. escapeshellarg($local_file)
. (empty($remote_file) ? "" : " " . escapeshellarg($remote_file)));
$this->processAvailableInput(self::COMMAND_REPLY_TIMEOUT, 2);
} else {
Utils::log()->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(self::COMMAND_REPLY_TIMEOUT, 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(self::COMMAND_REPLY_TIMEOUT, 1);
$error = $this->lastError();
$pwd = false;
$this->pwd = null;
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)
{
if (($remote_file = $this->resolve($remote_file)) !== false && $remote_file !== $this->pwd()) {
$this->processAvailableInput();
$this->process->put("chmod " . escapeshellarg($mode) . " " . escapeshellarg($remote_file));
$this->processAvailableInput(self::COMMAND_REPLY_TIMEOUT, 2);
}
return $this;
}
public function chgrp($remote_file, $group_id)
{
if (($remote_file = $this->resolve($remote_file)) !== false && $remote_file !== $this->pwd()) {
$this->processAvailableInput();
$this->process->put("chgrp " . escapeshellarg($group_id) . " " . escapeshellarg($remote_file));
$this->processAvailableInput(self::COMMAND_REPLY_TIMEOUT, 2);
}
return $this;
}
public function rm($remote_file)
{
if (($remote_file = $this->resolve($remote_file)) !== false && $remote_file !== $this->pwd()) {
$this->processAvailableInput();
$this->process->put("rm " . escapeshellarg($remote_file));
$this->processAvailableInput(self::COMMAND_REPLY_TIMEOUT, 2);
}
return $this;
}
}
@@ -0,0 +1,392 @@
<?php
/*
* Copyright (C) 2019 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;
/**
* Handles file uploads via SFTP.
* @package OPNsense\AcmeClient
*/
class SftpUploader
{
public const UPLOAD_SUCCESS = 0;
public const UPLOAD_ERROR = 1;
public const UPLOAD_ERROR_NO_PERMISSION = 2;
public const UPLOAD_ERROR_NO_OVERWRITE = 3;
public const UPLOAD_ERROR_CHMOD_FAILED = 4;
public const UPLOAD_ERROR_CHGRP_FAILED = 5;
/* @var SftpClient */
private $sftp;
private $pending_files = [];
private $pending_base_path = "";
private $current_file = "";
private $temporary_files_index = -1;
public function __construct(SftpClient &$sftp)
{
$this->sftp = $sftp;
}
public function __destruct()
{
$this->temporaryFile(true);
}
/**
* Add a file to upload.
*
* @param string $local_file the path to the local file.
* @param string $remote_file the remote path to copy the file to or empty to use the files name.
* @param bool $chmod the 4 digit unix permission to apply or false to leave it unchanged.
* @param bool $chgrp the numeric group on the remote server to apply or false to leave it unchanged.
* @return string the name of the normalized local file.
*/
public function addFile(string $local_file, $remote_file = "", $chmod = false, $chgrp = false): string
{
Utils::requireThat(is_file($local_file) && is_readable($local_file), "Not a file or not readable: '$local_file'");
$local_file = realpath($local_file);
$this->deleteSourceIfRequested($local_file);
$this->pending_files[$local_file] = [
"source" => $local_file,
"target" => $remote_file,
"mode" => $chmod,
"group" => $chgrp
];
return $local_file;
}
/**
* Adds content to upload.
*
* @param string $content the binary content to upload.
* @param string $remote_file the remote path to copy the file to.
* @param int $content_last_modified the unix timestamp when the content was last modified (is preserved when chmod is also specified).
* @param bool $chmod the 4 digit unix permission to apply or false to leave it unchanged.
* @param bool $chgrp the numeric group on the remote server to apply or false to leave it unchanged.
* @return string the name of the remote file.
*/
public function addContent(string $content, string $remote_file = "", $content_last_modified = 0, $chmod = false, $chgrp = false): string
{
$local_file = $this->temporaryFile();
Utils::requireThat($local_file, "Failed creating temporary file for '$remote_file'");
$content_written = file_put_contents($local_file, $content);
Utils::requireThat($content_written > 0, "Failed writing content of '$remote_file' to '$local_file', disk full?");
if (($time = intval($content_last_modified)) && $time > 0)
touch($local_file, $time);
$remote_file = trim($remote_file);
if (empty($remote_file))
$remote_file = basename($local_file);
$local_file = $this->addFile($local_file, $remote_file, $chmod, $chgrp);
$this->pending_files[$local_file]["delete_source"] = true;
return $remote_file;
}
/**
* @return array a list of files pending an upload.
*/
public function pending(): array
{
return array_values($this->pending_files);
}
/**
* @return array|null The current file being uploaded or failed to upload. Null when no upload was performed or when it succeeded.
*/
public function current(): ?array
{
return empty($this->current_file) || empty($this->pending_files)
? null
: $this->pending_files[$this->current_file];
}
/**
* Uploads all files from the pending list.
* @return int a return code indicating whether upload was successful or stopped.
*/
public function upload(): int
{
// Correct state when we are restarted after an error
if ($this->current_file) {
// Restore the remote path to where we originally have been.
if ($this->pending_base_path) {
$error = $this->sftp
->clearError()
->cd($this->pending_base_path)
->lastError();
if ($error) {
Utils::log()->error("Cannot continue since changing to initial remote path '{$this->pending_base_path}' failed", $error);
return self::UPLOAD_ERROR;
}
}
// Remove file that caused an error previously.
unset($this->pending_files[$this->current_file]);
}
$this->pending_base_path = $remote_base_path = $this->sftp->pwd();
$remote_files = [];
$remote_path = ".";
// Collecting files to upload (sorted by target to reduce remote directory changes)
$files_to_upload = $this->pending();
usort($files_to_upload, function (&$a, &$b) {
return $a["target"] <=> $b["target"];
});
// Uploading the files
foreach ($files_to_upload as $file) {
// Managing pending files.
$local_file = $this->current_file = $file["source"];
// Clear errors for processing next file.
$this->sftp->clearError();
try {
$connection = $this->sftp->connected();
if (!$connection) {
Utils::log()->error("The sftp client is not connected, upload stopped.");
return self::UPLOAD_ERROR;
}
// Changing remote directory if required.
if (($target_dir = dirname($file["target"])) !== $remote_path) {
$absolute_target_dir = $this->sftp->resolve($target_dir, $remote_base_path);
Utils::requireThat(
$absolute_target_dir && strpos($absolute_target_dir, $remote_base_path) === 0,
"Illegal target directory '$absolute_target_dir' is not below '$remote_base_path'");
$dir_names = preg_split('-/+-', substr($absolute_target_dir, strlen($remote_base_path)), 0, PREG_SPLIT_NO_EMPTY);
if (count($dir_names) == 1) {
$dir_names[0] = $absolute_target_dir; // Single dir: Use absolute path
} else {
$this->sftp->cd($remote_base_path); // None or multiple directories: Start from base path and create one by one as needed
}
foreach ($dir_names as $dir) {
if ($error = $this->sftp->cd($dir)->lastError()) {
if ($error["file_not_found"]) {
Utils::log()->info("Creating remote directory: $dir");
$this->sftp->clearError()
->mkdir($dir)
->cd($dir);
} else {
break;
}
}
}
if ($error = $this->sftp->lastError()) {
Utils::log()->error("Failed to cd into '$target_dir'.", $error);
return self::UPLOAD_ERROR;
}
$remote_path = $target_dir;
$remote_files = [];
}
// Listing existing remote files
if (empty($remote_files)) {
$remote_files = $this->sftp->clearError()->ls();
if ($error = $this->sftp->lastError()) {
Utils::log()->error("Failed listing remote files.", $error);
return self::UPLOAD_ERROR;
}
}
// 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_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) {
Utils::log()->error("Failed uploading file '{$local_file}' as there is a non-file in the way at '{$file["target"]}'");
return self::UPLOAD_ERROR_NO_OVERWRITE;
}
$chgrp = $file["group"] ?: "";
$chgrp = preg_match('/^\d+$/', $chgrp) ? (string)$chgrp : false;
$chmod = $file["mode"] ?: "";
$chmod = preg_match('/^0\d{3}$/', $chmod) ? (string)$chmod : false;
// Initial upload when permissions are properly set.
$retry_with_permission_change =
$chmod !== false
&& $remote_file["owner"] === $username
&& isset($remote_files[$remote_filename]);
if (!$remote_is_readonly) {
$preserve_times_and_mod = $chmod !== false;
if ($error = $this->sftp->put($local_file, $remote_filename, $preserve_times_and_mod)->lastError()) {
Utils::log()->error("Failed uploading file '{$local_file}' to '{$file["target"]}'", $error);
if ($error["permission_denied"] !== true)
$retry_with_permission_change = false;
if ($retry_with_permission_change) {
Utils::log()->info("Retrying file '{$local_file}' to '{$file["target"]}' with adjusted permissions");
} else {
return self::UPLOAD_ERROR_NO_PERMISSION;
}
} else {
$retry_with_permission_change = false;
}
}
// Second attempt when initial failed or was skipped due to write protection (only possible if we have chmod defined to reset permissions later)
if ($retry_with_permission_change) {
$this->sftp->chmod($remote_filename, '0600');
if ($error = $this->sftp->put($local_file, $remote_filename)->lastError()) {
Utils::log()->error("Failed uploading file '{$local_file}' to '{$file["target"]}'", $error);
return self::UPLOAD_ERROR_NO_PERMISSION;
}
} else if ($remote_is_readonly) {
Utils::log()->error("Failed uploading file '{$local_file}' to '{$file["target"]}'. Existing file is write protected.");
return self::UPLOAD_ERROR_NO_PERMISSION;
}
// Applying chmod / chgrp if requested.
if ($chmod) {
if ($error = $this->sftp->chmod($remote_filename, $chmod)->lastError()) {
Utils::log()->error("Failed chmod ($chmod) for '{$file["target"]}'", $error);
return self::UPLOAD_ERROR_CHMOD_FAILED;
}
}
if ($chgrp) {
if ($error = $this->sftp->chgrp($remote_filename, $chgrp)->lastError()) {
Utils::log()->error("Failed chgrp ($chgrp) for '{$file["target"]}'", $error);
return self::UPLOAD_ERROR_CHGRP_FAILED;
}
}
} finally {
$this->deleteSourceIfRequested($local_file);
}
unset($this->pending_files[$local_file]);
}
$this->current_file = null;
if (empty($this->pending_files))
$this->temporaryFile(true);
return self::UPLOAD_SUCCESS;
}
private function deleteSourceIfRequested($file)
{
if (isset($this->pending_files[$file])
&& is_array($existing = $this->pending_files[$file])
&& $existing["delete_source"] === true) {
unlink($existing["source"]);
}
}
private function temporaryFile($delete_all = false)
{
static $shared_temporary_files;
static $shared_temporary_files_index_sequence = 0;
// Maintain all generated files statically to ensure they are removed even when the destructor isn't called.
if (!is_array($shared_temporary_files)) {
$shared_temporary_files = [];
register_shutdown_function(function () use (&$shared_temporary_files) {
$count = 0;
foreach ($shared_temporary_files as $temporary_files) {
if (!is_iterable($temporary_files))
continue;
foreach ($temporary_files as $file) {
if (is_file($file)) {
unlink($file);
$count++;
}
}
}
if ($count > 0)
Utils::log()->info("Removed $count files in shutdown hook instead of object destruction.");
$shared_temporary_files = [];
});
}
$index = $this->temporary_files_index;
if ($index <= 0 || !is_array($shared_temporary_files[$index])) {
$index = $this->temporary_files_index = ++$shared_temporary_files_index_sequence;
$shared_temporary_files[$index] = [];
}
$temporary_files = &$shared_temporary_files[$index];
// Dealing with temp file creation or cleanup
if ($delete_all) {
foreach ($temporary_files as $file) {
if (is_file($file)) unlink($file);
}
unset($shared_temporary_files[$index]);
} else {
if ($file = tempnam(sys_get_temp_dir(), "sftp-upload-")) {
$file = realpath($file);
$temporary_files[] = $file;
Utils::requireThat(chmod($file, 0600), "failed setting user-only permissions on '$file'.");
return $file;
};
}
return false;
}
}
@@ -0,0 +1,143 @@
<?php
/*
* Copyright (C) 2019 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;
// Optional include to get "log_error"
@include_once("util.inc");
// Syslog level used for verbose info log.
// Change to "LOG_NOTICE" to make log output visible in the UI.
const SYSLOG_INFO_LEVEL = LOG_INFO;
/**
* Interface for logging.
* @package OPNsense\AcmeClient
*/
interface ILogger
{
function info($message);
function error($message, $error = null);
}
/**
* Shared utilities.
* @package OPNsense\AcmeClient
*/
class Utils
{
public static function &log($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, $error = null)
{
log_error(
$error
? ("$message ; Cause: " . json_encode($error, JSON_UNESCAPED_SLASHES))
: $message
);
}
};
} else {
$logger = new class implements ILogger
{
function info($message)
{
echo "INFO: {$message}" . PHP_EOL;
}
function error($message, $error = null)
{
echo "ERROR: "
. ($error
? ("$message ; Cause: " . json_encode($error, JSON_UNESCAPED_SLASHES))
: $message)
. PHP_EOL;
}
};
}
}
return $logger;
}
public static function requireThat($expression, $message)
{
if (!$expression) {
self::log()->error("FATAL: $message");
throw new \AssertionError($message);
}
return $expression;
}
/**
* Converts a relative to a normalized absolute path.
* @param string $file The relative file path to resolve against base.
* @param string $base The base path to use for resolving the file.
* @return bool|string The resolved path or false when impossible.
*/
public static function resolvePath(string $file, string $base = ".")
{
$combined_path = $file;
if (empty($file) || $file[0] != DIRECTORY_SEPARATOR) {
if (empty($base) || $base[0] != DIRECTORY_SEPARATOR)
$base = realpath(($base ?: "."));
$combined_path = $base . DIRECTORY_SEPARATOR . $file;
}
$path = [];
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);
}
}
@@ -738,6 +738,7 @@
<restart_haproxy>Restart HAProxy (OPNsense plugin)</restart_haproxy>
<restart_nginx>Restart Nginx (OPNsense plugin)</restart_nginx>
<upload_highwinds>Upload certificate to Highwinds CDN</upload_highwinds>
<upload_sftp>Upload certificate via SFTP</upload_sftp>
<configd>System or Plugin Command (select below)</configd>
</OptionValues>
</type>
@@ -751,6 +752,76 @@
<mask>/^.{1,1024}$/u</mask>
<ValidationMessage>Should be a string between 1 and 1024 characters.</ValidationMessage>
</highwinds_access_token>
<sftp_host type="TextField">
<Required>N</Required>
<mask>/^.{1,255}$/u</mask>
<ValidationMessage>Should be a string between 1 and 255 characters.</ValidationMessage>
</sftp_host>
<sftp_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>
</sftp_host_key>
<sftp_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>
</sftp_port>
<sftp_user type="TextField">
<Required>N</Required>
<mask>/^.{1,128}$/u</mask>
<ValidationMessage>Should be a string between 1 and 128 characters.</ValidationMessage>
</sftp_user>
<sftp_identity_type type="OptionField">
<Required>N</Required>
<OptionValues>
<ecdsa>ECDSA</ecdsa>
<rsa>RSA</rsa>
<ed25519>ed25519</ed25519>
</OptionValues>
</sftp_identity_type>
<sftp_remote_path type="TextField">
<Required>N</Required>
<mask>/^.{1,512}$/u</mask>
<ValidationMessage>Should be a string between 1 and 512 characters.</ValidationMessage>
</sftp_remote_path>
<sftp_chgrp type="TextField">
<Required>N</Required>
<mask>/^[0-9]+$/u</mask>
<ValidationMessage>Should be a numeric value.</ValidationMessage>
</sftp_chgrp>
<sftp_chmod type="TextField">
<Required>N</Required>
<mask>/^0[0-9]{3}$/u</mask>
<ValidationMessage>A unix permission, 4 digits (e.g. 0440).</ValidationMessage>
</sftp_chmod>
<sftp_chmod_key type="TextField">
<Required>N</Required>
<mask>/^0[0-9]{3}$/u</mask>
<ValidationMessage>A unix permission, 4 digits (e.g. 0400).</ValidationMessage>
</sftp_chmod_key>
<sftp_filename_cert type="TextField">
<Required>N</Required>
<mask>/^(?![\/\\])[\w\d_\-@.\/{}%]{1,255}(?&lt;![\/\\])$/ui</mask>
<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_cert>
<sftp_filename_key type="TextField">
<Required>N</Required>
<mask>/^(?![\/\\])[\w\d_\-@.\/{}%]{1,255}(?&lt;![\/\\])$/ui</mask>
<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_key>
<sftp_filename_ca type="TextField">
<Required>N</Required>
<mask>/^(?![\/\\])[\w\d_\-@.\/{}%]{1,255}(?&lt;![\/\\])$/ui</mask>
<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_ca>
<configd type="ConfigdActionsField">
<filters>
<description>/^(?!.*(Let\'s\ Encrypt|acme|[fF]irmware))([\S\s]{1,255})/</description>
@@ -60,7 +60,138 @@ 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 class="btn" type="button">'
+ '<span class="btn-text"></span>'
+ '<i class="fa fa-spinner fa-pulse" style="margin-left: 0.5em;"></i></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 = $('<span class="method_table" style="float: left"></span>')
.addClass(targetId)
.prependTo(targetContainer)
.hide();
}
return button.appendTo(target);
}
function makeStatusDiv(anchor, statusClass) {
return $('<div class="alert method_table" role="alert" style="word-break: break-all"></div>')
.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(
'<div class="message"></div>'
+ '<div class="detail-enabler" style="cursor: pointer"><i class="fa fa-plus-square"></i></div>'
+ '<div class="detail" style="font-family: monospace"></div>');
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_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", "host_not_trusted"], msg: "{{ lang._('Host cannot be trusted.') }}"},
{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, ' ').replace(/\\"/g, "'");
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();
});
</script>
@@ -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)) {
@@ -1,11 +1,17 @@
#!/bin/sh
ACME_DIRS="/var/etc/acme-client /var/etc/acme-client/certs /var/etc/acme-client/keys /var/etc/acme-client/configs /var/etc/acme-client/challenges /var/etc/acme-client/home"
ACME_BASE="/var/etc/acme-client"
ACME_DIRS="/var/etc/acme-client/certs /var/etc/acme-client/keys /var/etc/acme-client/configs /var/etc/acme-client/challenges /var/etc/acme-client/home"
# Generating dirs if missing and setting owner and mode (recursively)
for directory in ${ACME_DIRS}; do
mkdir -p ${directory}
chown -R root:wheel ${directory}
chmod -R 750 ${directory}
done
# Setting owner and mode for base and immediate children (non recursive)
chown root:wheel ${ACME_BASE} ${ACME_BASE}/*
chmod 750 ${ACME_BASE} ${ACME_BASE}/*
exit 0
File diff suppressed because it is too large Load Diff
@@ -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 --chmod=%s --chgrp=%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: