secubox-deb/packages/secubox-zigbee/sbin/zigbee-backup
CyberMind-FR 484e9f83f3 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.
2026-05-23 09:39:39 +02:00

116 lines
4.0 KiB
Bash
Executable File

#!/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 "$@"