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:
CyberMind-FR 2026-05-23 09:39:39 +02:00
parent 652bc63707
commit 484e9f83f3
12 changed files with 518 additions and 2 deletions

View File

@ -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"}

View File

@ -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

View File

@ -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.
;;

View File

@ -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

View File

@ -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

View File

@ -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

View 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 "$@"

View 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 "$@"

View 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

View File

@ -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

View 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

View File

@ -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>