mirror of
https://github.com/netbirdio/plugins.git
synced 2026-05-22 18:44:07 -07:00
Merge pull request #1455 from jkellerer/ft-acmeclient-sftp-upload
AcmeClient support cert upload via sftp
This commit is contained in:
+51
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+93
@@ -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;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
+392
@@ -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}(?<![\/\\])$/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}(?<![\/\\])$/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}(?<![\/\\])$/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:
|
||||
|
||||
Reference in New Issue
Block a user