mirror of
https://github.com/CyberMind-FR/secubox-deb.git
synced 2026-07-29 09:14:33 +00:00
feat(zigbee): v2.6.0 — host-side backup + restore (closes z2m DB wipe risk)
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/
<UTC-ts>/. Refuses on unhealthy state (z2m port closed OR
database.db < 1 KB). Rotates to last 24.
- sbin/zigbee-restore <ts>: stops z2m, archives current state as
pre-restore-<now>, 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.
This commit is contained in:
parent
652bc63707
commit
484e9f83f3
|
|
@ -163,6 +163,106 @@ def access() -> dict:
|
|||
}
|
||||
|
||||
|
||||
# ── v2.6.0 backup / restore surface ─────────────────────────────────
|
||||
# /data/backup/zigbee/<UTC-timestamp>/ 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: <timestamp>}.
|
||||
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"}
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
<UTC-ts>/. 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 <ts>: stops z2m, ARCHIVES the current
|
||||
state as pre-restore-<now> (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 <devel@cybermind.fr> 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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
;;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
115
packages/secubox-zigbee/sbin/zigbee-backup
Executable file
115
packages/secubox-zigbee/sbin/zigbee-backup
Executable file
|
|
@ -0,0 +1,115 @@
|
|||
#!/bin/bash
|
||||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gerald Kerma <devel@cybermind.fr>
|
||||
# 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/<timestamp>/
|
||||
# - 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/tcp/${LXC_IP}/${FRONTEND_PORT}" 2>/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 "$@"
|
||||
78
packages/secubox-zigbee/sbin/zigbee-restore
Executable file
78
packages/secubox-zigbee/sbin/zigbee-restore
Executable file
|
|
@ -0,0 +1,78 @@
|
|||
#!/bin/bash
|
||||
# SPDX-License-Identifier: LicenseRef-CMSD-1.0
|
||||
# Copyright (c) 2026 CyberMind — Gerald Kerma <devel@cybermind.fr>
|
||||
# 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/<timestamp>/. 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-<ts>` 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 <timestamp>
|
||||
# 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 "$@"
|
||||
7
packages/secubox-zigbee/sudoers.d/secubox-zigbee
Normal file
7
packages/secubox-zigbee/sudoers.d/secubox-zigbee
Normal file
|
|
@ -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/<zigbee>/
|
||||
# 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
|
||||
|
|
@ -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
|
||||
15
packages/secubox-zigbee/systemd/secubox-zigbee-backup.timer
Normal file
15
packages/secubox-zigbee/systemd/secubox-zigbee-backup.timer
Normal file
|
|
@ -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
|
||||
|
|
@ -115,6 +115,23 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- v2.6.0 — backup / restore. Hourly snapshots of database.db +
|
||||
configuration.yaml + coordinator_backup.json live at
|
||||
/data/backup/zigbee/ (host-side, survives LXC wipe). The
|
||||
2026-05-23 incident wiped the in-LXC database during a z2m
|
||||
crash-loop; this card lets the operator roll back to a known-
|
||||
good snapshot without re-pairing every device. -->
|
||||
<section class="card" style="margin-bottom: 1.5rem;">
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 0.75rem;">
|
||||
<h3 style="font-size:14px; font-weight:600; color:var(--text);">Backups</h3>
|
||||
<div style="display:flex; gap:0.4rem;">
|
||||
<button class="btn" id="backup-refresh">Refresh</button>
|
||||
<button class="btn primary" id="backup-now">Backup now</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="backup-list" class="v" style="font-family:'JetBrains Mono', monospace; font-size:12px;">loading…</div>
|
||||
</section>
|
||||
|
||||
<div class="about">
|
||||
<h2>About this module</h2>
|
||||
<p>
|
||||
|
|
@ -165,6 +182,97 @@
|
|||
var list = (d.access || []).map(function (a) { return '<div>' + a.scope + ': <a href="' + a.url + '">' + a.url + '</a></div>'; }).join('');
|
||||
document.getElementById('access-v').innerHTML = list || '<span style="color:var(--muted)">no access points</span>';
|
||||
}).catch(function () { document.getElementById('access-v').innerHTML = '<span style="color:var(--muted)">unavailable</span>'; });
|
||||
|
||||
// ── 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 = '<span style="color:var(--muted);">No backups yet. Click <b>Backup now</b> to create one.</span>';
|
||||
return;
|
||||
}
|
||||
var rows = backups.map(function (b) {
|
||||
var kindBadge = b.kind === 'pre-restore'
|
||||
? '<span style="background:var(--wall-xlt); color:var(--wall-main); padding:1px 6px; border-radius:3px; font-size:10px; margin-left:6px;">PRE-RESTORE</span>'
|
||||
: '';
|
||||
return '<tr>' +
|
||||
'<td>' + fmtTs(b.timestamp) + kindBadge + '</td>' +
|
||||
'<td style="text-align:right;">' + (b.devices || 0) + ' dev</td>' +
|
||||
'<td style="text-align:right;">' + fmtSize(b.size_db) + '</td>' +
|
||||
'<td style="text-align:right;"><button class="btn" data-id="' + b.id + '" data-restore>Restore</button></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
el.innerHTML =
|
||||
'<table style="width:100%; border-collapse:collapse;">' +
|
||||
'<thead><tr style="color:var(--muted); font-size:11px; text-align:left; border-bottom:1px solid var(--border);">' +
|
||||
'<th style="padding:4px 0;">Timestamp</th>' +
|
||||
'<th style="padding:4px 0; text-align:right;">Devices</th>' +
|
||||
'<th style="padding:4px 0; text-align:right;">DB size</th>' +
|
||||
'<th></th>' +
|
||||
'</tr></thead>' +
|
||||
'<tbody>' + rows + '</tbody>' +
|
||||
'</table>';
|
||||
// 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 = '<span style="color:var(--danger);">Failed to load backups</span>';
|
||||
});
|
||||
}
|
||||
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();
|
||||
})();
|
||||
</script>
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user