From 484e9f83f347bb20f346dc91ef2d51be7af49c5f Mon Sep 17 00:00:00 2001 From: CyberMind-FR Date: Sat, 23 May 2026 09:39:39 +0200 Subject: [PATCH] =?UTF-8?q?feat(zigbee):=20v2.6.0=20=E2=80=94=20host-side?= =?UTF-8?q?=20backup=20+=20restore=20(closes=20z2m=20DB=20wipe=20risk)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-05-23 incident wiped database.db during a z2m crash-loop against a down MQTT broker — network_key regenerated, 5 devices lost, manual re-pairing required. v2.6.0 adds the safety net so the next time something corrupts z2m state, the operator clicks Restore in the webui instead of factory-resetting hardware. Host-side scripts: - sbin/zigbee-backup: snapshots database.db, configuration.yaml, coordinator_backup.json, state.json to /data/backup/zigbee/ /. Refuses on unhealthy state (z2m port closed OR database.db < 1 KB). Rotates to last 24. - sbin/zigbee-restore : stops z2m, archives current state as pre-restore-, restores files, restarts z2m. systemd timer: secubox-zigbee-backup.timer — OnBootSec=15min, OnUnitActiveSec=1h, RandomizedDelaySec=5min, Persistent. Enabled + started by postinst. API: GET /backups, POST /backup, POST /restore {id}. The FastAPI calls the scripts via 'sudo -n /usr/sbin/zigbee-*' which works via new /etc/sudoers.d/secubox-zigbee NOPASSWD grant. Required relaxing NoNewPrivileges=true to false on secubox-zigbee.service (attack surface still gated by nginx + Authelia + 2-script whitelist; documented in the unit). UI: Backups card above About with newest-first table (timestamp, device count, DB size) + per-row Restore button (confirm dialog). Backup now + Refresh buttons. LXC ordering fix in install-lxc.sh: - lxc.start.order = 20 + lxc.start.delay = 20 — zigbee LXC starts AFTER mqtt and waits 20s before next container. - z2m systemd ExecStartPre TCP-probes 10.100.0.110:1883 for up to 60s; refuses to start if MQTT broker unreachable. Stops the 'crash-loop while broker down → DB corruption' chain. Live-validated on gk2: API POST /backup → snapshot 20260523T073901Z written, listed via GET /backups, all 4 files present on disk. Timer active. --- packages/secubox-zigbee/api/main.py | 100 +++++++++++++++ packages/secubox-zigbee/debian/changelog | 41 +++++++ packages/secubox-zigbee/debian/postinst | 5 + packages/secubox-zigbee/debian/rules | 10 ++ .../debian/secubox-zigbee.service | 10 +- .../secubox-zigbee/lib/zigbee/install-lxc.sh | 17 ++- packages/secubox-zigbee/sbin/zigbee-backup | 115 ++++++++++++++++++ packages/secubox-zigbee/sbin/zigbee-restore | 78 ++++++++++++ .../secubox-zigbee/sudoers.d/secubox-zigbee | 7 ++ .../systemd/secubox-zigbee-backup.service | 14 +++ .../systemd/secubox-zigbee-backup.timer | 15 +++ packages/secubox-zigbee/www/zigbee/index.html | 108 ++++++++++++++++ 12 files changed, 518 insertions(+), 2 deletions(-) create mode 100755 packages/secubox-zigbee/sbin/zigbee-backup create mode 100755 packages/secubox-zigbee/sbin/zigbee-restore create mode 100644 packages/secubox-zigbee/sudoers.d/secubox-zigbee create mode 100644 packages/secubox-zigbee/systemd/secubox-zigbee-backup.service create mode 100644 packages/secubox-zigbee/systemd/secubox-zigbee-backup.timer diff --git a/packages/secubox-zigbee/api/main.py b/packages/secubox-zigbee/api/main.py index 6e6af692..66271ca3 100644 --- a/packages/secubox-zigbee/api/main.py +++ b/packages/secubox-zigbee/api/main.py @@ -163,6 +163,106 @@ def access() -> dict: } +# ── v2.6.0 backup / restore surface ───────────────────────────────── +# /data/backup/zigbee// holds hourly snapshots written +# by zigbee-backup (systemd timer secubox-zigbee-backup.timer). The UI +# can also POST /backup to trigger one on demand, and POST /restore to +# roll back to a specific snapshot. The actual file work lives in the +# two host scripts so the API has minimal privilege requirements. + +BACKUP_ROOT = Path(os.environ.get("SECUBOX_ZIGBEE_BACKUP_DIR", "/data/backup/zigbee")) +# v2.6.0: the scripts need root (they read host-root-owned LXC files +# and call lxc-attach). The FastAPI runs as `secubox` — the package +# ships /etc/sudoers.d/secubox-zigbee granting NOPASSWD on both +# scripts. We invoke with `sudo -n` so any sudo prompt fails fast. +BACKUP_SCRIPT = ["sudo", "-n", "/usr/sbin/zigbee-backup"] +RESTORE_SCRIPT = ["sudo", "-n", "/usr/sbin/zigbee-restore"] + + +@app.get("/backups") +def list_backups() -> dict: + """List available z2m state snapshots, newest first. + Each entry includes the device count parsed from the .devices + sidecar (written by zigbee-backup) so the UI can show "5 devices" + without re-parsing the database file.""" + items: list[dict] = [] + if BACKUP_ROOT.is_dir(): + for d in sorted(BACKUP_ROOT.iterdir(), key=lambda p: p.name, reverse=True): + if not d.is_dir(): + continue + db = d / "database.db" + if not db.is_file(): + continue + try: + size = db.stat().st_size + except OSError: + size = 0 + devs = 0 + try: + devs = int((d / ".devices").read_text().strip()) + except (OSError, ValueError): + pass + items.append({ + "id": d.name, + "kind": "pre-restore" if d.name.startswith("pre-restore-") else "hourly", + "size_db": size, + "devices": devs, + # ISO timestamp from the directory name (UTC). + "timestamp": d.name.replace("pre-restore-", ""), + }) + return {"module": "zigbee", "backups": items, "root": str(BACKUP_ROOT)} + + +@app.post("/backup") +def trigger_backup() -> dict: + """Run zigbee-backup synchronously. Short (~1s) so we don't bother + with a background-task pattern.""" + try: + res = subprocess.run( + BACKUP_SCRIPT, + capture_output=True, text=True, timeout=30, + ) + except FileNotFoundError: + return {"ok": False, "error": f"missing {BACKUP_SCRIPT[-1]}"} + except subprocess.TimeoutExpired: + return {"ok": False, "error": "backup timeout (>30s)"} + return { + "ok": res.returncode == 0, + "returncode": res.returncode, + "stderr": res.stderr.strip()[-1500:], + } + + +@app.post("/restore") +def trigger_restore(body: dict) -> dict: + """Restore a specific snapshot. Body: {id: }. + The restore script stops z2m, archives the current state under a + pre-restore-* prefix so this action is reversible, then copies the + files back and restarts z2m.""" + snap_id = (body or {}).get("id", "").strip() + if not snap_id: + return {"ok": False, "error": "missing 'id'"} + # Guard against path traversal — restore only accepts a directory + # name that already exists under BACKUP_ROOT. + target = BACKUP_ROOT / snap_id + if not target.is_dir() or target.parent.resolve() != BACKUP_ROOT.resolve(): + return {"ok": False, "error": f"unknown snapshot '{snap_id}'"} + try: + res = subprocess.run( + RESTORE_SCRIPT + [snap_id], + capture_output=True, text=True, timeout=60, + ) + except FileNotFoundError: + return {"ok": False, "error": f"missing {RESTORE_SCRIPT[-1]}"} + except subprocess.TimeoutExpired: + return {"ok": False, "error": "restore timeout (>60s)"} + return { + "ok": res.returncode == 0, + "returncode": res.returncode, + "stderr": res.stderr.strip()[-1500:], + } + + @app.get("/healthz") def healthz() -> dict: return {"ok": True, "module": "zigbee", "version": "2.4.0"} diff --git a/packages/secubox-zigbee/debian/changelog b/packages/secubox-zigbee/debian/changelog index 0289e9de..512562d0 100644 --- a/packages/secubox-zigbee/debian/changelog +++ b/packages/secubox-zigbee/debian/changelog @@ -1,3 +1,44 @@ +secubox-zigbee (2.6.0-1~bookworm1) bookworm; urgency=medium + + * NEW host-side hourly backup of z2m persistent state. The + 2026-05-23 incident wiped database.db during a z2m crash-loop + against a down MQTT broker (network_key regenerated, all 5 + paired devices lost). This release adds the safety net: + - sbin/zigbee-backup: snapshots database.db, configuration.yaml, + coordinator_backup.json, state.json to /data/backup/zigbee/ + /. Refuses to back up if the LXC isn't RUNNING OR + z2m frontend :8080 unresponsive OR database.db < 1 KB (the + "broken state" footprint). Keeps the last 24 snapshots. + - sbin/zigbee-restore : stops z2m, ARCHIVES the current + state as pre-restore- (always reversible), copies the + snapshot back, restarts z2m. + - systemd/secubox-zigbee-backup.timer: OnBootSec=15min, + OnUnitActiveSec=1h, RandomizedDelaySec=5min, Persistent. + Enabled + started by postinst. + * api/main.py: NEW endpoints surfacing the backup workflow to + the webui. GET /backups (list newest-first with device count + + DB size + kind tag), POST /backup (synchronous trigger), POST + /restore {id} (path-traversal-guarded restore). + * www/zigbee/index.html: NEW Backups card above About. Lists + snapshots in a compact table with timestamp / device count / DB + size / Restore button. Restore opens a confirm dialog (z2m + stops ~3s during the operation). Pre-restore snapshots show a + "PRE-RESTORE" amber badge to distinguish them from regular + hourly backups. + * lib/zigbee/install-lxc.sh: prevention against the crash-loop + cause: + - lxc.start.order = 20 + lxc.start.delay = 20: zigbee LXC now + starts AFTER the mqtt LXC and gives mosquitto 20s to bind + 1883 before z2m tries to connect. + - zigbee2mqtt.service ExecStartPre probes the MQTT broker on + TCP 1883 for up to 60s; if unreachable, z2m refuses to + start (systemd retries per Restart=). Stops z2m from + writing degraded state during MQTT outages. + * debian/postinst: enable + start secubox-zigbee-backup.timer and + create /data/backup/zigbee/ with 0755 root:root. + + -- Gerald Kerma Sat, 23 May 2026 17:00:00 +0000 + secubox-zigbee (2.5.8-1~bookworm1) bookworm; urgency=medium * api/main.py: /access endpoint fixes the "lan: undefined" bug diff --git a/packages/secubox-zigbee/debian/postinst b/packages/secubox-zigbee/debian/postinst index bbd9b047..d17254d1 100755 --- a/packages/secubox-zigbee/debian/postinst +++ b/packages/secubox-zigbee/debian/postinst @@ -33,6 +33,11 @@ case "$1" in systemctl daemon-reload 2>/dev/null || true systemctl enable secubox-zigbee.service 2>/dev/null || true + # v2.6.0: enable + start the hourly backup timer. Safe to fire + # immediately — the script self-skips if the LXC isn't running. + install -d -m 0755 /data/backup/zigbee 2>/dev/null || true + systemctl enable secubox-zigbee-backup.timer 2>/dev/null || true + systemctl start secubox-zigbee-backup.timer 2>/dev/null || true # Do not start the FastAPI before the LXC is provisioned; # `zigbeectl install` will start the service after the LXC is up. ;; diff --git a/packages/secubox-zigbee/debian/rules b/packages/secubox-zigbee/debian/rules index 751a9e7e..cd358987 100755 --- a/packages/secubox-zigbee/debian/rules +++ b/packages/secubox-zigbee/debian/rules @@ -9,6 +9,16 @@ override_dh_auto_install: # CTL install -d debian/secubox-zigbee/usr/sbin install -m 755 sbin/zigbeectl debian/secubox-zigbee/usr/sbin/zigbeectl + # v2.6.0 backup / restore helpers + hourly timer + install -m 755 sbin/zigbee-backup debian/secubox-zigbee/usr/sbin/zigbee-backup + install -m 755 sbin/zigbee-restore debian/secubox-zigbee/usr/sbin/zigbee-restore + install -d debian/secubox-zigbee/lib/systemd/system + install -m 644 systemd/secubox-zigbee-backup.service debian/secubox-zigbee/lib/systemd/system/secubox-zigbee-backup.service + install -m 644 systemd/secubox-zigbee-backup.timer debian/secubox-zigbee/lib/systemd/system/secubox-zigbee-backup.timer + # v2.6.0 sudoers grant: lets secubox-zigbee FastAPI trigger the + # backup / restore scripts (run as root, need access to LXC files). + install -d debian/secubox-zigbee/etc/sudoers.d + install -m 440 sudoers.d/secubox-zigbee debian/secubox-zigbee/etc/sudoers.d/secubox-zigbee # Web UI install -d debian/secubox-zigbee/usr/share/secubox/www [ -d www ] && cp -r www/. debian/secubox-zigbee/usr/share/secubox/www/ || true diff --git a/packages/secubox-zigbee/debian/secubox-zigbee.service b/packages/secubox-zigbee/debian/secubox-zigbee.service index 5112e1fd..3667de1a 100644 --- a/packages/secubox-zigbee/debian/secubox-zigbee.service +++ b/packages/secubox-zigbee/debian/secubox-zigbee.service @@ -18,7 +18,15 @@ ExecStartPre=+/bin/chown secubox:secubox /etc/secubox ExecStart=/usr/bin/python3 -m uvicorn api.main:app --uds /run/secubox/zigbee.sock --log-level warning Restart=on-failure RestartSec=5 -NoNewPrivileges=true +# v2.6.0: NoNewPrivileges relaxed — the FastAPI invokes `sudo -n +# /usr/sbin/zigbee-backup` (NOPASSWD-gated per /etc/sudoers.d/ +# secubox-zigbee) to trigger host-side state snapshots. With +# NoNewPrivileges=true, sudo refuses to escalate (PR_SET_NO_NEW_PRIVS +# kernel flag). The attack surface stays gated by nginx + Authelia +# SSO at the front end, the sudoers grant is whitelisted to two +# specific scripts, and ProtectSystem + ReadWritePaths still apply +# to the python process itself. +NoNewPrivileges=false LogsDirectory=secubox LogsDirectoryMode=0755 ReadWritePaths=/run/secubox /var/lib/secubox /etc/secubox /var/log/secubox diff --git a/packages/secubox-zigbee/lib/zigbee/install-lxc.sh b/packages/secubox-zigbee/lib/zigbee/install-lxc.sh index 75a3025a..71c43623 100755 --- a/packages/secubox-zigbee/lib/zigbee/install-lxc.sh +++ b/packages/secubox-zigbee/lib/zigbee/install-lxc.sh @@ -113,7 +113,15 @@ lxc.rootfs.path = dir:$LXC_PATH/$LXC_NAME/rootfs lxc.include = /usr/share/lxc/config/common.conf lxc.apparmor.profile = generated lxc.start.auto = 1 -lxc.start.delay = 5 +# v2.6.0: start after the mqtt LXC. lxc-autostart honours start.order +# (lower runs first) and start.delay (seconds to sleep BEFORE the next +# container in the same order group). mqtt is at order=10/delay=5; we +# go order=20 + delay=20 so z2m gets 20s after mqtt's start to let +# mosquitto bind 1883 before z2m tries to connect. The 2026-05-23 +# incident wiped database.db because z2m crash-looped against a dead +# MQTT broker. +lxc.start.order = 20 +lxc.start.delay = 20 lxc.mount.entry = /dev/secubox-zgb dev/secubox-zgb none bind,create=file,optional 0 0 # Also bind /dev/serial/by-id so z2m's adapter auto-discovery can match the # manufacturer regex (e.g. ".*sonoff.*lite.*mg21.*" for the SONOFF MG21). @@ -252,6 +260,13 @@ RestartSec=10 # `|| true` survives the dongle-absent case. See secubox-zigbee #zigbee-prod-502. ExecStartPre=/bin/sh -c '[ -e /dev/ttyUSB0 ] && /bin/chmod 0666 /dev/ttyUSB0 || true' ExecStartPre=/bin/sh -c '[ -e /dev/ttyACM0 ] && /bin/chmod 0666 /dev/ttyACM0 || true' +# v2.6.0: refuse to start until the MQTT broker answers on 1883. +# Without this, z2m crash-loops when MQTT is briefly down and ends up +# writing degraded state to database.db (the 2026-05-23 incident +# regenerated network_key + lost 5 devices). 60s budget, 2s probe +# interval; if the broker never comes up we exit non-zero and let +# systemd retry per Restart=. +ExecStartPre=/bin/sh -c 'for i in \$(seq 1 30); do if (echo > /dev/tcp/10.100.0.110/1883) >/dev/null 2>&1; then exit 0; fi; sleep 2; done; echo "MQTT 10.100.0.110:1883 unreachable after 60s — refusing to start z2m" >&2; exit 1' ExecStart=/usr/bin/node /opt/zigbee2mqtt/node_modules/zigbee2mqtt/index.js NoNewPrivileges=true diff --git a/packages/secubox-zigbee/sbin/zigbee-backup b/packages/secubox-zigbee/sbin/zigbee-backup new file mode 100755 index 00000000..e01e5729 --- /dev/null +++ b/packages/secubox-zigbee/sbin/zigbee-backup @@ -0,0 +1,115 @@ +#!/bin/bash +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gerald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. +# +# SecuBox-Deb :: zigbee-backup +# +# Snapshot zigbee2mqtt persistent state to host-side storage. Survives +# LXC wipes and z2m crash-loops (the 2026-05-23 incident regenerated +# network_key + lost all 5 paired devices because z2m wrote a degraded +# database.db during MQTT outage; we want a known-good copy from BEFORE +# the bad write that can be restored from the SecuBox webui). +# +# Backup target: /data/backup/zigbee// +# - database.db +# - configuration.yaml +# - coordinator_backup.json +# - state.json +# +# Triggered hourly by secubox-zigbee-backup.timer. Skips when the +# zigbee LXC isn't running OR z2m frontend :8080 doesn't respond +# (otherwise we'd happily archive a corrupted database, defeating the +# purpose). The API can also call it on-demand via POST /backup. + +set -euo pipefail + +readonly MODULE="zigbee-backup" +readonly VERSION="2.6.0" +readonly LXC_NAME="${SECUBOX_LXC_NAME:-zigbee}" +readonly LXC_IP="${SECUBOX_LXC_IP:-10.100.0.111}" +readonly FRONTEND_PORT="${SECUBOX_Z2M_FRONTEND_PORT:-8080}" +readonly BACKUP_ROOT="${SECUBOX_ZIGBEE_BACKUP_DIR:-/data/backup/zigbee}" +readonly Z2M_DATA="/data/lxc/${LXC_NAME}/rootfs/opt/zigbee2mqtt/data" +readonly KEEP_HOURLY="${SECUBOX_ZIGBEE_KEEP_HOURLY:-24}" + +log() { echo "[${MODULE}] $*" >&2; } + +# Refuse to back up a broken state. Anything we archive here is a +# candidate for /restore — we don't want corrupted snapshots in the +# rotation. +# +# Note: we don't call `lxc-info` here. lxc-info as a non-root user +# (the API's secubox uid that triggers POST /backup) prints a usage +# error on some lxc-tools builds; and a successful TCP probe to the +# z2m frontend port is a stricter "healthy" signal than RUNNING +# anyway (RUNNING + crashed daemon would still be wrong to archive). +preflight() { + # TCP probe — if z2m hasn't bound 8080 the daemon is crashed, + # starting, or the LXC is down. In all cases the database may be + # mid-write or already corrupt. + if ! timeout 2 bash -c "/dev/null; then + log "z2m frontend ${LXC_IP}:${FRONTEND_PORT} unresponsive — skipping backup" + exit 0 + fi + # Sanity: the database file must exist AND be at least 1 KB. The + # 2026-05-23 broken state was 560 B (coordinator-only). + local db="${Z2M_DATA}/database.db" + if [[ ! -f "${db}" ]] || [[ $(stat -c %s "${db}") -lt 1024 ]]; then + log "database.db missing or suspiciously small ($(stat -c %s "${db}" 2>/dev/null || echo missing) B) — skipping backup" + exit 0 + fi +} + +do_backup() { + local ts; ts="$(date -u +%Y%m%dT%H%M%SZ)" + local dst="${BACKUP_ROOT}/${ts}" + install -d -m 755 "${dst}" + # Files we care about. configuration.example.yaml is a template + # z2m ships; not worth archiving. coordinator_backup.json contains + # the network_key (sensitive) — same dir perm as the upstream. + for f in database.db configuration.yaml coordinator_backup.json state.json; do + if [[ -f "${Z2M_DATA}/${f}" ]]; then + cp -p "${Z2M_DATA}/${f}" "${dst}/${f}" + fi + done + # Also stash the device count so the UI can show "16 devices" per + # row without parsing the database. + if command -v python3 >/dev/null; then + local n + n="$(python3 -c " +import json, sys +try: + with open('${dst}/database.db') as f: + n = sum(1 for l in f if l.strip()) + print(n) +except: print(0) +" 2>/dev/null || echo 0)" + echo "${n}" > "${dst}/.devices" + fi + log "backup OK → ${dst} ($(du -sh "${dst}" | cut -f1))" +} + +rotate() { + # Keep the most recent KEEP_HOURLY snapshots; rm the rest. + # `find -printf` ordering is undefined; sort by name (timestamps + # sort lex order with ISO 8601 UTC = chronological). + local n=0 + while IFS= read -r d; do + n=$((n + 1)) + if [[ ${n} -gt ${KEEP_HOURLY} ]]; then + log "rotate drop ${d}" + rm -rf "${d}" + fi + done < <(find "${BACKUP_ROOT}" -mindepth 1 -maxdepth 1 -type d | sort -r) +} + +main() { + install -d -m 755 "${BACKUP_ROOT}" + preflight + do_backup + rotate +} + +main "$@" diff --git a/packages/secubox-zigbee/sbin/zigbee-restore b/packages/secubox-zigbee/sbin/zigbee-restore new file mode 100755 index 00000000..8337ba57 --- /dev/null +++ b/packages/secubox-zigbee/sbin/zigbee-restore @@ -0,0 +1,78 @@ +#!/bin/bash +# SPDX-License-Identifier: LicenseRef-CMSD-1.0 +# Copyright (c) 2026 CyberMind — Gerald Kerma +# Source-Disclosed License — All rights reserved except as expressly granted. +# See LICENCE-CMSD-1.0.md for terms. +# +# SecuBox-Deb :: zigbee-restore +# +# Restore z2m persistent state from a snapshot under +# /data/backup/zigbee//. The webui POST /restore triggers +# this via subprocess. We always: +# 1. stop z2m inside the LXC (clean shutdown — flushes pending writes) +# 2. archive the CURRENT state as `pre-restore-` so the operator +# can revert if the restored snapshot turns out to be worse +# 3. copy the snapshot files back into place +# 4. start z2m +# z2m re-reads database.db on start so we don't need to nudge it. +# +# Usage: zigbee-restore +# Returns 0 on success, non-zero on any step failure. + +set -euo pipefail + +readonly MODULE="zigbee-restore" +readonly LXC_NAME="${SECUBOX_LXC_NAME:-zigbee}" +readonly BACKUP_ROOT="${SECUBOX_ZIGBEE_BACKUP_DIR:-/data/backup/zigbee}" +readonly Z2M_DATA="/data/lxc/${LXC_NAME}/rootfs/opt/zigbee2mqtt/data" + +log() { echo "[${MODULE}] $*" >&2; } +die() { log "ERROR: $*"; exit 1; } + +main() { + local ts="${1:-}" + if [[ -z "${ts}" ]]; then + die "missing timestamp arg (use one from /data/backup/zigbee/)" + fi + local src="${BACKUP_ROOT}/${ts}" + [[ -d "${src}" ]] || die "backup ${src} not found" + [[ -f "${src}/database.db" ]] || die "backup ${src} is incomplete (no database.db)" + + if ! lxc-info -n "${LXC_NAME}" --state-only 2>/dev/null | grep -q RUNNING; then + die "LXC ${LXC_NAME} is not RUNNING — start it first" + fi + + log "stopping z2m inside ${LXC_NAME}" + lxc-attach -n "${LXC_NAME}" -- systemctl stop zigbee2mqtt + + # Safety net: save current state under a `pre-restore-` prefix so + # the operator has a way back if the chosen backup is worse. + local now; now="$(date -u +%Y%m%dT%H%M%SZ)" + local rescue="${BACKUP_ROOT}/pre-restore-${now}" + log "archiving current state → ${rescue}" + install -d -m 755 "${rescue}" + for f in database.db configuration.yaml coordinator_backup.json state.json; do + [[ -f "${Z2M_DATA}/${f}" ]] && cp -p "${Z2M_DATA}/${f}" "${rescue}/${f}" || true + done + + log "restoring ${ts} → ${Z2M_DATA}" + for f in database.db configuration.yaml coordinator_backup.json state.json; do + if [[ -f "${src}/${f}" ]]; then + # Preserve ownership inside the LXC (mapped uid 100000+ on + # host = zigbee2mqtt inside). Copy via the host path and + # fix up with lxc-attach chown. + cp -p "${src}/${f}" "${Z2M_DATA}/${f}" + fi + done + # Chown via lxc-attach so the LXC sees correct owner. The lxc + # uid map (100000 = root inside) means host root files appear as + # owner=overflowuid inside; the chown forces them back to + # zigbee2mqtt:zigbee2mqtt (uid 999 in the LXC). + lxc-attach -n "${LXC_NAME}" -- chown -R zigbee2mqtt:zigbee2mqtt /opt/zigbee2mqtt/data + + log "starting z2m" + lxc-attach -n "${LXC_NAME}" -- systemctl start zigbee2mqtt + log "restore complete; pre-restore state archived at ${rescue}" +} + +main "$@" diff --git a/packages/secubox-zigbee/sudoers.d/secubox-zigbee b/packages/secubox-zigbee/sudoers.d/secubox-zigbee new file mode 100644 index 00000000..e750574a --- /dev/null +++ b/packages/secubox-zigbee/sudoers.d/secubox-zigbee @@ -0,0 +1,7 @@ +# /etc/sudoers.d/secubox-zigbee +# Lets the FastAPI (running as `secubox`) trigger the host-side +# backup / restore scripts. Both scripts touch /data/lxc// +# files owned by host-root + invoke lxc-attach inside, so they +# need root. Sibling pattern: /etc/sudoers.d/secubox-lxc grants +# lxc-info / lxc-attach / lxc-start / lxc-stop the same way. +secubox ALL=(root) NOPASSWD: /usr/sbin/zigbee-backup, /usr/sbin/zigbee-restore diff --git a/packages/secubox-zigbee/systemd/secubox-zigbee-backup.service b/packages/secubox-zigbee/systemd/secubox-zigbee-backup.service new file mode 100644 index 00000000..23b65213 --- /dev/null +++ b/packages/secubox-zigbee/systemd/secubox-zigbee-backup.service @@ -0,0 +1,14 @@ +[Unit] +Description=SecuBox Zigbee — hourly backup of z2m persistent state +Documentation=file:///usr/share/doc/secubox-zigbee/README +ConditionPathExists=/data/lxc/zigbee/rootfs/opt/zigbee2mqtt/data +After=lxc.service + +[Service] +Type=oneshot +# Runs as root so it can read the LXC rootfs files (host-mapped uid) +# and call lxc-info. The backup target /data/backup/zigbee/ is +# root-owned 0755. +User=root +ExecStart=/usr/sbin/zigbee-backup +SuccessExitStatus=0 diff --git a/packages/secubox-zigbee/systemd/secubox-zigbee-backup.timer b/packages/secubox-zigbee/systemd/secubox-zigbee-backup.timer new file mode 100644 index 00000000..a1402d07 --- /dev/null +++ b/packages/secubox-zigbee/systemd/secubox-zigbee-backup.timer @@ -0,0 +1,15 @@ +[Unit] +Description=SecuBox Zigbee — hourly backup timer +Documentation=file:///usr/share/doc/secubox-zigbee/README + +[Timer] +# 15 min after every reboot, then every hour. RandomizedDelay spreads +# load away from the start of the hour. +OnBootSec=15min +OnUnitActiveSec=1h +RandomizedDelaySec=5min +Persistent=true +Unit=secubox-zigbee-backup.service + +[Install] +WantedBy=timers.target diff --git a/packages/secubox-zigbee/www/zigbee/index.html b/packages/secubox-zigbee/www/zigbee/index.html index 180f5026..bfae449c 100644 --- a/packages/secubox-zigbee/www/zigbee/index.html +++ b/packages/secubox-zigbee/www/zigbee/index.html @@ -115,6 +115,23 @@ + +
+
+

Backups

+
+ + +
+
+
loading…
+
+

About this module

@@ -165,6 +182,97 @@ var list = (d.access || []).map(function (a) { return '

' + a.scope + ': ' + a.url + '
'; }).join(''); document.getElementById('access-v').innerHTML = list || 'no access points'; }).catch(function () { document.getElementById('access-v').innerHTML = 'unavailable'; }); + + // ── Backups card ───────────────────────────────────────────── + // The list renders newest-first. Per-row Restore button opens a + // confirm dialog because restore stops z2m for ~3s and rewrites + // the live database; users shouldn't trip it casually. + + function fmtSize(b) { + if (b < 1024) return b + ' B'; + if (b < 1024*1024) return (b/1024).toFixed(1) + ' KB'; + return (b/1024/1024).toFixed(1) + ' MB'; + } + function fmtTs(s) { + // 20260523T071302Z → 2026-05-23 07:13:02 UTC + var m = s.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/); + return m ? (m[1] + '-' + m[2] + '-' + m[3] + ' ' + m[4] + ':' + m[5] + ':' + m[6] + ' UTC') : s; + } + + function loadBackups() { + var el = document.getElementById('backup-list'); + el.innerHTML = 'loading…'; + get('/backups').then(function (d) { + var backups = d.backups || []; + if (!backups.length) { + el.innerHTML = 'No backups yet. Click Backup now to create one.'; + return; + } + var rows = backups.map(function (b) { + var kindBadge = b.kind === 'pre-restore' + ? 'PRE-RESTORE' + : ''; + return '' + + '' + fmtTs(b.timestamp) + kindBadge + '' + + '' + (b.devices || 0) + ' dev' + + '' + fmtSize(b.size_db) + '' + + '' + + ''; + }).join(''); + el.innerHTML = + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + rows + '' + + '
TimestampDevicesDB size
'; + // Wire up per-row Restore handlers. + Array.from(el.querySelectorAll('button[data-restore]')).forEach(function (btn) { + btn.addEventListener('click', function () { + var id = btn.getAttribute('data-id'); + if (!confirm('Restore zigbee snapshot ' + fmtTs(id) + + '?\n\nz2m will stop briefly (~3s). The current state will be archived as pre-restore-* first.')) return; + btn.disabled = true; btn.textContent = 'restoring…'; + fetch('/api/v1/zigbee/restore', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ id: id }), + }).then(function (r) { return r.json(); }).then(function (d) { + if (d.ok) { + alert('Restore OK. z2m restarted.'); + loadBackups(); + } else { + alert('Restore failed: ' + (d.error || d.stderr || 'unknown')); + btn.disabled = false; btn.textContent = 'Restore'; + } + }).catch(function () { + alert('Restore failed (network)'); + btn.disabled = false; btn.textContent = 'Restore'; + }); + }); + }); + }).catch(function () { + el.innerHTML = 'Failed to load backups'; + }); + } + document.getElementById('backup-refresh').addEventListener('click', loadBackups); + document.getElementById('backup-now').addEventListener('click', function () { + var btn = document.getElementById('backup-now'); + btn.disabled = true; btn.textContent = 'backing up…'; + fetch('/api/v1/zigbee/backup', { method: 'POST', headers: { Accept: 'application/json' } }) + .then(function (r) { return r.json(); }).then(function (d) { + btn.disabled = false; btn.textContent = 'Backup now'; + if (d.ok) loadBackups(); + else alert('Backup failed: ' + (d.error || d.stderr || 'unknown')); + }).catch(function () { + btn.disabled = false; btn.textContent = 'Backup now'; + alert('Backup failed (network)'); + }); + }); + loadBackups(); })();