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