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
+
+
+