From 7118a82a0502d5f4c920f3da3402c386b78056e2 Mon Sep 17 00:00:00 2001 From: Sam Sheridan Date: Fri, 26 Jul 2024 03:08:33 +0100 Subject: [PATCH] system: add snapshots (boot environments) GUI support #7749 This pull request introduces a new feature to the OPNsense web interface, allowing users to manage FreeBSD boot environments directly within OPNsense. This integration provides an intuitive and seamless way for users to create, manage, and switch between boot environments, enhancing system management and recovery options. Renamed the menu item to "Snapshots" in an attempt to explain the feature to non-FreeBSD users. --- .../OPNsense/Core/Api/SnapshotsController.php | 240 ++++++++++++++++++ .../OPNsense/Core/SnapshotsController.php | 48 ++++ .../OPNsense/Core/forms/snapshot.xml | 12 + .../mvc/app/models/OPNsense/Core/ACL/ACL.xml | 7 + .../app/models/OPNsense/Core/Menu/Menu.xml | 1 + .../mvc/app/views/OPNsense/Core/snapshot.volt | 84 ++++++ src/opnsense/scripts/system/bectl.py | 100 ++++++++ .../service/conf/actions.d/actions_zfs.conf | 42 +++ 8 files changed, 534 insertions(+) create mode 100644 src/opnsense/mvc/app/controllers/OPNsense/Core/Api/SnapshotsController.php create mode 100644 src/opnsense/mvc/app/controllers/OPNsense/Core/SnapshotsController.php create mode 100644 src/opnsense/mvc/app/controllers/OPNsense/Core/forms/snapshot.xml create mode 100644 src/opnsense/mvc/app/views/OPNsense/Core/snapshot.volt create mode 100755 src/opnsense/scripts/system/bectl.py diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/SnapshotsController.php b/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/SnapshotsController.php new file mode 100644 index 000000000..7c90a1346 --- /dev/null +++ b/src/opnsense/mvc/app/controllers/OPNsense/Core/Api/SnapshotsController.php @@ -0,0 +1,240 @@ +environments)) { + $this->environments = json_decode(trim((new Backend())->configdRun('zfs snapshot list')), true) ?? []; + } + foreach ($this->environments as $record) { + if (isset($record[$fieldname]) && $record[$fieldname] == $value) { + return $record; + } + } + return null; + } + + /** + * @param string $uuid generated uuid to search (calculated by name) + * @return array|null + */ + private function findByUuid($uuid) + { + return $this->find('uuid', $uuid); + } + + /** + * @param string $name snapshot name, the actual key of the record + * @return array|null + */ + private function findByName($name) + { + return $this->find('name', $name); + } + + /** + * allow all but whitespaces for now + * @param string $name snapshot name + */ + private function isValidName($name) + { + return !preg_match('/\s/', $name); + } + + /** + * @return boolean is this a supported feature (ZFS enabled) + */ + public function isSupportedAction() + { + $result = json_decode((new Backend())->configdRun('zfs snapshot supported'), true) ?? []; + return ['supported' => !empty($result) && $result['status'] == 'OK']; + } + + /** + * search snapshots + * @return array + */ + public function searchAction() + { + $records = json_decode((new Backend())->configdRun('zfs snapshot list'), true) ?? []; + return $this->searchRecordsetBase($records); + } + + /** + * fetch an environment by uuid, return new when not found or $uuid equals null + * @param string $uuid + * @return array + */ + public function getAction($uuid = null) + { + if (!empty($uuid)) { + $result = $this->findByUuid($uuid); + if (!empty($result)) { + return $result; + } + } + // new or not found + return ['name' => 'BE'.date("YmdHis"), 'uuid' => '']; + } + + /** + * create a new snapshot + * @param string $uuid uuid to save + * @return array status + */ + public function setAction($uuid) + { + if ($this->request->isPost() && $this->request->hasPost('name')) { + $name = $this->request->getPost('name', 'string', null); + + $be = $this->findByUuid($uuid); + $new_be = $this->findByName($name); + + if (!empty($be) && $be['name'] == $name) { + /* skip, unchanged */ + return ['status' => 'ok']; + } elseif (!empty($be) && empty($new_be) && $this->isValidName($name)) { + return json_decode( + (new Backend())->configdpRun("zfs snapshot rename", [$be['name'], $name]), + true + ); + } else { + if (!empty($new_be)) { + $msg = gettext('A snapshot already exists by this name'); + } elseif (!$this->isValidName($name)){ + $msg = gettext('Invalid name specified'); + } else { + $msg = gettext('Snapshot not found'); + } + return [ + 'status' => 'failed', + 'validations' => [ + 'name' => $msg + ] + ]; + } + } + + return ['status' => 'failed']; + } + + /** + * add or clone a snapshot + * @return array status + */ + public function addAction() + { + if ($this->request->isPost()) { + $uuid = $this->request->getPost('uuid', 'string', ''); + $name = $this->request->getPost('name', 'string', ''); + + $msg = null; + if ($this->findByName($name)) { + $msg = gettext('A snapshot already exists by this name'); + } elseif (!$this->isValidName($name)){ + $msg = gettext('Invalid name specified'); + } + if (!empty($uuid) && empty($msg)) { + /* clone environment */ + $be = $this->findByUuid($uuid); + if (empty($be)) { + $msg = gettext('Snapshot not found'); + } else { + return json_decode( + (new Backend())->configdpRun("zfs snapshot clone", [$name, $be['name']]), true + ); + } + } elseif (empty($msg)) { + return (new Backend())->configdpRun("zfs snapshot create", [$name]); + } + + if ($msg) { + return [ + 'status' => 'failed', + 'validations' => [ + 'name' => $msg + ] + ]; + } + } + return ['status' => 'failed']; + } + + /** + * delete an environment by uuid + * @param string $uuid + * @return array + * @throws UserException when not found (or possible) + */ + public function delAction($uuid) + { + if ($this->request->isPost()) { + $be = $this->findByUuid($uuid); + if (empty($be)) { + throw new UserException(gettext("Snapshot not found"), gettext("Snapshots")); + } + if ($be['active'] != '-') { + throw new UserException(gettext("Cannot delete active snapshot"), gettext("Snapshots")); + } + return (json_decode((new Backend())->configdpRun("zfs snapshot destroy", [$be['name']]), true)); + } + return ['status' => 'failed']; + } + /** + * activate a snapshot by uuid + * @param string $uuid + * @return array + * @throws UserException when not found (or possible) + */ + public function activateAction($uuid) + { + if ($this->request->isPost()) { + $be = $this->findByUuid($uuid); + if (empty($be)) { + throw new UserException(gettext("Snapshot not found"), gettext("Snapshots")); + } + return json_decode((new Backend())->configdpRun("zfs snapshot activate", [$be['name']]), true); + } + return ['status' => 'failed']; + } +} diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Core/SnapshotsController.php b/src/opnsense/mvc/app/controllers/OPNsense/Core/SnapshotsController.php new file mode 100644 index 000000000..00866a88c --- /dev/null +++ b/src/opnsense/mvc/app/controllers/OPNsense/Core/SnapshotsController.php @@ -0,0 +1,48 @@ +view->pick('OPNsense/Core/snapshot'); + $this->view->SnapshotForm = $this->getForm('snapshot'); + } +} diff --git a/src/opnsense/mvc/app/controllers/OPNsense/Core/forms/snapshot.xml b/src/opnsense/mvc/app/controllers/OPNsense/Core/forms/snapshot.xml new file mode 100644 index 000000000..f194dd35e --- /dev/null +++ b/src/opnsense/mvc/app/controllers/OPNsense/Core/forms/snapshot.xml @@ -0,0 +1,12 @@ +
+ + uuid + + hidden + + + name + + text + +
diff --git a/src/opnsense/mvc/app/models/OPNsense/Core/ACL/ACL.xml b/src/opnsense/mvc/app/models/OPNsense/Core/ACL/ACL.xml index 5b0883486..f93e087cf 100644 --- a/src/opnsense/mvc/app/models/OPNsense/Core/ACL/ACL.xml +++ b/src/opnsense/mvc/app/models/OPNsense/Core/ACL/ACL.xml @@ -652,6 +652,13 @@ api/trust/crl/* + + System: Snapshots + + ui/snapshots/* + api/snapshots/* + + System: Firmware diff --git a/src/opnsense/mvc/app/models/OPNsense/Core/Menu/Menu.xml b/src/opnsense/mvc/app/models/OPNsense/Core/Menu/Menu.xml index 4670d3bdb..2929d928c 100644 --- a/src/opnsense/mvc/app/models/OPNsense/Core/Menu/Menu.xml +++ b/src/opnsense/mvc/app/models/OPNsense/Core/Menu/Menu.xml @@ -72,6 +72,7 @@ + diff --git a/src/opnsense/mvc/app/views/OPNsense/Core/snapshot.volt b/src/opnsense/mvc/app/views/OPNsense/Core/snapshot.volt new file mode 100644 index 000000000..ba43aaa99 --- /dev/null +++ b/src/opnsense/mvc/app/views/OPNsense/Core/snapshot.volt @@ -0,0 +1,84 @@ + + + + + + +{{ partial("layout_partials/base_dialog",['fields':SnapshotForm,'id':'frmSnapshot', 'label':lang._('Edit snapshot')])}} diff --git a/src/opnsense/scripts/system/bectl.py b/src/opnsense/scripts/system/bectl.py new file mode 100755 index 000000000..269aba00f --- /dev/null +++ b/src/opnsense/scripts/system/bectl.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +""" + Copyright (c) 2024 Deciso B.V. + Copyright (c) 2024 Sheridan Computers Limited + All rights reserved. + + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + + THIS SOFTWARE IS PROVIDED ``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, + INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY + AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE + AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, + OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + POSSIBILITY OF SUCH DAMAGE. + ------------------------------------------------------------------------------------------ + Simple wrapper around bectl shell command +""" +import argparse +import datetime +import json +import subprocess +import sys +import time +import uuid + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument( + 'action', + help='action to perform, see bectl for details', + choices=['is_supported', 'activate', 'create', 'clone', 'destroy', 'list', 'rename'] + ) + parser.add_argument('--beName', help='name of boot environment', type=str) + parser.add_argument('--from-source', help='boot environment to clone', type=str) + inputargs = parser.parse_args() + + cmd = [] + error_msg = None + if subprocess.run(['df', '-Tt', 'zfs', '/'], capture_output=True).returncode != 0: + error_msg = 'Unsupported root filesystem' + elif inputargs.action == 'is_supported': + print(json.dumps({"status": "OK", "message": "File system is ZFS"})) + elif inputargs.action == 'list': + cmd = ['bectl', 'list', '-H'] + elif inputargs.action == 'activate' and inputargs.beName: + cmd = ['bectl', 'activate', inputargs.beName] + elif inputargs.action == 'create': + name = inputargs.beName if inputargs.beName else "BE-{date:%Y%m%d%H%M%S}".format(date=datetime.datetime.now()) + cmd = ['bectl', 'create', name] + elif inputargs.action == 'clone' and inputargs.from_source: + name = inputargs.beName if inputargs.beName else "BE-{date:%Y%m%d%H%M%S}".format(date=datetime.datetime.now()) + cmd = ['bectl', 'create', '-e', inputargs.from_source, name] + elif inputargs.action == 'destroy' and inputargs.beName: + cmd = ['bectl', 'destroy', inputargs.beName] + elif inputargs.action == 'rename' and inputargs.beName and inputargs.from_source: + cmd = ['bectl', 'rename', inputargs.from_source, inputargs.beName] + else: + print(json.dumps({"status": "failed", "result": "Incomplete argument list"})) + sys.exit(-1) + + if error_msg: + print(json.dumps({"status": "failed", "result": error_msg})) + elif len(cmd) > 0: + sp = subprocess.run(cmd, capture_output=True, text=True) + if sp.returncode != 0: + print(json.dumps({"status": "failed", "result": sp.stderr.strip()})) + elif inputargs.action == 'list': + result = [] + for line in sp.stdout.split("\n"): + parts = line.split("\t") + if len(parts) >= 5: + result.append({ + "uuid": str(uuid.uuid3(uuid.NAMESPACE_DNS, parts[0])), + "name": parts[0], + "active": parts[1], + "mountpoint": parts[2], + "size": parts[3], + "created_str": parts[4], + "created": time.mktime(datetime.datetime.strptime(parts[4], "%Y-%m-%d %H:%M").timetuple()) + }) + print(json.dumps(result)) + else: + print(json.dumps({ + "status": "ok", + "result": 'bootenvironment executed %s successfully' % inputargs.action + })) + diff --git a/src/opnsense/service/conf/actions.d/actions_zfs.conf b/src/opnsense/service/conf/actions.d/actions_zfs.conf index 3200afbb1..fd86d63e9 100644 --- a/src/opnsense/service/conf/actions.d/actions_zfs.conf +++ b/src/opnsense/service/conf/actions.d/actions_zfs.conf @@ -11,3 +11,45 @@ parameters:%s type:script message:Scrubbing ZFS Pool %s description:ZFS pool scrub + +[snapshot.list] +command:/usr/local/opnsense/scripts/system/bectl.py +parameters:list +type:script_output +message:List snapshots + +[snapshot.create] +command:/usr/local/opnsense/scripts/system/bectl.py +parameters: create --beName %s +type:script_output +message:Creating snapshot %s + +[snapshot.clone] +command:/usr/local/opnsense/scripts/system/bectl.py +parameters: clone --beName %s --from-source %s +type:script_output +message:Cloning snapshot %s from %s + +[snapshot.activate] +command:/usr/local/opnsense/scripts/system/bectl.py +parameters: activate --beName %s +type:script_output +message:Activate snapshot %s + +[snapshot.destroy] +command:/usr/local/opnsense/scripts/system/bectl.py +parameters: destroy --beName %s +type:script_output +message:Delete snapshot %s + +[snapshot.rename] +command:/usr/local/opnsense/scripts/system/bectl.py +parameters: rename --from-source %s --beName %s +type:script_output +message:Rename snapshot %s to %s + +[snapshot.supported] +command:/usr/local/opnsense/scripts/system/bectl.py +parameters: is_supported +type:script_output +message:Checking if ZFS is supported