From e139155afcbde390f23f34dcd2a5a9ba8d5042ff Mon Sep 17 00:00:00 2001
From: Tommaso Bailetti <tommaso.bailetti@nethesis.it>
Date: Thu, 19 Oct 2023 10:03:44 +0200
Subject: [PATCH] ns-api: added ns.backup APIs

Signed-off-by: Tommaso Bailetti <tommaso.bailetti@nethesis.it>
---
 packages/ns-api/Makefile             |   4 +-
 packages/ns-api/README.md            | 166 ++++++++++++++++++++++
 packages/ns-api/files/ns.backup      | 200 +++++++++++++++++++++++++++
 packages/ns-api/files/ns.backup.json |  13 ++
 4 files changed, 382 insertions(+), 1 deletion(-)
 create mode 100755 packages/ns-api/files/ns.backup
 create mode 100644 packages/ns-api/files/ns.backup.json

diff --git a/packages/ns-api/Makefile b/packages/ns-api/Makefile
index e24b6c317..8a859ea9b 100644
--- a/packages/ns-api/Makefile
+++ b/packages/ns-api/Makefile
@@ -6,7 +6,7 @@
 include $(TOPDIR)/rules.mk
  
 PKG_NAME:=ns-api
-PKG_VERSION:=0.0.13
+PKG_VERSION:=0.0.14
 PKG_RELEASE:=1
  
 PKG_BUILD_DIR:=$(BUILD_DIR)/ns-api-$(PKG_VERSION)
@@ -105,6 +105,8 @@ define Package/ns-api/install
 	$(INSTALL_DATA) ./files/ns.storage.json $(1)/usr/share/rpcd/acl.d/
 	$(INSTALL_BIN) ./files/ns.account $(1)/usr/libexec/rpcd/
 	$(INSTALL_DATA) ./files/ns.account.json $(1)/usr/share/rpcd/acl.d/
+	$(INSTALL_BIN) ./files/ns.backup $(1)/usr/libexec/rpcd/
+	$(INSTALL_DATA) ./files/ns.backup.json $(1)/usr/share/rpcd/acl.d/
 	$(INSTALL_DIR) $(1)/lib/upgrade/keep.d
 	$(INSTALL_CONF) files/msmtp.keep $(1)/lib/upgrade/keep.d/msmtp
 	$(LN) /usr/bin/msmtp $(1)/usr/sbin/sendmail
diff --git a/packages/ns-api/README.md b/packages/ns-api/README.md
index 2deebcf6c..9e74f00e7 100644
--- a/packages/ns-api/README.md
+++ b/packages/ns-api/README.md
@@ -2476,3 +2476,169 @@ Parameter list:
 
 - `username`: target to change the password to, must be present inside `rpcd` configuration
 - `password`: password to set to the user
+
+## ns.backup
+
+Allows the backup/restore of the system, some features are only available to subscribed users. Please refer
+to `ns.subscription` for more info.
+
+### set-passphrase
+
+Set encryption passphrase to be used to encrypt/decrypt backups.
+
+Required parameters:
+
+- `passphrase`: passphrase to be saved in the machine
+
+```bash
+api-cli ns.backup set-passphrase --data='{"passphrase": "another-very-cool-passphrase"}'
+```
+
+To delete the passphrase, just set it to an empty string:
+
+```bash
+api-cli ns.backup set-passphrase --data='{"passphrase": ""}'
+```
+
+Example response:
+
+```json
+{
+   "message": "success"
+}
+```
+
+### backup
+
+Runs a backup of the system, returning a `base64` encoding of the result file, the backup will be encoded with the passphrase set with `set-passphrase` API.
+
+```bash
+api-cli ns.backup backup
+```
+
+Example response:
+
+```json
+{
+   "backup": "H4sIAAAAAAAAA+w9a3PbNr..."
+}
+```
+
+### restore
+
+Restore the system with the given backup, once successful, restarts the system.
+
+Required parameters:
+
+- `backup`: contains the backup to restore, must be a `base64` encoded string
+
+```bash
+api-cli ns.backup restore --data '{"backup": "H4sIAAAAAAAAA+w9a3PbNr..."}' 
+```
+
+Optional `passphrase` can be given to decrypt the file:
+
+```bash
+api-cli ns.backup restore --data '{"backup": "H4sIAAAAAAAAA+w9a3PbNr...", "passphrase": "very-cool-passphrase"}'
+```
+
+Example response:
+
+```json
+{
+   "message": "success"
+}
+```
+
+### registered-list-backups
+
+List backups stored on remote server.
+
+```bash 
+api-cli ns.backup registered-list-backups
+```
+
+Example response:
+
+```json
+{
+   "values": [
+      {
+         "file": "882e399c4e6562da3cfa43e886d82454c1b6311392608100801ad0e19307e8b3.bin",
+         "name": "Cool backup"
+      },
+      {
+         "file": "2fc23dae2cfa1ac1aafe074f7662cb0a2fc23dae2cfa1ac1aafe074f7662cb0a.bin",
+         "name": "Not so cool backup"
+      },
+      {
+         "file": "83174ecdae5e5de26942c026c37d30b31793b667d35a8bb62794da153853c8f0.bin",
+         "name": "Very old backup"
+      }
+   ]
+}
+```
+
+### registered-backup
+
+Manually run a backup and send it to remote server, the backup will be encoded with the passphrase set with `set-passphrase` API.
+
+```bash
+api-cli ns.backup registered-backup
+```
+
+Example response:
+
+```json
+{
+   "message": "success"
+}
+```
+
+### registered-restore
+
+Restore a backup from the remote server.
+
+Required parameters:
+
+- `file`: contains the name of the backup to restore, can be retrieved from `registered-list-backups` API
+
+```bash
+api-cli ns.backup registered-restore --data '{"file": "882e399c4e6562da3cfa43e886d82454c1b6311392608100801ad0e19307e8b3.bin"}'
+```
+
+Optional parameters:
+
+- `passphrase`: passphrase to decrypt the backup if needed
+
+```bash
+api-cli ns.backup registered-restore --data '{"file": "882e399c4e6562da3cfa43e886d82454c1b6311392608100801ad0e19307e8b3.bin", "passphrase": "very-cool-passphrase"}'
+```
+
+Example response:
+
+```json
+{
+   "message": "success"
+}
+```
+
+### registered-download-backup
+
+Download a backup from the remote server.
+
+Required parameters:
+
+- `file`: contains the name of the backup to download, can be retrieved from `registered-list-backups` API
+
+```bash
+api-cli ns.backup registered-download-backup --data '{"file": "882e399c4e6562da3cfa43e886d82454c1b6311392608100801ad0e19307e8b3.bin"}'
+```
+
+Example response:
+
+```json
+{
+   "backup": "H4sIAAAAAAAAA+w9a3PbNr..."
+}
+```
\ No newline at end of file
diff --git a/packages/ns-api/files/ns.backup b/packages/ns-api/files/ns.backup
new file mode 100755
index 000000000..e33aafc9f
--- /dev/null
+++ b/packages/ns-api/files/ns.backup
@@ -0,0 +1,200 @@
+#!/usr/bin/python3
+
+#
+# Copyright (C) 2023 Nethesis S.r.l.
+# SPDX-License-Identifier: GPL-2.0-only
+#
+
+import base64
+import json
+import os
+import shutil
+import subprocess
+import sys
+
+from nethsec import utils
+
+BACKUP_PATH = '/tmp/backup.tar.gz'
+PASSPHRASE_PATH = '/etc/backup.pass'
+
+
+def create_backup():
+    try:
+        # run sysupgrade to create backup file
+        subprocess.run(['/sbin/sysupgrade', '-b', BACKUP_PATH], check=True, capture_output=True)
+        # if passphrase is present, encrypt backup file
+        passphrase = fetch_passphrase()
+        if passphrase is not None:
+            subprocess.run([
+                '/usr/bin/gpg',
+                '--symmetric',
+                '--batch',
+                '--passphrase', passphrase,
+                '--output', f'{BACKUP_PATH}.gpg',
+                '--yes',
+                BACKUP_PATH
+            ], check=True, capture_output=True)
+            shutil.move(f'{BACKUP_PATH}.gpg', BACKUP_PATH)
+
+    except subprocess.CalledProcessError as error:
+        if error.cmd[0] == '/usr/bin/gpg':
+            raise RuntimeError('encryption failed')
+        elif error.cmd[0] == '/sbin/sysupgrade':
+            raise RuntimeError('backup failed')
+
+
+def restore_backup(passphrase=None):
+    try:
+        if passphrase is not None:
+            subprocess.run([
+                '/usr/bin/gpg',
+                '--decrypt',
+                '--batch',
+                '--passphrase', passphrase,
+                '--output', f'{BACKUP_PATH}.decrypted',
+                '--yes',
+                BACKUP_PATH
+            ], check=True, capture_output=True)
+            shutil.move(f'{BACKUP_PATH}.decrypted', BACKUP_PATH)
+
+        # run sysupgrade to restore backup file
+        subprocess.run(['/sbin/sysupgrade', '-r', BACKUP_PATH], check=True, capture_output=True)
+
+    except subprocess.CalledProcessError as error:
+        if error.cmd[0] == '/usr/bin/gpg':
+            raise RuntimeError('decryption failed')
+        elif error.cmd[0] == '/sbin/sysupgrade':
+            raise RuntimeError('restore failed')
+
+
+def fetch_passphrase():
+    try:
+        passphrase = None
+        if os.path.exists(PASSPHRASE_PATH):
+            passphrase = open(PASSPHRASE_PATH, 'r').read().strip()
+
+        return passphrase
+    except IOError:
+        raise RuntimeError('failed to read passphrase')
+
+
+cmd = sys.argv[1]
+
+if cmd == 'list':
+    print(json.dumps({
+        'backup': { },
+        'restore': {
+            'backup': 'str',
+            'passphrase': 'str'
+        },
+        'set-passphrase': {
+            'passphrase': 'str'
+        },
+        'registered-list-backups': {},
+        'registered-backup': {},
+        'registered-restore': {
+            'file': 'str',
+            'passphrase': 'str'
+        },
+        'registered-download-backup': {
+            'file': 'str'
+        }
+    }))
+elif cmd == 'call':
+    action = sys.argv[2]
+    if action == 'backup':
+        try:
+            # decode input, generate backup
+            create_backup()
+            # encode backup in base64 and removed used file
+            backup_encoded = base64.b64encode(open(BACKUP_PATH, 'rb').read()).decode('utf-8')
+            os.remove(BACKUP_PATH)
+            # return content
+            print(json.dumps({'backup': backup_encoded}))
+        except RuntimeError as error:
+            print(json.dumps(utils.generic_error(error.args[0])))
+
+    elif action == 'restore':
+        try:
+            # decode input, write backup in default path
+            data = json.load(sys.stdin)
+            open(BACKUP_PATH, 'wb').write(base64.b64decode(data['backup']))
+            # restore backup
+            restore_backup(data.get('passphrase', None))
+            # restore successful, restart the system
+            subprocess.run(['/sbin/reboot'], check=True, capture_output=True)
+            # reboot takes a few seconds to complete, enough to send the response
+            print(json.dumps({'message': 'success'}))
+        except RuntimeError as error:
+            print(json.dumps(utils.generic_error(error.args[0])))
+
+    elif action == 'set-passphrase':
+        try:
+            # read passphrase, write into default file
+            data = json.load(sys.stdin)
+            if data['passphrase'] == '':
+                os.remove(PASSPHRASE_PATH)
+            else:
+                open(PASSPHRASE_PATH, 'w').write(data['passphrase'])
+            print(json.dumps({'message': 'success'}))
+        except IOError as error:
+            print(json.dumps(utils.generic_error(f'passphrase write failed')))
+        except KeyError as error:
+            print(json.dumps(utils.validation_error('passphrase', 'required')))
+
+    elif action == 'registered-list-backups':
+        try:
+            # list remote backups, the return is a string, so it gets decoded and encoded to generate a formatted json
+            completed_process = subprocess.run(['/usr/sbin/remote-backup', 'list'], check=True, capture_output=True,
+                                               text=True)
+            print(json.dumps({'values': json.loads(completed_process.stdout)}))
+        except subprocess.CalledProcessError as error:
+            print(json.dumps(utils.generic_error(f'remote list failed')))
+
+    elif action == 'registered-backup':
+        try:
+            # create backup
+            create_backup()
+            # upload backup to server and remove it from filesystem
+            completed_process = subprocess.run(['/usr/sbin/remote-backup', 'upload', BACKUP_PATH], check=True,
+                                               capture_output=True)
+            os.remove(BACKUP_PATH)
+            print(json.dumps({'message': 'success'}))
+        except subprocess.CalledProcessError as error:
+            print(json.dumps(utils.generic_error(f'remote upload failed')))
+        except RuntimeError as error:
+            print(json.dumps(utils.generic_error(error.args[0])))
+
+    elif action == 'registered-restore':
+        try:
+            # fetch file from remote server
+            data = json.load(sys.stdin)
+            subprocess.run(['/usr/sbin/remote-backup', 'download', data['file'], BACKUP_PATH],
+                           check=True, capture_output=True)
+            # fetch completed successfully, restore system
+            restore_backup(data.get('passphrase', None))
+            # restore successful, restart the system
+            subprocess.run(['/sbin/reboot'], check=True, capture_output=True)
+            # reboot takes a few seconds to complete, enough to send the response
+            print(json.dumps({'message': 'success'}))
+        except subprocess.CalledProcessError as error:
+            print(json.dumps(utils.generic_error('remote backup download failed')))
+        except KeyError as error:
+            print(json.dumps(utils.validation_error('file', 'required')))
+        except RuntimeError as error:
+            print(json.dumps(utils.generic_error(error.args[0])))
+
+    elif action == 'registered-download-backup':
+        try:
+            # fetch file from remote server
+            data = json.load(sys.stdin)
+            subprocess.run(['/usr/sbin/remote-backup', 'download', data['file'], BACKUP_PATH],
+                           check=True, capture_output=True)
+            backup_encoded = base64.b64encode(open(BACKUP_PATH, 'rb').read()).decode('utf-8')
+            os.remove(BACKUP_PATH)
+            # return content
+            print(json.dumps({'backup': backup_encoded}))
+        except subprocess.CalledProcessError as error:
+            print(json.dumps(utils.generic_error('remote backup download failed')))
+        except KeyError as error:
+            print(json.dumps(utils.validation_error('file', 'required')))
diff --git a/packages/ns-api/files/ns.backup.json b/packages/ns-api/files/ns.backup.json
new file mode 100644
index 000000000..07c09e83c
--- /dev/null
+++ b/packages/ns-api/files/ns.backup.json
@@ -0,0 +1,13 @@
+{
+  "backup-manager": {
+    "description": "Manages community and enterprise backups",
+    "write": {},
+    "read": {
+      "ubus": {
+        "ns.backup": [
+          "*"
+        ]
+      }
+    }
+  }
+}
