From b098a173faabd50fe27a47e1fb282474f273e9a4 Mon Sep 17 00:00:00 2001 From: Arthur Lu Date: Wed, 25 Jun 2025 19:41:17 +0000 Subject: [PATCH] add user backup endpoints --- README.md | 13 ++- config/template.config.json | 3 + src/routes/cluster/backup.js | 150 +++++++++++++++++++++++++++++++++++ template.localdb.json | 3 + 4 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 src/routes/cluster/backup.js diff --git a/README.md b/README.md index 5c22eea..1417bd9 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,20 @@ In the Proxmox web GUI, perform the following steps: 1. Add a new user `proxmoxaas-api` to Proxmox VE 2. Create a new API token for the user `proxmoxaas-api` and copy the secret key to a safe location 3. Create a new role `proxmoxaas-api` with at least the following permissions: - - VM.* except VM.Audit, VM.Backup, VM.Clone, VM.Console, VM.Monitor, VM.PowerMgmt, VM.Snapshot, VM.Snapshot.Rollback - - Datastore.Allocate, Datastore.AllocateSpace, Datastore.Audit + - VM.* except VM.Clone, VM.Console, VM.Monitor, VM.PowerMgmt, VM.Snapshot, VM.Snapshot.Rollback + - Datastore.Allocate, Datastore.AllocateSpace, Datastore.AllocateTemplate, Datastore.Audit - User.Modify - Pool.Audit - SDN.Use (if instances use SDN networks) + - Sys.Audit 4. Add a new API Token Permission with path: `/`, select the API token created previously, and role: `proxmoxaas-api` 5. Add a new User Permission with path: `/`, select the `proxmoxaas-api` user, and role: `proxmoxaas-api` +6. To prevent users from bypassing the API provided methods, create a new role with only the following permssions: + - Datastore.Audit + - VM.Audit + - VM.Console + - VM.Monitor + - VM.PowerMgmt ### Installation - API 1. Clone this repo onto the `ProxmoxAAS-API` host @@ -43,6 +50,8 @@ In the Proxmox web GUI, perform the following steps: 5. In `useriso`: - node: host of storage with user accessible iso files - storage: name of storage with user accessible iso files + 6. In `backups`: + - storage: name of storage for instance backups 4. Start the service using `node .`, or call the provided shell script, or use the provided systemctl service script # Backends diff --git a/config/template.config.json b/config/template.config.json index e236286..55d2bba 100644 --- a/config/template.config.json +++ b/config/template.config.json @@ -60,6 +60,9 @@ "node": "examplenode1", "storage": "cephfs" }, + "backups": { + "storage": "cephfs" + }, "resources": { "cpu": { "type": "list", diff --git a/src/routes/cluster/backup.js b/src/routes/cluster/backup.js new file mode 100644 index 0000000..a045986 --- /dev/null +++ b/src/routes/cluster/backup.js @@ -0,0 +1,150 @@ +import { Router } from "express"; +export const router = Router({ mergeParams: true }); ; + +const checkAuth = global.utils.checkAuth; + +/** + * GET - get backups for an instance + * request: + * - node: string - vm host node id + * - type: string - vm type (lxc, qemu) + * - vmid: number - vm id number + * responses: + * - 200: List of backups + * - 401: {auth: false, path: string} + * - 500: {error: string} + * - 500: PVE Task Object + */ +router.get("/", async (req, res) => { + const params = { + node: req.params.node, + type: req.params.type, + vmid: req.params.vmid + }; + + // check auth for specific instance + const vmpath = `/nodes/${params.node}/${params.type}/${params.vmid}`; + const auth = await checkAuth(req.cookies, res, vmpath); + if (!auth) { + return; + } + + // get vm backups + const storage = global.config.backups.storage; + const backups = await global.pve.requestPVE(`/nodes/${params.node}/storage/${storage}/content?content=backup&vmid=${params.vmid}`, "GET", { token: true }); + if (backups.status === 200) { + res.status(backups.status).send(backups.data.data); + } + else { + res.status(backups.status).send({ error: backups.statusText }); + } +}); + +/** + * POST - create a new backup of instance using snapshot mode + * !!! Due to the time that backups can take, the API will not wait for the proxmox task to finish !!! + * request: + * - node: string - vm host node id + * - type: string - vm type (lxc, qemu) + * - vmid: number - vm id number + * - notes: notes template string or null if the default one should be used + * responses: + * - 200: PVE Task Object + * - 401: {auth: false, path: string} + * - 500: {error: string} + * - 500: PVE Task Object + */ +router.post("/", async (req, res) => { + const params = { + node: req.params.node, + type: req.params.type, + vmid: req.params.vmid, + notes: req.body.notes ? req.body.notes : "[PAAS] {{node}}.{{vmid}} ({{guestname}}) has been backed up" + }; + + // check auth for specific instance + const vmpath = `/nodes/${params.node}/${params.type}/${params.vmid}`; + const auth = await checkAuth(req.cookies, res, vmpath); + if (!auth) { + return; + } + + // check if number of backups is less than the allowed number + const storage = global.config.backups.storage; + const backups = await global.pve.requestPVE(`/nodes/${params.node}/storage/${storage}/content?content=backup&vmid=${params.vmid}`, "GET", { token: true }); + const numBackups = backups.data.data.length; + const userObj = global.utils.getUserObjFromUsername(req.cookies.username); + const maxAllowed = (await global.userManager.getUser(userObj, req.cookies)).cluster.backups.max; + if (backups.status !== 200) { + res.status(backups.status).send({ error: backups.statusText }); + return; + } + else if (numBackups >= maxAllowed) { + res.status(backups.status).send({ error: `${params.vmid} already has ${numBackups} >= ${maxAllowed} max backups allowed` }); + return; + } + + // create backup using vzdump path + const body = { + storage, + vmid: params.vmid, + mode: "snapshot", + remove: 0, + compress: "zstd", + "notes-template": params.notes + }; + const result = await global.pve.requestPVE(`/nodes/${params.node}/vzdump`, "POST", { token: true }, body); + res.status(result.status).send(result.data.data); +}); + +/** + * DELETE - delete existing backup of instance + * request: + * - node: string - vm host node id + * - type: string - vm type (lxc, qemu) + * - vmid: number - vm id number + * - volid: volid of the backup to be deleted + * responses: + * - 200: PVE Task Object + * - 401: {auth: false, path: string} + * - 500: {error: string} + * - 500: PVE Task Object + */ +router.delete("/", async (req, res) => { + const params = { + node: req.params.node, + type: req.params.type, + vmid: req.params.vmid, + volid: req.body.volid + }; + + // check auth for specific instance + const vmpath = `/nodes/${params.node}/${params.type}/${params.vmid}`; + const auth = await checkAuth(req.cookies, res, vmpath); + if (!auth) { + return; + } + + // check if the specified volid is a backup for the instance + // for whatever reason, calling /nodes/node/storage/content/volid does not return the vmid number whereas /nodes/storage/content?... does + const storage = global.config.backups.storage; + const backups = await global.pve.requestPVE(`/nodes/${params.node}/storage/${storage}/content?content=backup&vmid=${params.vmid}`, "GET", { token: true }); + if (backups.status !== 200) { + res.status(backups.status).send({ error: backups.statusText }); + return; + } + let found = false; + for (const volume of backups.data.data) { + if (volume.subtype === params.type && String(volume.vmid) === params.vmid && volume.content === "backup" && volume.volid === params.volid) { + found = true; + } + } + if (!found) { + res.status(500).send({ error: `Did not find backup volume ${params.volid} for ${params.node}.${params.vmid}` }); + return; + } + + // found a valid backup with matching vmid and volid + const result = await global.pve.requestPVE(`/nodes/${params.node}/storage/${storage}/content/${params.volid}?delay=5`, "DELETE", { token: true }); + res.status(result.status).send(result.data.data); +}); diff --git a/template.localdb.json b/template.localdb.json index 04e4f83..7be43de 100644 --- a/template.localdb.json +++ b/template.localdb.json @@ -85,6 +85,9 @@ "pools": { "example-pool-1": true, "example-pool-2": true + }, + "backups": { + "max": 5 } }, "templates": {