From a6786e059ddddb39b9c0847c5960bcbe5753daae Mon Sep 17 00:00:00 2001 From: Ad Schellevis Date: Sun, 23 Feb 2025 19:42:13 +0100 Subject: [PATCH] sysutils/sftp-backup : add sftp backup connector --- sysutils/sftp-backup/Makefile | 7 + sysutils/sftp-backup/pkg-descr | 3 + .../mvc/app/library/OPNsense/Backup/Sftp.php | 269 ++++++++++++++++++ .../models/OPNsense/Backup/SftpSettings.php | 39 +++ .../models/OPNsense/Backup/SftpSettings.xml | 52 ++++ 5 files changed, 370 insertions(+) create mode 100644 sysutils/sftp-backup/Makefile create mode 100644 sysutils/sftp-backup/pkg-descr create mode 100644 sysutils/sftp-backup/src/opnsense/mvc/app/library/OPNsense/Backup/Sftp.php create mode 100644 sysutils/sftp-backup/src/opnsense/mvc/app/models/OPNsense/Backup/SftpSettings.php create mode 100644 sysutils/sftp-backup/src/opnsense/mvc/app/models/OPNsense/Backup/SftpSettings.xml diff --git a/sysutils/sftp-backup/Makefile b/sysutils/sftp-backup/Makefile new file mode 100644 index 000000000..918b74903 --- /dev/null +++ b/sysutils/sftp-backup/Makefile @@ -0,0 +1,7 @@ +PLUGIN_NAME= sftp-backup +PLUGIN_VERSION= 1.0 +PLUGIN_COMMENT= Backup configurations using sftp +PLUGIN_MAINTAINER= ad@opnsense.org +PLUGIN_TIER= 2 + +.include "../../Mk/plugins.mk" diff --git a/sysutils/sftp-backup/pkg-descr b/sysutils/sftp-backup/pkg-descr new file mode 100644 index 000000000..f9dcd53e9 --- /dev/null +++ b/sysutils/sftp-backup/pkg-descr @@ -0,0 +1,3 @@ +This package adds a backup option using sftp (secure copy). + +Due to the sensitive nature of the data being send to the backup, we strongly advise to not use a public service to send backups to. diff --git a/sysutils/sftp-backup/src/opnsense/mvc/app/library/OPNsense/Backup/Sftp.php b/sysutils/sftp-backup/src/opnsense/mvc/app/library/OPNsense/Backup/Sftp.php new file mode 100644 index 000000000..5c49ac008 --- /dev/null +++ b/sysutils/sftp-backup/src/opnsense/mvc/app/library/OPNsense/Backup/Sftp.php @@ -0,0 +1,269 @@ +model = new SftpSettings(); + } + + /** + * @inheritdoc + */ + public function getConfigurationFields() + { + $fields = [ + [ + "name" => "enabled", + "type" => "checkbox", + "label" => gettext("Enable"), + "value" => null + ], + [ + "name" => "url", + "type" => "text", + "label" => gettext("URL"), + "help" => gettext( + "Target location, specified as uri, e.g. sftp://user@my.host.at.domain[:port]//path/to/backup" + ), + "value" => null + ], + [ + "name" => "privkey", + "type" => "passwordarea", + "label" => gettext("SSH private key"), + "help" => gettext("The private key used to setup the connection."), + "value" => null + ], + [ + "name" => "backupcount", + "type" => "text", + "label" => gettext("Backup Count"), + "value" => null + ], + [ + "name" => "password", + "type" => "password", + "label" => gettext("Encrypt Password"), + "value" => null + ], + [ + "name" => "passwordconfirm", + "type" => "password", + "label" => gettext("Confirm"), + "value" => null + ] + ]; + foreach ($fields as &$field) { + if ($field['name'] == 'passwordconfirm') { + $field['value'] = (string)$this->model->getNodeByReference('password'); + } else { + $field['value'] = (string)$this->model->getNodeByReference($field['name']); + } + } + return $fields; + } + + /** + * @inheritdoc + */ + public function getName() + { + return gettext("sftp"); + } + + /** + * @inheritdoc + */ + public function setConfiguration($conf) + { + $this->setModelProperties($this->model, $conf); + $validation_messages = $this->validateModel($this->model); + if ($conf['passwordconfirm'] != $conf['password']) { + $validation_messages[] = gettext("The supplied 'Password' and 'Confirm' field values must match."); + } + if (empty($validation_messages)) { + $this->model->serializeToConfig(); + Config::getInstance()->save(); + } + return $validation_messages; + } + + /** + * sftp command + * @param string $sftpcmd command to execute + * @return array [stdout|stderr|exit_status] + */ + private function sftpCmd($sftpcmd) + { + $cmd = [ + '/usr/local/bin/sftp', + '-o StrictHostKeyChecking=accept-new', + '-o PasswordAuthentication=no', + '-o ChallengeResponseAuthentication=no', + '-i ' . $this->getIdentity(), + escapeshellarg($this->model->url) + ]; + + $result = ['exit_status' => -1, 'stderr' => '', 'stdout' => '']; + $process = proc_open( + implode(' ', $cmd), + [["pipe", "r"], ["pipe", "w"], ["pipe", "w"]], + $pipes + ); + if (is_resource($process)) { + fwrite($pipes[0], $sftpcmd); + fclose($pipes[0]); + $result['stdout'] = stream_get_contents($pipes[1]); + fclose($pipes[1]); + $result['stderr'] = stream_get_contents($pipes[2]); + fclose($pipes[2]); + $result['exit_status'] = proc_close($process); + } + if ($result['exit_status'] !== 0) { + /* always throw on non zero exit status */ + syslog(LOG_ERR, "sftp-backup error (" . str_replace("\n", " ", $result['stderr']) . ")"); + throw new \Exception($result['stderr']); + } + return $result; + } + + /** + * @return identity file, create new when non existent + */ + private function getIdentity() + { + $confdir = "/conf/backup/sftp"; + $identfile = $confdir . '/identity'; + if (!is_dir($confdir)) { + mkdir($confdir); + } + if (!is_file($identfile) || file_get_contents($identfile) != $this->model->privkey) { + File::file_put_contents($identfile, $this->model->privkey, 0600); + } + return $identfile; + } + + /** + * @return list of files on remote location + */ + private function ls($pattern='') + { + $result = []; + foreach (explode("\n", $this->sftpCmd('ls -lnt '. $pattern)['stdout']) as $line) { + $parts = preg_split('/\s+/', $line, -1, PREG_SPLIT_NO_EMPTY); + if (count($parts) >= 7) { + $result[] = $parts[count($parts)-1]; + } + } + return $result; + } + + /** + * @param string $source filename + * @param string $destination filename + */ + private function put($source, $destination) + { + $this->sftpCmd(sprintf('put %s %s', $source, $destination)); + } + + /** + * @param string $filename + */ + private function del($filename) + { + $this->sftpCmd(sprintf('rm %s', $filename)); + } + + /** + * @return array filelist + */ + public function backup() + { + if ($this->model->enabled->isEmpty()) { + /* disabled */ + return; + } + /** + * Collect most recent backup, since /conf/backup/ always contains the latests, we can use the filename + * for easy comparison. + **/ + $all_backups = glob('/conf/backup/config-*.xml'); + $most_recent = $all_backups[count($all_backups) - 1]; + $confdata = file_get_contents($most_recent); + if (!$this->model->password->isEmpty()) { + $confdata = $this->encrypt($confdata, (string)$this->model->password); + } + /* backup filename when not already on remote location */ + $remote_backups = $this->ls('config-*.xml'); + $target_filename = basename($most_recent); + if (!in_array($target_filename, $remote_backups)) { + syslog(LOG_NOTICE, "backup configuration as " . $target_filename); + $tmpfilename = sprintf("/conf/backup/sftp/%s", $target_filename); + File::file_put_contents($tmpfilename, $confdata, 0600); + $this->put($tmpfilename, $target_filename); + unlink($tmpfilename); + $remote_backups = $this->ls('config-*.xml'); + } + /* cleanup */ + rsort($remote_backups); + if (count($remote_backups) > (int)$this->model->backupcount->getCurrentValue()) { + for ($i = $this->model->backupcount->getCurrentValue() ; $i < count($remote_backups); $i++) { + $this->del($remote_backups[$i]); + } + $remote_backups = $this->ls('config-*.xml'); + } + + return $remote_backups; + } + + /** + * @inheritdoc + */ + public function isEnabled() + { + return !$this->model->enabled->isEmpty(); + } +} diff --git a/sysutils/sftp-backup/src/opnsense/mvc/app/models/OPNsense/Backup/SftpSettings.php b/sysutils/sftp-backup/src/opnsense/mvc/app/models/OPNsense/Backup/SftpSettings.php new file mode 100644 index 000000000..4cad4acf7 --- /dev/null +++ b/sysutils/sftp-backup/src/opnsense/mvc/app/models/OPNsense/Backup/SftpSettings.php @@ -0,0 +1,39 @@ + + //system/backup/sftp + 1.0.0 + OPNsense sftp Backup Settings + + + 0 + Y + + + privkey.check001 + + + url.check001 + + + + + N + /^((sftp))?:\/\/.*[^\/]$/ + A valid location must be provided. + + + A backup location (url) is required. + DependConstraint + + enabled + + + + + + N + + + A private key is required. + DependConstraint + + enabled + + + + + + + + 60 + Y + 1 + + +