diff --git a/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/Api/ActionsController.php b/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/Api/ActionsController.php index 48682f8b3..c40ced752 100644 --- a/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/Api/ActionsController.php +++ b/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/Api/ActionsController.php @@ -31,6 +31,7 @@ namespace OPNsense\AcmeClient\Api; use \OPNsense\Base\ApiMutableModelControllerBase; use \OPNsense\Base\UIModelGrid; +use \OPNsense\Core\Backend; use \OPNsense\Core\Config; use \OPNsense\AcmeClient\AcmeClient; @@ -73,4 +74,54 @@ class ActionsController extends ApiMutableModelControllerBase { return $this->searchBase('actions.action', array('enabled', 'name', 'description'), 'name'); } + + public function sftpGetIdentityAction() + { + $result = ["status" => "unavailable"]; + + if ($response = $this->callBackend(["show-sftp-identity"], ["sftp_identity_type", "sftp_host"])) { + $result["status"] = "ok"; + $result["identity"] = $response; + } + + return $result; + } + + public function sftpTestConnectionAction() + { + if ($response = $this->callBackend( + ["test-sftp-connection"], + ["sftp_host", "sftp_host_key", "sftp_port", "sftp_user", "sftp_identity_type", "sftp_remote_path", "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; + } } diff --git a/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/forms/dialogAction.xml b/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/forms/dialogAction.xml index 54669fa74..5d89e439f 100644 --- a/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/forms/dialogAction.xml +++ b/security/acme-client/src/opnsense/mvc/app/controllers/OPNsense/AcmeClient/forms/dialogAction.xml @@ -40,6 +40,99 @@ text Access token for Highwinds API. + + + header + + + + action.sftp_host + + text + IP address or hostname of the SFTP server. + + + action.sftp_port + + text + SFTP server port. Leave blank to use default "22". + true + + + action.sftp_host_key + + text + SFTP server host key, formatted as in 'known_hosts'. + Leave blank to auto accept host key on first connect (not as secure as specifying it). + + + action.sftp_user + + text + The username to login to the SFTP server. + + + action.sftp_identity_type + + dropdown + The type of identify to present to the SFTP server for authorization. Select 'none' to use default "ECDSA". + + + action.sftp_remote_path + + text + Path on the SFTP server to change to after login. + The path can be absolute or relative to home and must exist. + Leave blank to not change path after login. + + + action.sftp_chmod + + text + Unix permission to apply to uploaded public keys. Leave blank to use default "0440". + true + + + action.sftp_chmod_key + + text + Unix permission to apply to uploaded private keys. Leave blank to use default "0400". + true + + + action.sftp_chgrp + + text + Unix group id to apply to all uploaded files. Leave blank to not change the group. + true + + + action.sftp_filename_cert + + text + 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". + true + + + action.sftp_filename_key + + text + 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". + true + + + action.sftp_filename_ca + + text + 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". + true + header diff --git a/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/Process.php b/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/Process.php new file mode 100644 index 000000000..e35df1d7b --- /dev/null +++ b/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/Process.php @@ -0,0 +1,180 @@ +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; + } +} diff --git a/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/SSHKeys.php b/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/SSHKeys.php new file mode 100644 index 000000000..aaefc5e2d --- /dev/null +++ b/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/SSHKeys.php @@ -0,0 +1,524 @@ + 4096, + "ecdsa" => 521, + ]; + + public const DEFAULT_PORT = 22; + + private $config_path; + private $known_hosts_file; + + public function __construct($config_path) + { + if (!is_dir($config_path)) { + $dir_created = mkdir($config_path, self::CONFIG_PATH_CREATE_MODE, true); + + Utils::requireThat($dir_created, + "Failed creating directory '$config_path' with permission " . self::CONFIG_PATH_CREATE_MODE); + } + + $this->config_path = realpath($config_path); + $this->known_hosts_file = Utils::resolvePath("known_hosts", $this->config_path); + } + + public function knownHostsFile() + { + if (!is_file($this->known_hosts_file)) { + $file_created = + touch($this->known_hosts_file) + && chmod($this->known_hosts_file, self::KNOWN_HOSTS_FILE_CREATE_MODE); + + Utils::requireThat($file_created, + "Failed creating file '{$this->known_hosts_file}' with permission " . self::KNOWN_HOSTS_FILE_CREATE_MODE); + } + + return $this->known_hosts_file; + } + + /** + * Establishes a trust in the specified host with the specified host-key. Fails if host-key mismatches or host cannot be reached. + * @param string $host The name or IP address of the host to trust. + * @param string $host_key The expected host-key required to establish a trust. Is auto-accepted on first connect and stored to "known_hosts" if omitted. + * @param int $port The port of the SSH server on the host to trust. + * @param bool $no_modification_allowed Indicates whether "known_hosts" may be modified to trust the host or not. + * @return array A status following the format ["ok"=>true/false, "error"=>"reason"]. + */ + public function trustHost(string $host, $host_key = "", $port = self::DEFAULT_PORT, $no_modification_allowed = false): array + { + Utils::requireThat(!empty(trim($host)), "Hostname must not be empty."); + + + // Convert the specified host_key to a data structure that can be compared + if (empty($host_key = trim($host_key))) { + $host_key = false; + } else { + $host_key = self::getHostKeyInfo($host_key); + if ($host_key === false) + return ["ok" => false, "error" => "Invalid host_key specified."]; + } + + + // Check our current known_host file + $addKeyInfo = function (array &$key_list) { + foreach ($key_list as &$item) { + $item["key_info"] = self::getHostKeyInfo($item["host_key"]); + } + return array_filter($key_list, function (&$item) { + return $item["key_info"] !== false; + }); + }; + + $known_keys = $addKeyInfo($this->getKnownHostKey($host, $port)); + + // Find known_hosts item with same hostname + $known_by_host = array_reduce($known_keys, function ($found, $key) use ($host) { + return (!$found && !empty(trim($key["host"])) && strcasecmp(trim($host), trim($key["host"])) == 0) + ? $key + : $found; + }, false); + + $known_by_host_matches_port = $known_by_host && ($port == self::DEFAULT_PORT || $known_by_host["host"] !== $known_by_host["host_query"]); + + // Find known_hosts item with same public host-key + $known_by_key = array_reduce($known_keys, function ($found, $key) use ($host_key) { + return (!$found && $host_key && $host_key === $key["key_info"]) + ? $key + : $found; + }, false); + + + // Updating $host and $host_key from known_hosts and check if we need to update known_hosts. + if ($host_key === false && $known_by_host) { + if ($known_by_host_matches_port) { + Utils::log()->info("No host key specified, using existing known_hosts entry for '$host'"); + $host_key = $known_by_host["key_info"]; + } else { + Utils::log()->info("No host key specified and existing entry for '$host' cannot be used as isn't matching port $port."); + } + } + + $is_key_known = false; + if ($known_by_host && $host_key && $host_key === $known_by_host["key_info"]) { + $is_key_known = true; + + } else if ($known_by_key) { + if (strcasecmp(trim($host), trim($known_by_key["host"])) != 0) { + Utils::log()->info("Host key is in known_hosts but hostname differs. Changing '$host' to '{$known_by_key["host"]}'."); + $host = $known_by_key["host"]; + } + $is_key_known = true; + } + + + // Check if we don't have a matching known_hosts entry and add or update it as required. + if (!$is_key_known && !$no_modification_allowed) { + + // Query the key. + $key_type = $host_key ? $host_key["key_type"] : self::DEFAULT_KEY_TYPE; + $remote_host_keys = $addKeyInfo($this->queryHostKey($host, $key_type, $port, $query_error)); + + // Retry with ALTERNATE_DEFAULT_KEY_TYPE when DEFAULT_KEY_TYPE was applied in the first place. + if (empty($remote_host_keys) + && $query_error + && $query_error["connection_refused"] + && !$host_key + && self::ALTERNATE_DEFAULT_KEY_TYPE != self::DEFAULT_KEY_TYPE) { + + $key_type = self::ALTERNATE_DEFAULT_KEY_TYPE; + $remote_host_keys = $addKeyInfo($this->queryHostKey($host, $key_type, $port, $query_error)); + } + + $matching_remote_host_keys = array_filter($remote_host_keys, function ($key) use ($host_key) { + return $key["key_info"] !== false && (!$host_key || $host_key === $key["key_info"]); + }); + + if (!empty($matching_remote_host_keys)) { + if ($known_by_host && $known_by_host_matches_port) { + Utils::log()->info("Removing known_hosts entry with differing key for '{$known_by_host["host_query"]}' as it is in the way."); + $this->removeKnownHost($known_by_host["host_query"]); + } + + foreach ($matching_remote_host_keys as $key) { + Utils::log()->info("Adding known_hosts entry: " . json_encode($key["key_info"], JSON_UNESCAPED_SLASHES)); + $ok = file_put_contents($this->knownHostsFile(), $key["host_key"] . PHP_EOL, FILE_APPEND); + if (!$ok) + Utils::log()->error("Failed adding known_hosts entry {$key["host_key"]}"); + } + + // Verify that known_hosts contains the correct keys after adding them (using recursion). + return $this->trustHost($host, $matching_remote_host_keys[0]["host_key"], $port, true); + + } else { + if (empty($remote_host_keys)) { + $msg = "No connection to '$host'; Failed querying host key from server."; + } else { + $remote_infos = array_map(function ($key) { return $key["key_info"]; }, $remote_host_keys); + $msg = "Key mismatch for '$host'; " + . "The expected key (" . json_encode($host_key) . ") was not found in (" . json_encode($remote_infos) . ")"; + } + + return array_merge(["ok" => false, "error" => $msg], ($query_error ?: [])); + } + } + + + if ($is_key_known) { + return ["ok" => true, "host" => $host, "key_info" => $host_key]; + } else { + return ["ok" => false, "error" => "Host unknown and remote key cannot be queried."]; + } + } + + /** + * Returns a normalized list of names and IP addresses (IPv4) that point to the same host. + * @param string $host The name or IP address of the host. + * @param int $port Add port specific host names and addresses to the search-list when greater 0. + * @return array A list of hostnames / ip addresses pointing to the same host. + */ + public static function getHostSearchList(string $host, int $port = 0) + { + $host = strtolower($host); + $search_list = [$host]; + + // Add IP-address to search list (IPv4 only) + $has_ip = ($ip = gethostbyname($host)) + && ($ip !== $host || preg_match('/^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$/', $ip)); + + if ($has_ip) + $search_list[] = strtolower($ip); + + // Add FQDN to search list if reverse lookup provides a valid one. + $has_fqdn = $has_ip + && ($reverse_fqdn = gethostbyaddr($ip)) + && $reverse_fqdn !== $ip + && gethostbyname($reverse_fqdn) === $ip; + + if ($has_fqdn && isset($reverse_fqdn)) + $search_list[] = strtolower($reverse_fqdn); + + // Build unique search list (dedup list) + $search_list = array_filter($search_list, function ($value, $index) use (&$search_list) { + return !empty(trim($value)) && array_search($value, $search_list) == $index; + }, ARRAY_FILTER_USE_BOTH); + + // Insert port specific items at the beginning when a port was specified. + // Reason: Multiple SSH servers may be used on the same host but different ports. + // This ensures specific keys (with port) are selected first. + if ($port > 0) { + foreach (array_reverse($search_list) as $item) { + array_unshift($search_list, "[{$item}]:{$port}"); + } + } + + return $search_list; + } + + /** + * Queries the host-key from the SSH server running on the specified host. + * @param string $host The name or IP address of the host. + * @param string $key_type The type of host-key to query (one of "rsa", "ecdsa" or "ed25519"). + * @param int $port the port of the SSH server. + * @param array $error receives error details. + * @return array A list of host-keys returned by the query. + */ + public static function queryHostKey(string $host, $key_type = self::DEFAULT_KEY_TYPE, $port = self::DEFAULT_PORT, ?array &$error = []) + { + // Error list matching output from "ssh-keyscan". Keep names in sync with similar list in SftpClient. + static $expected_errors = [ + ["host_not_resolved", /* -> */ '/.*not known.*/i'], + ["network_unreachable", /* -> */ '/.*no route.*/i'], + ["failure", /* -> */ '/.*(connect|write|broken).*/i'], + ]; + + $keys = []; + $failed = false; + $names = join(",", self::getHostSearchList($host)); + + if (!empty($names) && ($p = Process::open(["ssh-keyscan", "-p", $port, "-t", $key_type, $names]))) { + $lines = []; + while (($line = $p->get(60)) !== false) { + $line = trim($line); + if (empty($line) || $line[0] == "#") + continue; + + if (!$failed) { + foreach ($expected_errors as $err) + if (preg_match($err[1], $line)) { + $error = [$err[0] => true]; + $failed = true; + break; + } + } + + $lines[] = $line; + } + + if ($p->close() == 0 && !$failed) { + foreach ($lines as $line) { + $keys[] = ["host_key" => $line]; + } + } else { + $marker = $failed ? "yes" : "no"; + $output = empty($lines) + ? "" + : PHP_EOL . "ssh-keyscan: " . join(PHP_EOL . "ssh-keyscan: ", $lines); + + Utils::log()->error("Failed querying host keys ($key_type) for [$names] port $port. Exit code: {$p->exitCode} (error-marker: $marker) $output"); + } + } + + if (empty($keys)) { + Utils::log()->info("Couldn't fetch public host key ($key_type) from {$host}:{$port}"); + + if (!is_array($error) || empty($error)) + $error = ["connection_refused" => true]; + } + + return $keys; + } + + /** + * Returns all host-keys from "known_hosts" that match the specified hostname. + * @param string $host The name of the host to lookup. + * @param int $port The port of the SSH server on the host (used to find port specific known host entries). + * @return array A list of all matching host keys. + */ + public function getKnownHostKey(string $host, int $port = self::DEFAULT_PORT) + { + $keys = []; + + foreach (self::getHostSearchList($host, $port) as $name_or_ip) { + if ($p = Process::open(["ssh-keygen", "-F", $name_or_ip, "-f", $this->knownHostsFile()])) { + $lines = []; + while (($line = $p->get()) !== false) { + $line = trim($line); + if (empty($line) || $line[0] == "#") + continue; + + $lines[] = $line; + } + + if ($p->close() == 0) { + // Removing port from name or ip before returning it. + $hostname = preg_match('/^\[([^\]]+?)\]:\d+/', $name_or_ip, $matches) + ? $matches[1] + : $name_or_ip; + + $keys[] = [ + "host" => $hostname, + "host_key" => $lines[0], + "host_query" => $name_or_ip, + ]; + + } else if ($p->exitCode != 1 /* 1 == NOT_FOUND */) { + $output = empty($lines) + ? "" + : PHP_EOL . join(PHP_EOL, $lines); + + Utils::log()->error("Failed querying known hosts for $name_or_ip ($host). Exit code: {$p->exitCode} $output"); + } + } + } + + if (empty($keys)) + Utils::log()->info("Didn't find $host in known_hosts"); + + return $keys; + } + + /** + * Removes a specific host from the "known_hosts" file. + * @param string $host The name of the host to remove. + * @return bool True on success. + */ + public function removeKnownHost(string $host) + { + $ok = false; + + if ($p = Process::open(["ssh-keygen", "-R", $host, "-f", $this->knownHostsFile()])) { + $ok = $p->close() === 0; + if (!$ok) + Utils::log()->error("Failed removing known hosts for $host. Return code was: {$p->exitCode}"); + } + + return $ok; + } + + /** + * Returns the key info (key hash, length, type) for a specified host key. + * @param string $host_key The host key as formatted in "authorized_keys" or "known_hosts". + * @return array|bool A host key info [hash=>.., key_type=>.., key_length=>..] or false on failure. + */ + public static function getHostKeyInfo(string $host_key) + { + if ($p = Process::open(["ssh-keygen", "-l", "-f", "-"])) { + $p->put($host_key); + $p->closeInput(); + + if (($hash = $p->get()) && preg_match('/^([0-9]+) (.+?) .+? \(([^()]+)\)$/', $hash, $matches)) { + return [ + "hash" => $matches[2], + "key_type" => $matches[3], + "key_length" => $matches[1] + ]; + } else { + Utils::log()->error("Unsupported hash type: $hash"); + } + } + + Utils::log()->error("Failed getting hash for host_key"); + return false; + } + + /** + * Returns the path to the public identity key file, generating it if missing. + * @param string $identity_type the type of identity to return {@see IDENTITY_TYPES}. + * @param bool $private Return the path to the private key file instead. + * @return string The path to the key file. + */ + public function getIdentity(string $identity_type = self::DEFAULT_IDENTITY_TYPE, $private = false): string + { + Utils::requireThat(in_array($identity_type, self::IDENTITY_TYPES), "Identity type '$identity_type' unknown."); + + list($key_type, $key_size) = explode('_', $identity_type, 2); + if (!$key_size && self::DEFAULT_IDENTITY_KEY_BITS[$key_type] > 0) + $key_size = self::DEFAULT_IDENTITY_KEY_BITS[$key_type]; + + $identity_path = "{$this->config_path}/id.{$identity_type}"; + + if (!file_exists($identity_path)) { + $generate_key = [ + "ssh-keygen", "-v", + "-f", $identity_path, + "-t", $key_type, + "-N", "", + ]; + + if (intval($key_size) > 0) + array_push($generate_key, "-b", $key_size); + + if ($p = Process::open($generate_key)) { + while (($line = $p->get(10)) !== false) { + Utils::log()->info("SSH keygen: $line"); + } + + Utils::requireThat($p->close() == 0, + "Failed generating identity $identity_path: Error code: {$p->exitCode}" . PHP_EOL + . "Command: " . join(" ", $generate_key)); + } + } + + return $private ? $identity_path : "{$identity_path}.pub"; + } + + /** + * Returns a restrictions comment to be used in "authorized_keys" to limit what an identity can be used for. + * @param string $host the SSH server host to create the restriction for. + * @param string $outgoing_ip the IP address of the interface that will be used to connect the SSH server or empty to autodetect. + * @param string $command the command to restrict the identity to. + * @return string A restriction comment to be used to prepend an "authorized_keys" entry. + */ + public static function getIdentityRestrictions($host = "", $outgoing_ip = "", $command = "internal-sftp"): string + { + $restrictions = ['restrict']; + + if ($command) + $restrictions[] = 'command="' . $command . '"'; + + $restrict_ip = empty(trim($outgoing_ip)) + ? (empty(trim($host)) ? false : self::getOutgoingIpFor($host)) + : $outgoing_ip; + + if ($restrict_ip) + $restrictions[] = 'from="' . $restrict_ip . '"'; + + + return count($restrictions) > 1 + ? join(",", $restrictions) + : ""; + } + + /** + * Returns the IP address of the interface that will be used when connecting to host. + * @param string $host the host to check outgoing IP address for. + * @return bool|mixed an IPv4 address when the route & interface was detected. + */ + public static function getOutgoingIpFor(string $host) + { + $ip = gethostbyname($host); + $interface = null; + + if ($p = Process::open(["route", "-n", "get", $ip])) { + while (($line = $p->get(10)) !== false) + if (preg_match('/\s*interface:\s*([^\s]+).*$/', $line, $matches)) { + $interface = $matches[1]; + } + } + + if ($interface && $p = Process::open(["ifconfig", $interface, "inet"])) { + while (($line = $p->get(10)) !== false) + if (preg_match('/\s*inet\s+([^\s]+)\s+netmask.*/', $line, $matches)) { + return $matches[1]; + } + } + + return false; + } +} diff --git a/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/SftpClient.php b/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/SftpClient.php new file mode 100644 index 000000000..0292fa166 --- /dev/null +++ b/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/SftpClient.php @@ -0,0 +1,335 @@ +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; + } +} diff --git a/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/SftpUploader.php b/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/SftpUploader.php new file mode 100644 index 000000000..b56bc2411 --- /dev/null +++ b/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/SftpUploader.php @@ -0,0 +1,392 @@ +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; + } +} diff --git a/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/Utils.php b/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/Utils.php new file mode 100644 index 000000000..89a89c5ae --- /dev/null +++ b/security/acme-client/src/opnsense/mvc/app/library/OPNsense/AcmeClient/Utils.php @@ -0,0 +1,143 @@ +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); + } +} diff --git a/security/acme-client/src/opnsense/mvc/app/models/OPNsense/AcmeClient/AcmeClient.xml b/security/acme-client/src/opnsense/mvc/app/models/OPNsense/AcmeClient/AcmeClient.xml index e66b9d041..c41a91337 100644 --- a/security/acme-client/src/opnsense/mvc/app/models/OPNsense/AcmeClient/AcmeClient.xml +++ b/security/acme-client/src/opnsense/mvc/app/models/OPNsense/AcmeClient/AcmeClient.xml @@ -738,6 +738,7 @@ Restart HAProxy (OPNsense plugin) Restart Nginx (OPNsense plugin) Upload certificate to Highwinds CDN + Upload certificate via SFTP System or Plugin Command (select below) @@ -751,6 +752,76 @@ /^.{1,1024}$/u Should be a string between 1 and 1024 characters. + + N + /^.{1,255}$/u + Should be a string between 1 and 255 characters. + + + N + + /^.+?\s(?:[a-z0-9+\/]{4})*(?:[a-z0-9+\/]{2}==|[a-z0-9+\/]{3}=)?(?:\s.+?)?$/i + Should be a valid public SSH host key (see "known_hosts"). + + + N + 1 + 49151 + 22 + Should be a valid port number between 1 and 49151. + + + N + /^.{1,128}$/u + Should be a string between 1 and 128 characters. + + + N + + ECDSA + RSA + ed25519 + + + + N + /^.{1,512}$/u + Should be a string between 1 and 512 characters. + + + N + /^[0-9]+$/u + Should be a numeric value. + + + N + /^0[0-9]{3}$/u + A unix permission, 4 digits (e.g. 0440). + + + N + /^0[0-9]{3}$/u + A unix permission, 4 digits (e.g. 0400). + + + N + /^(?![\/\\])[\w\d_\-@.\/{}%]{1,255}(?<![\/\\])$/ui + Should be a string between 1 and 255 characters. + Characters are limited to [a-z], [0-9] and [{}@./-_%] and the string must neither begin nor end with '/'. + + + N + /^(?![\/\\])[\w\d_\-@.\/{}%]{1,255}(?<![\/\\])$/ui + Should be a string between 1 and 255 characters. + Characters are limited to [a-z], [0-9] and [{}@./-_%] and the string must neither begin nor end with '/'. + + + N + /^(?![\/\\])[\w\d_\-@.\/{}%]{1,255}(?<![\/\\])$/ui + 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 '/'. + /^(?!.*(Let\'s\ Encrypt|acme|[fF]irmware))([\S\s]{1,255})/ diff --git a/security/acme-client/src/opnsense/mvc/app/views/OPNsense/AcmeClient/actions.volt b/security/acme-client/src/opnsense/mvc/app/views/OPNsense/AcmeClient/actions.volt index dcb794f7e..1585373c1 100644 --- a/security/acme-client/src/opnsense/mvc/app/views/OPNsense/AcmeClient/actions.volt +++ b/security/acme-client/src/opnsense/mvc/app/views/OPNsense/AcmeClient/actions.volt @@ -60,7 +60,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.addClass(buttonClass || "btn-primary"); + $('.fa-spinner', button).hide(); + $('.btn-text', button).html(label); + + var targetContainer = $("#DialogAction .modal-footer"), + targetId = "method_table_" + buttonGroup, + target = $("." + targetId, targetContainer); + + if (!target.is('span')) { + target = $('') + .addClass(targetId) + .prependTo(targetContainer) + .hide(); + } + + return button.appendTo(target); + } + + function makeStatusDiv(anchor, statusClass) { + return $('') + .appendTo($(anchor).closest("table").find("thead th[colspan=3]").first()) + .addClass(statusClass || 'alert-info') + .hide(); + } + + // SFTP - Identity show button + (function ($identityType) { + var identityDiv = makeStatusDiv($identityType); + + makeButton("{{ lang._('Show Identity') }}", "upload_sftp", "btn-info") + .click(function () { + identityDiv.hide(); + var button = $(this); + button.prop('disabled', true).find(".fa-spinner").show(); + + ajaxCall("/api/acmeclient/actions/sftpGetIdentity", getFormData("DialogAction").action, function (data, status) { + button.prop('disabled', false).find(".fa-spinner").hide(); + + if (status === "success" && data.status === "ok") { + identityDiv.text(data.identity).show(); + } else { + identityDiv.text("{{ lang._('Failed loading identity') }}").show(); + } + }); + }); + + // Hide when input changes that influences the identity. + $identityType.change(function() { + identityDiv.hide(); + }); + })($('#action\\.sftp_identity_type')); + + // SFTP - Connection test button + (function ($user) { + var statusDiv = makeStatusDiv($user, 'alert-success').html( + '
' + + '
' + + '
'); + + statusDiv.find(".detail-enabler").click(function() { + $(".detail", statusDiv).show(); + $(this).hide(); + }); + + var errors = [ + {cond: ["connect_failed", "invalid_parameters"], msg: "{{ lang._('Host or username not specified.') }}"}, + {cond: ["connect_failed", "host_not_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(); }); diff --git a/security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/certhelper.php b/security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/certhelper.php index c561c3bcd..7d4266995 100755 --- a/security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/certhelper.php +++ b/security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/certhelper.php @@ -1290,6 +1290,9 @@ function run_restart_actions($certlist, $modelObj) case 'upload_highwinds': $response = $backend->configdRun("acmeclient upload_highwinds ${cert_id} ${action_id}"); break; + case 'upload_sftp': + $response = $backend->configdRun("acmeclient upload-sftp ${cert_id} ${action_id}"); + break; case 'configd': // Make sure a configd command was specified. if (empty((string)$action->configd)) { diff --git a/security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/setup.sh b/security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/setup.sh index 583e3f9d6..af6039d44 100755 --- a/security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/setup.sh +++ b/security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/setup.sh @@ -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 diff --git a/security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/upload_sftp.php b/security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/upload_sftp.php new file mode 100644 index 000000000..5bef2ceac --- /dev/null +++ b/security/acme-client/src/opnsense/scripts/OPNsense/AcmeClient/upload_sftp.php @@ -0,0 +1,655 @@ +#!/usr/local/bin/php + [ + "description" => "transfers certificates to the specified target host", + "options" => [ + "host::", "port::", "host-key::", "user::", "identity-type::", "remote-path::", + "certificates::", "files::", "chgrp::", "chmod::", "chmod-key::", + "cert-name::", "key-name::", "ca-name::"], + "implementation" => "commandUpload", + "default" => true, + ], + + "test-connection" => [ + "description" => "connects to the host and returns results as JSON", + "options" => [ + "host:", "port::", "host-key::", "user:", "remote-path::", "identity-type::", + "chgrp::", "chmod::"], + "implementation" => "commandTestConnection", + ], + + "show-identity" => [ + "description" => "prints the ssh client identity (publickey)", + "options" => ["identity-type::", "source-ip::", "host::", "unrestricted"], + "implementation" => "commandShowIdentity", + ], +]; + +const STATIC_OPTIONS = << ["default" => "{{name}}/cert.pem", "option" => "cert-name"], + "key" => ["default" => "{{name}}/key.pem", "option" => "key-name"], + "ca" => ["default" => "{{name}}/ca.pem", "option" => "ca-name"], +]; + +// Exit codes +const EXITCODE_SUCCESS = 0; +const EXITCODE_ERROR = 1; +const EXITCODE_ERROR_NO_PERMISSION = 2; +const EXITCODE_ERROR_NOTHING_TO_UPLOAD = 4; +const EXITCODE_ERROR_UNKNOWN_COMMAND = 255; + +// Optional imports +@include_once("config.inc"); +@include_once("certs.inc"); +@include_once("util.inc"); + +// Optional autoloader (for local dev environment) +if (!function_exists("log_error")) { + spl_autoload_register(function ($class_name) { + require_once(__DIR__ . "/../../../mvc/app/library/" . str_replace("\\", "/", $class_name) . ".php"); + }); +} + +// Importing classes +use OPNsense\AcmeClient\SftpUploader; +use OPNsense\AcmeClient\SftpClient; +use OPNsense\AcmeClient\SSHKeys; +use OPNsense\AcmeClient\Utils; + +// Implementing logic +function commandShowIdentity(array &$options): int +{ + $identity_type = trim(($options["identity-type"] ?: SSHKeys::DEFAULT_IDENTITY_TYPE)); + $source_ip = trim(($options["source-ip"] ?: "")); + $host = trim(($options["host"] ?: "")); + + $keys = new SSHKeys(configPath()); + if (($id_file = $keys->getIdentity($identity_type)) && is_readable($id_file)) { + + if (!isset($options["unrestricted"]) + && ($restrictions = SSHKeys::getIdentityRestrictions($host, $source_ip))) { + echo "$restrictions "; + } + + echo file_get_contents($id_file); + return EXITCODE_SUCCESS; + + } else { + Utils::log()->error("Failed getting identity. See log output for details."); + } + return EXITCODE_ERROR; +} + +function commandTestConnection(array &$options): int +{ + $result = ["actions" => ["connecting"]]; + + // Testing connection + $sftp = connectWithServer($options, $error); + if ($result["success"] = ($sftp !== null && $sftp->connected())) { + $result["actions"][] = "connected"; + $result["remote"] = array_merge($sftp->connected(), ["path" => $sftp->pwd()]); + } else { + $result = array_merge($result, ($error ?: [])); + } + + // Testing file upload + if ($result["success"]) { + $result["actions"][] = "upload-testing"; + + $uploader = new SftpUploader($sftp); + + $chgrp = $options["chgrp"] ?: false; + $chmod = isset($options["chmod"]) ? ($options["chmod"] ?: DEFAULT_CERT_MODE) : false; + $filename = $uploader->addContent("upload-test", "", 0, $chmod, $chgrp); + + $upload_result = $uploader->upload(); + $result["success"] = $upload_result === SftpUploader::UPLOAD_SUCCESS; + + if ($result["success"]) { + $result["actions"][] = "upload-tested"; + + } else { + if ($error = $sftp->lastError(3)) + $result = array_merge($result, $error); + + if ($upload_result === SftpUploader::UPLOAD_ERROR_CHGRP_FAILED) { + $result["chgrp_failed"] = true; + } else if ($upload_result === SftpUploader::UPLOAD_ERROR_CHMOD_FAILED) { + $result["chmod_failed"] = true; + } + } + + $remove_file = in_array($upload_result, [ + SftpUploader::UPLOAD_SUCCESS, + SftpUploader::UPLOAD_ERROR_CHGRP_FAILED, + SftpUploader::UPLOAD_ERROR_CHMOD_FAILED]); + + if ($remove_file) { + if ($error = $sftp->clearError()->rm($filename)->lastError(3)) + Utils::log()->error("Failed removing upload test file '$filename'", $error); + } + + $sftp->close(); + } + + echo json_encode($result, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT) . PHP_EOL; + + return $result["success"] ? EXITCODE_SUCCESS : EXITCODE_ERROR; +} + +function commandUpload(array &$options): int +{ + if (isset($options["certificates"])) { + // Includes host, upload all certs to the same host. + if (isset($options["host"])) { + return uploadCertificatesToHost($options); + + } else { + // Find the actions associated with the given certs. + $tasks = []; + $cert_ids = preg_split('/[,;\s]+/', $options["certificates"] ?: "", 0, PREG_SPLIT_NO_EMPTY); + foreach (findCertificates($cert_ids, false) as $id => $cert) { + foreach ($cert["automations"] as $action_id) { + if (!isset($tasks[$action_id])) + $tasks[$action_id] = []; + $tasks[$action_id][] = $id; + } + } + + $result = 0; + foreach ($tasks as $action_id => $cert_list) { + if (!empty($cert_list) && ($task_options = getOptionsById($action_id, true))) { + $task_options = array_merge($options, $task_options, ["certificates" => join(",", $cert_list)]); + $result = uploadCertificatesToHost($task_options); + if ($result != EXITCODE_SUCCESS) + break; + } + } + + return $result; + } + + } else if (isset($options["host"])) { + return uploadCertificatesToHost($options); + + } else { + Utils::log()->error("No work to do, neither --host nor --certificates is present."); + return EXITCODE_ERROR_NOTHING_TO_UPLOAD; + } +} + +function uploadCertificatesToHost(array $options): int +{ + $sftp = connectWithServer($options, $error); + if ($sftp === null) { + Utils::log()->error("Aborting after connect failure."); + return $error["connect_failed"] + ? EXITCODE_ERROR + : EXITCODE_ERROR_NO_PERMISSION; + } + + try { + $uploader = new SftpUploader($sftp); + + addFilesToUpload($options, $uploader); + + if (empty($uploader->pending())) + return EXITCODE_ERROR_NOTHING_TO_UPLOAD; + + for ($max_restarts = 5; !empty($uploader->pending()) && $max_restarts > 0; $max_restarts--) { + + $result = $uploader->upload(); + + if ($result != SftpUploader::UPLOAD_SUCCESS) { + Utils::log()->error("Failed on " . json_encode($uploader->current(), JSON_UNESCAPED_SLASHES)); + + switch ($result) { + case SftpUploader::UPLOAD_ERROR_CHGRP_FAILED: + case SftpUploader::UPLOAD_ERROR_CHMOD_FAILED: + case SftpUploader::UPLOAD_ERROR_NO_OVERWRITE: + continue; + + case SftpUploader::UPLOAD_ERROR_NO_PERMISSION: + return EXITCODE_ERROR_NO_PERMISSION; + + case SftpUploader::UPLOAD_ERROR: + return EXITCODE_ERROR; + } + } else { + break; + } + } + } finally { + $sftp->close(); + } + + return EXITCODE_SUCCESS; +} + +function connectWithServer(array $options, &$error): ?SftpClient +{ + $identity_type = trim(($options["identity-type"] ?: SSHKeys::DEFAULT_IDENTITY_TYPE)); + $host = trim(($options["host"] ?: "")); + $host_key = ($options["host-key"] ?: ""); + $port = $options["port"] ?: 22; + $username = $options["user"]; + + $sftp = new SftpClient(configPath(), $identity_type); + + if (!$sftp->connect($host, $username, $host_key, $port)) { + $error = $sftp->lastError(); + $error["connect_failed"] = true; + return null; + } + + // Apply start path (if one was specified, defaults to home dir) + if (($remote_path = $options["remote-path"])) { + if ($err = $sftp->cd($remote_path)->lastError()) { + $error = $err; + $error["change_home_dir_failed"] = true; + Utils::log()->error("Failed cd into '{$remote_path}'", $err); + return null; + } + } + + return $sftp; +} + +function help() +{ + echo ABOUT . PHP_EOL + . "Usage: " . basename($GLOBALS["argv"][0]) . " [options] [--command=]COMMAND" . PHP_EOL + . PHP_EOL . STATIC_OPTIONS . PHP_EOL; + + foreach (COMMANDS as $name => $cmd) { + echo PHP_EOL . "COMMAND \"$name\" {$cmd["description"]}" . PHP_EOL . "Options:" . PHP_EOL; + foreach ($cmd["options"] as $option) { + $option = preg_replace(['/^([^:]+)$/', '/(.+)::$/', '/(.+):$/'], ['[$1]', '[$1=value]', '$1=value'], "--$option"); + echo " $option" . PHP_EOL; + } + } + + echo PHP_EOL . "Examples:" . PHP_EOL + . str_replace('/\r\n|\n|\r/g', PHP_EOL, EXAMPLES) + . PHP_EOL . PHP_EOL; +} + +function getCommand() +{ + $default = null; + $command = null; + $parsed_args = getopt("", ["command::"]); + foreach (COMMANDS as $name => $cmd) { + if (in_array($name, $GLOBALS["argv"]) || $parsed_args["command"] === $name) + $command = $cmd; + if ($cmd["default"] === true) + $default = $cmd; + } + + return $command ?: $default; +} + +function getActionById($automation_id) +{ + $config = OPNsense\Core\Config::getInstance()->object(); + $client = $config->OPNsense->AcmeClient; + + foreach ($client->actions->children() as $action) { + if ($automation_id === (string)$action->attributes()["uuid"] + || $automation_id === (string)$action->id) + return $action; + } + + return null; +} + +function getOptionsById($automation_id, $silent = false) +{ + if (!$silent) Utils::log()->info("Reading options from automation: $automation_id"); + + if (is_object($action = getActionById($automation_id))) { + if ($action->enabled && "upload_sftp" === (string)$action->type) { + return [ + "host" => trim((string)$action->sftp_host), + "host-key" => trim((string)$action->sftp_host_key), + "port" => trim((string)$action->sftp_port), + "identity-type" => trim((string)$action->sftp_identity_type), + "user" => trim((string)$action->sftp_user), + "remote-path" => trim((string)$action->sftp_remote_path), + "chgrp" => trim((string)$action->sftp_chgrp), + "chmod" => trim((string)$action->sftp_chmod), + "chmod-key" => trim((string)$action->sftp_chmod_key), + "cert-name" => trim((string)$action->sftp_filename_cert), + "key-name" => trim((string)$action->sftp_filename_key), + "ca-name" => trim((string)$action->sftp_filename_ca), + "certificates" => "", // defaults to all (= empty), may be overridden via CLI + ]; + } else if (!$silent) { + Utils::log()->error("Ignoring disabled or invalid automation '$automation_id'"); + } + } else { + Utils::log()->error("No upload automation found with uuid = '$automation_id'"); + } + + return false; +} + +function addFilesToUpload(array $options, SftpUploader &$uploader) +{ + $chmod = isset($options["chmod"]) ? ($options["chmod"] ?: DEFAULT_CERT_MODE) : false; + $chmod_key = isset($options["chmod-key"]) ? ($options["chmod-key"] ?: DEFAULT_KEY_MODE) : false; + $chgrp = $options["chgrp"] ?: false; + + if (isset($options["certificates"])) { + $cert_ids = preg_split('/[,;\s]+/', $options["certificates"] ?: "", 0, PREG_SPLIT_NO_EMPTY); + + foreach (findCertificates($cert_ids) as $cert) { + + if (!isset($cert["content"])) { + Utils::log()->error("Ignoring upload for cert '{$cert["name"]}', since it is not available in trust storage."); + continue; + } + + foreach ($cert["content"] as $name => $content) { + + if (empty($content)) { + Utils::log()->error("Content for '{$name}.pem' in cert '{$cert["name"]}' is empty, skipping it."); + continue; + } + + // Build the upload name + $target_path = requireThat(UPLOAD_NAME_TEMPLATES[$name], "No upload template defined for '{$name}'"); + $target_path = stripcslashes($options[$target_path["option"]] ?: $target_path["default"]); + + $target_path = join("/", array_map( + function ($path_part) use (&$cert) { + // Replace template params "{{.+}}" & "%s" + $path_part = preg_replace_callback( + ['/%s/', '/{{([^}]+?)}}/'], + function ($m) use (&$cert) { + $index = $m[0] == '%s' ? "name" : trim($m[1]); + + return in_array($index, ["name", "id", "updated"]) + ? stripcslashes($cert[$index]) + : "__unknown-template-param__{$index}__"; + }, + $path_part); + + // Sanitize user input. Allow unicode chars, numbers and some special characters [_-@.]. + // Also replace all ".." with "." to avoid upwards tree traversal. + return preg_replace(['/\.+/', '/[^\w\d_\-@.]+/uim'], ['.', '-'], trim($path_part)); + }, + preg_split('-[/\\\\]+-', $target_path, 0, PREG_SPLIT_NO_EMPTY))); + + + // Add the file to upload (if valid) + if (!empty($target_path) + && preg_match('-^(?!/).+?(?addContent($content, $target_path, $cert["updated"], $mod, $chgrp); + + } else { + Utils::log()->error("Cannot add '{$name}.pem' since the upload path '$target_path' is invalid."); + } + } + } + + if (empty($uploader->pending())) + Utils::log()->error("Didn't find any certificates to upload (cert-ids: " . (empty($cert_ids) ? "*all*" : join(", ", $cert_ids)) . ")."); + + } else if (isset($options["files"])) { + $files = preg_split('/[,;\s]+/', $options["files"] ?: "", 0, PREG_SPLIT_NO_EMPTY); + foreach ($files as $file) { + $uploader->addFile($file, "", $chmod, $chgrp); + }; + + if (empty($uploader->pending())) + Utils::log()->error("Didn't files to upload (files: " . join(", ", $files) . ")."); + + } else { + Utils::log()->error("Neither '--certificates' nor '--files' was specified. Have nothing to upload."); + } +} + +function findCertificates(array $certificate_ids_or_names, $load_content = true): array +{ + if (!class_exists("OPNsense\\Core\\Config")) return []; + + $config = OPNsense\Core\Config::getInstance()->object(); + $client = $config->OPNsense->AcmeClient; + + $result = []; + $refids = []; + + foreach ($client->certificates->children() as $cert) { + $item = []; + $id = (string)$cert->id; + $name = (string)$cert->name; + + if (empty($certificate_ids_or_names) + || in_array($id, $certificate_ids_or_names) + || in_array($name, $certificate_ids_or_names)) { + + if ($cert->enabled == 0) { + if (!empty($certificate_ids_or_names)) + Utils::log()->error("Certificate '{$name}' (id: $id) is disabled, skipping it."); + + continue; + } + + $item["id"] = $id; + $item["name"] = $name; + $item["updated"] = intval($cert->lastUpdate); + $item["automations"] = preg_split('/[\s,]+/', $cert->restartActions); + if (isset($cert->certRefId)) { + $refids[] = $item['content_id'] = (string)$cert->certRefId; + } + + $result[$id] = $item; + } + } + + if ($load_content && ($certificates = exportCertificates($refids))) { + foreach ($result as &$cert_info) { + $id = $cert_info["content_id"]; + if (isset($certificates[$id])) + $cert_info["content"] = $certificates[$id]; + } + } + + return $result; +} + +function exportCertificates(array $cert_refids) +{ + $result = []; + $config = OPNsense\Core\Config::getInstance()->object(); + foreach ($config->cert as $cert) { + $refid = (string)$cert->refid; + $item = []; + if (in_array($refid, $cert_refids)) { + $item["cert"] = str_replace(["\n\n", "\r"], ["\n", ""], base64_decode($cert->crt)); + $item["key"] = str_replace(["\n\n", "\r"], ["\n", ""], base64_decode($cert->prv)); + // check if a CA is linked + if (!empty((string)$cert->caref)) { + $cert = (array)$cert; + $item["ca"] = ca_chain($cert); + } + $result[$refid] = $item; + } + } + + return $result; +} + +function configPath(): string +{ + static $paths = [ + '/var/etc/acme-client', + __DIR__ + ]; + foreach ($paths as $path) { + if (is_dir($path)) return $path . DIRECTORY_SEPARATOR . 'sftp-config'; + } + die("Failed detecting config path"); +} + +function main() +{ + global $argv; + $command = getCommand(); + $options = ["help", "log", "no-error"]; + + $has_automation_id = preg_match('/--automation-id=\S+/', join(" ", $argv)); + if ($has_automation_id) { + $options = array_merge($options, ["automation-id:", "certificates::"]); + } else { + $options = array_merge($options, $command["options"]); + } + + $index = 0; + if ($options = getopt("h", $options, $index)) { + if (isset($options["h"]) || isset($options["help"])) { + help(); + } else { + if (isset($options["log"])) + Utils::log(true)->info("Logging to stdout enabled"); + + $options = array_filter($options, function ($value) { + return !is_string($value) + || (!empty($value = trim($value)) && $value !== "__default_value"); + }); + + if (isset($options["automation-id"])) + $options = array_merge(getOptionsById($options["automation-id"]), $options); + + if (is_callable($runner = $command["implementation"])) { + $code = $runner($options); + + if ($code != EXITCODE_SUCCESS) { + Utils::log()->error("Command execution failed, exit code $code. Last input was: " . json_encode($options, JSON_UNESCAPED_SLASHES)); + } + + exit(isset($options["no-error"]) ? EXITCODE_SUCCESS : $code); + } else { + exit(EXITCODE_ERROR_UNKNOWN_COMMAND); + } + } + } else { + if (count($argv) < 2) { + help(); + } else { + $cmd = join(" ", $argv); + Utils::log()->error("Parsing of '$cmd' failed at argument '{$argv[$index]}'"); + } + exit(1); + } +} + +function requireThat($expression, $message) +{ + try { + Utils::requireThat($message, $message); + } catch (\AssertionError $e) { + exit(EXITCODE_ERROR); + } + return $expression; +} + +// Running the main script +main(); diff --git a/security/acme-client/src/opnsense/service/conf/actions.d/actions_acmeclient.conf b/security/acme-client/src/opnsense/service/conf/actions.d/actions_acmeclient.conf index 99858b1aa..7c53c1cfd 100644 --- a/security/acme-client/src/opnsense/service/conf/actions.d/actions_acmeclient.conf +++ b/security/acme-client/src/opnsense/service/conf/actions.d/actions_acmeclient.conf @@ -84,6 +84,24 @@ parameters:-c %s -a %s type:script message:uploading a certificate to highwinds +[upload-sftp] +command:/usr/local/opnsense/scripts/OPNsense/AcmeClient/upload_sftp.php +parameters:--certificates=%s --automation-id=%s +type:script +message:uploading a certificate to sftp server + +[test-sftp-connection] +command:/usr/local/opnsense/scripts/OPNsense/AcmeClient/upload_sftp.php +parameters:--host=%s --host-key=%s --port=%s --user=%s --identity-type=%s --remote-path=%s --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: