diff --git a/kiosk/README.md b/kiosk/README.md
new file mode 100644
index 0000000..5bed705
--- /dev/null
+++ b/kiosk/README.md
@@ -0,0 +1,135 @@
+# Vigilar Kiosk — Raspberry Pi TV Display
+
+Turn a Raspberry Pi into a dedicated security-camera display showing the
+Vigilar 2x2 camera grid on any HDMI-connected TV.
+
+## Hardware
+
+- Raspberry Pi Zero 2W, 3, 4, or 5
+- Micro-HDMI (Zero 2W) or HDMI cable to TV
+- Power supply (5V, 2.5A+)
+- MicroSD card (8 GB+)
+
+## Quick Start
+
+### 1. Flash Raspberry Pi OS
+
+1. Download **Raspberry Pi OS Lite (64-bit)** (no desktop) from
+ .
+2. Flash to MicroSD with Raspberry Pi Imager.
+3. In Imager's settings (gear icon), enable SSH and set a password for the
+ `pi` user. Optionally configure Wi-Fi.
+4. Boot the Pi.
+
+### 2. Copy kiosk files to the Pi
+
+From the machine running Vigilar:
+
+```bash
+scp -r kiosk/ pi@:~/kiosk/
+```
+
+### 3. Run setup
+
+```bash
+ssh pi@
+cd ~/kiosk
+sudo bash setup_kiosk.sh
+```
+
+The script will:
+- Install X11, Chromium, and unclutter
+- Create a `vigilar` user
+- Ask for the Vigilar server URL (default: `https://vigilar.local:49735/kiosk/`)
+- Configure auto-login and kiosk auto-start via systemd
+- Set GPU memory, screen blanking, hostname, and SSH
+
+### 4. Reboot
+
+```bash
+sudo reboot
+```
+
+The Pi will boot directly into the fullscreen camera grid.
+
+## Reconfiguring
+
+Change URL, rotation, or resolution without re-running full setup:
+
+```bash
+# Interactive
+sudo ./update_kiosk.sh
+
+# Non-interactive
+sudo ./update_kiosk.sh --url https://192.168.1.50:49735/kiosk/ --restart
+
+# Change rotation (requires reboot)
+sudo ./update_kiosk.sh --rotation 90
+sudo reboot
+```
+
+## Files
+
+| File | Purpose |
+|------|---------|
+| `setup_kiosk.sh` | Full initial setup script |
+| `update_kiosk.sh` | Reconfigure URL/rotation/resolution |
+| `kiosk.service` | systemd unit (installed by setup) |
+| `kiosk_config.txt` | Template config (copied to Pi) |
+
+On the Pi after setup:
+
+| File | Purpose |
+|------|---------|
+| `/home/vigilar/kiosk_config.txt` | Active configuration |
+| `/home/vigilar/.xinitrc` | X session startup |
+| `/home/vigilar/.bash_profile` | Fallback auto-start |
+| `/etc/systemd/system/vigilar-kiosk.service` | systemd service |
+
+## Management
+
+```bash
+# Check status
+sudo systemctl status vigilar-kiosk
+
+# View logs
+sudo journalctl -u vigilar-kiosk -f
+
+# Restart kiosk
+sudo systemctl restart vigilar-kiosk
+
+# Stop kiosk
+sudo systemctl stop vigilar-kiosk
+
+# Switch from systemd to .bash_profile method
+sudo systemctl disable vigilar-kiosk
+# Then uncomment 'exec startx' in /home/vigilar/.bash_profile
+```
+
+## Troubleshooting
+
+### Black screen after boot
+- Check that the Vigilar server is reachable: `curl -k https://vigilar.local:49735/kiosk/`
+- Check service logs: `sudo journalctl -u vigilar-kiosk --no-pager -n 50`
+- Verify X can start: `sudo -u vigilar startx -- -nocursor`
+
+### "Cannot open display" errors
+- Ensure the Pi is booting to tty1 and the service has TTY access
+- Check: `sudo systemctl status vigilar-kiosk`
+
+### Certificate errors in Chromium
+- The `.xinitrc` includes `--ignore-certificate-errors` for self-signed certs
+- For production, use a proper certificate on the Vigilar server
+
+### Screen stays on when it should sleep
+- Screen blanking is deliberately disabled for a security kiosk
+- To re-enable: remove `consoleblank=0` from cmdline.txt, remove `xset` lines from `.xinitrc`
+
+### Resolution or rotation not applied
+- Rotation and resolution set in `config.txt` require a reboot
+- Check current settings: `cat /boot/firmware/config.txt` (Bookworm) or `cat /boot/config.txt`
+
+### Low memory on Pi Zero 2W
+- Chromium with `--incognito` and `--disk-cache-dir=/dev/null` minimises memory use
+- The setup sets `gpu_mem=128` which is a good balance
+- If OOM occurs, try `gpu_mem=64` in config.txt
diff --git a/kiosk/kiosk.service b/kiosk/kiosk.service
new file mode 100644
index 0000000..7b47c2b
--- /dev/null
+++ b/kiosk/kiosk.service
@@ -0,0 +1,30 @@
+[Unit]
+Description=Vigilar Kiosk (X11 + Chromium)
+After=network-online.target systemd-user-sessions.service
+Wants=network-online.target
+
+[Service]
+Type=simple
+User=vigilar
+Group=vigilar
+PAMName=login
+TTYPath=/dev/tty1
+StandardInput=tty
+StandardOutput=journal
+StandardError=journal
+
+# Ensure we have access to the display hardware
+SupplementaryGroups=video input render
+
+Environment=HOME=/home/vigilar
+Environment=XDG_RUNTIME_DIR=/run/user/1001
+WorkingDirectory=/home/vigilar
+
+ExecStartPre=/bin/bash -c 'source /home/vigilar/kiosk_config.txt'
+ExecStart=/usr/bin/xinit /home/vigilar/.xinitrc -- /usr/bin/X :0 vt1 -keeptty -nocursor
+
+Restart=on-failure
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target
diff --git a/kiosk/kiosk_config.txt b/kiosk/kiosk_config.txt
new file mode 100644
index 0000000..0235e0b
--- /dev/null
+++ b/kiosk/kiosk_config.txt
@@ -0,0 +1,3 @@
+VIGILAR_URL=https://vigilar.local:49735/kiosk/
+ROTATION=0
+RESOLUTION=1920x1080
diff --git a/kiosk/setup_kiosk.sh b/kiosk/setup_kiosk.sh
new file mode 100755
index 0000000..8e09b14
--- /dev/null
+++ b/kiosk/setup_kiosk.sh
@@ -0,0 +1,385 @@
+#!/usr/bin/env bash
+# Vigilar Kiosk — Full Raspberry Pi setup script
+# Run on a fresh Raspberry Pi OS Lite (64-bit, no desktop).
+# Safe to re-run (idempotent).
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+KIOSK_USER="vigilar"
+KIOSK_HOME="/home/${KIOSK_USER}"
+CONFIG_FILE="${KIOSK_HOME}/kiosk_config.txt"
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+info() { printf '\n\033[1;34m>>> %s\033[0m\n' "$*"; }
+warn() { printf '\033[1;33mWARN: %s\033[0m\n' "$*"; }
+error() { printf '\033[1;31mERROR: %s\033[0m\n' "$*" >&2; exit 1; }
+
+need_root() {
+ if [[ $EUID -ne 0 ]]; then
+ error "This script must be run as root (use sudo)."
+ fi
+}
+
+# ---------------------------------------------------------------------------
+# Pre-flight
+# ---------------------------------------------------------------------------
+need_root
+info "Vigilar Kiosk Setup"
+echo "This script turns a Raspberry Pi into a dedicated security-camera display."
+echo ""
+
+# Detect Debian version
+if [[ -f /etc/os-release ]]; then
+ # shellcheck source=/dev/null
+ . /etc/os-release
+ DEBIAN_VERSION="${VERSION_ID:-11}"
+else
+ DEBIAN_VERSION="11"
+fi
+info "Detected Debian version: ${DEBIAN_VERSION} (${PRETTY_NAME:-unknown})"
+
+# ---------------------------------------------------------------------------
+# 1. Ask for Vigilar server URL
+# ---------------------------------------------------------------------------
+DEFAULT_URL="https://vigilar.local:49735/kiosk/"
+read -r -p "Vigilar server URL [${DEFAULT_URL}]: " USER_URL
+VIGILAR_URL="${USER_URL:-$DEFAULT_URL}"
+
+# ---------------------------------------------------------------------------
+# 2. Ask for display settings
+# ---------------------------------------------------------------------------
+read -r -p "Screen rotation (0/90/180/270) [0]: " USER_ROTATION
+ROTATION="${USER_ROTATION:-0}"
+
+read -r -p "Screen resolution (e.g. 1920x1080, 1280x720) [1920x1080]: " USER_RES
+RESOLUTION="${USER_RES:-1920x1080}"
+
+echo ""
+info "Configuration summary"
+echo " URL: ${VIGILAR_URL}"
+echo " Rotation: ${ROTATION}"
+echo " Resolution: ${RESOLUTION}"
+echo ""
+read -r -p "Continue? [Y/n]: " CONFIRM
+if [[ "${CONFIRM,,}" == "n" ]]; then
+ echo "Aborted."
+ exit 0
+fi
+
+# ---------------------------------------------------------------------------
+# 3. Install packages
+# ---------------------------------------------------------------------------
+info "Updating package lists"
+apt-get update -qq
+
+info "Installing X11, Chromium, and utilities"
+PACKAGES=(
+ xserver-xorg
+ xinit
+ x11-xserver-utils
+ chromium-browser
+ unclutter
+)
+
+# On Bookworm the package may just be 'chromium'
+if [[ "${DEBIAN_VERSION}" -ge 12 ]]; then
+ # Try chromium-browser first; fall back to chromium
+ if ! apt-cache show chromium-browser &>/dev/null; then
+ PACKAGES=("${PACKAGES[@]/chromium-browser/chromium}")
+ fi
+fi
+
+apt-get install -y -qq "${PACKAGES[@]}"
+
+# ---------------------------------------------------------------------------
+# 4. Create kiosk user
+# ---------------------------------------------------------------------------
+if id "${KIOSK_USER}" &>/dev/null; then
+ info "User '${KIOSK_USER}' already exists"
+else
+ info "Creating user '${KIOSK_USER}'"
+ useradd -m -s /bin/bash -G video,input,render,tty "${KIOSK_USER}"
+fi
+
+# Ensure group memberships even if user existed
+usermod -aG video,input,render,tty "${KIOSK_USER}"
+
+# ---------------------------------------------------------------------------
+# 5. Write config file
+# ---------------------------------------------------------------------------
+info "Writing ${CONFIG_FILE}"
+cat > "${CONFIG_FILE}" < "${KIOSK_HOME}/.xinitrc" <<'XINITEOF'
+#!/usr/bin/env bash
+# Vigilar kiosk X session
+set -euo pipefail
+
+CONFIG="$HOME/kiosk_config.txt"
+if [[ -f "$CONFIG" ]]; then
+ # shellcheck source=/dev/null
+ source "$CONFIG"
+fi
+URL="${VIGILAR_URL:-https://vigilar.local:49735/kiosk/}"
+
+# Disable screen blanking and DPMS
+xset s off
+xset s noblank
+xset -dpms
+
+# Hide cursor after 0.5 s of inactivity
+unclutter -idle 0.5 -root &
+
+# Handle rotation
+ROTATION="${ROTATION:-0}"
+case "${ROTATION}" in
+ 90) xrandr --output "$(xrandr | grep ' connected' | head -1 | awk '{print $1}')" --rotate left ;;
+ 180) xrandr --output "$(xrandr | grep ' connected' | head -1 | awk '{print $1}')" --rotate inverted ;;
+ 270) xrandr --output "$(xrandr | grep ' connected' | head -1 | awk '{print $1}')" --rotate right ;;
+ *) ;; # 0 = normal, no action needed
+esac
+
+# Determine chromium binary
+CHROMIUM=""
+for candidate in chromium-browser chromium; do
+ if command -v "$candidate" &>/dev/null; then
+ CHROMIUM="$candidate"
+ break
+ fi
+done
+if [[ -z "$CHROMIUM" ]]; then
+ echo "ERROR: No chromium binary found" >&2
+ exit 1
+fi
+
+# Launch Chromium in kiosk mode
+# --ignore-certificate-errors is needed for self-signed TLS certs on vigilar.local
+exec "$CHROMIUM" \
+ --noerrdialogs \
+ --disable-infobars \
+ --kiosk \
+ --incognito \
+ --disable-translate \
+ --no-first-run \
+ --fast \
+ --fast-start \
+ --disable-features=TranslateUI \
+ --disk-cache-dir=/dev/null \
+ --check-for-update-interval=31536000 \
+ --ignore-certificate-errors \
+ --disable-component-update \
+ --disable-background-networking \
+ --disable-sync \
+ --autoplay-policy=no-user-gesture-required \
+ "$URL"
+XINITEOF
+chmod +x "${KIOSK_HOME}/.xinitrc"
+chown "${KIOSK_USER}:${KIOSK_USER}" "${KIOSK_HOME}/.xinitrc"
+
+# ---------------------------------------------------------------------------
+# 7. Write .bash_profile for auto-start (fallback method)
+# ---------------------------------------------------------------------------
+info "Writing ${KIOSK_HOME}/.bash_profile (auto-start X on tty1)"
+# Preserve existing content if any
+BASH_PROFILE="${KIOSK_HOME}/.bash_profile"
+MARKER="# --- Vigilar kiosk auto-start ---"
+
+# Remove old block if present
+if [[ -f "${BASH_PROFILE}" ]]; then
+ sed -i "/${MARKER}/,/# --- end Vigilar kiosk ---/d" "${BASH_PROFILE}"
+fi
+
+cat >> "${BASH_PROFILE}" <<'BPEOF'
+# --- Vigilar kiosk auto-start ---
+if [[ -z "${DISPLAY:-}" ]] && [[ "$(tty)" == "/dev/tty1" ]]; then
+ exec startx -- -nocursor
+fi
+# --- end Vigilar kiosk ---
+BPEOF
+chown "${KIOSK_USER}:${KIOSK_USER}" "${BASH_PROFILE}"
+
+# ---------------------------------------------------------------------------
+# 8. Install systemd service (default method)
+# ---------------------------------------------------------------------------
+info "Installing systemd service"
+SERVICE_SRC="${SCRIPT_DIR}/kiosk.service"
+SERVICE_DEST="/etc/systemd/system/vigilar-kiosk.service"
+
+if [[ -f "${SERVICE_SRC}" ]]; then
+ cp "${SERVICE_SRC}" "${SERVICE_DEST}"
+else
+ # Generate inline if source file not found
+ cat > "${SERVICE_DEST}" < "${AUTOLOGIN_DIR}/autologin.conf" <> "${CONFIG_TXT}"
+ fi
+fi
+
+# ---------------------------------------------------------------------------
+# 12. Screen rotation and resolution in config.txt
+# ---------------------------------------------------------------------------
+info "Configuring display rotation and resolution"
+if [[ -f "${CONFIG_TXT}" ]]; then
+ # Rotation via display_rotate (legacy) or display_lcd_rotate
+ if [[ "${ROTATION}" != "0" ]]; then
+ ROTATE_VAL=0
+ case "${ROTATION}" in
+ 90) ROTATE_VAL=1 ;;
+ 180) ROTATE_VAL=2 ;;
+ 270) ROTATE_VAL=3 ;;
+ esac
+ if grep -q '^display_rotate=' "${CONFIG_TXT}"; then
+ sed -i "s/^display_rotate=.*/display_rotate=${ROTATE_VAL}/" "${CONFIG_TXT}"
+ else
+ echo "display_rotate=${ROTATE_VAL}" >> "${CONFIG_TXT}"
+ fi
+ fi
+
+ # Resolution via hdmi_group and hdmi_mode
+ # Parse WxH
+ RES_W="${RESOLUTION%%x*}"
+ RES_H="${RESOLUTION##*x}"
+ HDMI_MODE=""
+ case "${RES_W}x${RES_H}" in
+ 1920x1080) HDMI_MODE=16 ;;
+ 1280x720) HDMI_MODE=4 ;;
+ 1680x1050) HDMI_MODE=58 ;;
+ 1280x1024) HDMI_MODE=35 ;;
+ 1024x768) HDMI_MODE=16 ;; # DMT mode 16
+ *) warn "Unknown resolution ${RESOLUTION}, skipping hdmi_mode config" ;;
+ esac
+
+ if [[ -n "${HDMI_MODE}" ]]; then
+ # hdmi_group=1 = CEA (TVs), hdmi_group=2 = DMT (monitors)
+ HDMI_GROUP=1
+ if [[ "${RES_W}x${RES_H}" == "1680x1050" ]] || [[ "${RES_W}x${RES_H}" == "1280x1024" ]]; then
+ HDMI_GROUP=2
+ fi
+ for KEY_VAL in "hdmi_group=${HDMI_GROUP}" "hdmi_mode=${HDMI_MODE}"; do
+ KEY="${KEY_VAL%%=*}"
+ if grep -q "^${KEY}=" "${CONFIG_TXT}"; then
+ sed -i "s/^${KEY}=.*/${KEY_VAL}/" "${CONFIG_TXT}"
+ else
+ echo "${KEY_VAL}" >> "${CONFIG_TXT}"
+ fi
+ done
+ fi
+fi
+
+# ---------------------------------------------------------------------------
+# 13. Enable SSH
+# ---------------------------------------------------------------------------
+info "Enabling SSH"
+systemctl enable ssh 2>/dev/null || systemctl enable sshd 2>/dev/null || true
+systemctl start ssh 2>/dev/null || systemctl start sshd 2>/dev/null || true
+
+# ---------------------------------------------------------------------------
+# 14. Set hostname
+# ---------------------------------------------------------------------------
+info "Setting hostname to 'vigilar-kiosk'"
+hostnamectl set-hostname vigilar-kiosk 2>/dev/null || {
+ echo "vigilar-kiosk" > /etc/hostname
+ sed -i "s/127\.0\.1\.1.*/127.0.1.1\tvigilar-kiosk/" /etc/hosts
+}
+
+# ---------------------------------------------------------------------------
+# Done
+# ---------------------------------------------------------------------------
+info "Setup complete!"
+echo ""
+echo " Kiosk URL: ${VIGILAR_URL}"
+echo " User: ${KIOSK_USER}"
+echo " Service: vigilar-kiosk.service (systemd, enabled)"
+echo " Config: ${CONFIG_FILE}"
+echo ""
+echo " To start now: sudo systemctl start vigilar-kiosk"
+echo " To check status: sudo systemctl status vigilar-kiosk"
+echo " To view logs: sudo journalctl -u vigilar-kiosk -f"
+echo " To reconfigure: sudo ./update_kiosk.sh"
+echo ""
+echo " A reboot is recommended: sudo reboot"
diff --git a/kiosk/update_kiosk.sh b/kiosk/update_kiosk.sh
new file mode 100755
index 0000000..d40e3fc
--- /dev/null
+++ b/kiosk/update_kiosk.sh
@@ -0,0 +1,196 @@
+#!/usr/bin/env bash
+# Vigilar Kiosk — Update/reconfigure script
+# Change the URL, rotation, or resolution without re-running full setup.
+set -euo pipefail
+
+KIOSK_USER="vigilar"
+KIOSK_HOME="/home/${KIOSK_USER}"
+CONFIG_FILE="${KIOSK_HOME}/kiosk_config.txt"
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+info() { printf '\n\033[1;34m>>> %s\033[0m\n' "$*"; }
+warn() { printf '\033[1;33mWARN: %s\033[0m\n' "$*"; }
+error() { printf '\033[1;31mERROR: %s\033[0m\n' "$*" >&2; exit 1; }
+
+need_root() {
+ if [[ $EUID -ne 0 ]]; then
+ error "This script must be run as root (use sudo)."
+ fi
+}
+
+# ---------------------------------------------------------------------------
+# Load current config
+# ---------------------------------------------------------------------------
+need_root
+
+CURRENT_URL=""
+CURRENT_ROTATION="0"
+CURRENT_RESOLUTION="1920x1080"
+
+if [[ -f "${CONFIG_FILE}" ]]; then
+ # shellcheck source=/dev/null
+ source "${CONFIG_FILE}"
+ CURRENT_URL="${VIGILAR_URL:-}"
+ CURRENT_ROTATION="${ROTATION:-0}"
+ CURRENT_RESOLUTION="${RESOLUTION:-1920x1080}"
+fi
+
+info "Vigilar Kiosk — Update Configuration"
+echo ""
+echo "Current settings:"
+echo " URL: ${CURRENT_URL:-}"
+echo " Rotation: ${CURRENT_ROTATION}"
+echo " Resolution: ${CURRENT_RESOLUTION}"
+echo ""
+
+# ---------------------------------------------------------------------------
+# Parse arguments or ask interactively
+# ---------------------------------------------------------------------------
+usage() {
+ echo "Usage: $0 [--url URL] [--rotation 0|90|180|270] [--resolution WxH]"
+ echo ""
+ echo "Options:"
+ echo " --url URL Vigilar server URL"
+ echo " --rotation DEGREE Screen rotation (0, 90, 180, 270)"
+ echo " --resolution WxH Screen resolution (e.g. 1920x1080)"
+ echo " --restart Restart the kiosk service after update"
+ echo " -h, --help Show this help"
+ exit 0
+}
+
+NEW_URL=""
+NEW_ROTATION=""
+NEW_RESOLUTION=""
+DO_RESTART=false
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --url) NEW_URL="$2"; shift 2 ;;
+ --rotation) NEW_ROTATION="$2"; shift 2 ;;
+ --resolution) NEW_RESOLUTION="$2"; shift 2 ;;
+ --restart) DO_RESTART=true; shift ;;
+ -h|--help) usage ;;
+ *) warn "Unknown option: $1"; shift ;;
+ esac
+done
+
+# If no arguments given, ask interactively
+if [[ -z "${NEW_URL}" && -z "${NEW_ROTATION}" && -z "${NEW_RESOLUTION}" ]]; then
+ read -r -p "Vigilar URL [${CURRENT_URL}]: " NEW_URL
+ read -r -p "Rotation (0/90/180/270) [${CURRENT_ROTATION}]: " NEW_ROTATION
+ read -r -p "Resolution [${CURRENT_RESOLUTION}]: " NEW_RESOLUTION
+fi
+
+# Fall back to current values
+VIGILAR_URL="${NEW_URL:-$CURRENT_URL}"
+ROTATION="${NEW_ROTATION:-$CURRENT_ROTATION}"
+RESOLUTION="${NEW_RESOLUTION:-$CURRENT_RESOLUTION}"
+
+# Validate rotation
+case "${ROTATION}" in
+ 0|90|180|270) ;;
+ *) error "Invalid rotation: ${ROTATION}. Must be 0, 90, 180, or 270." ;;
+esac
+
+# Validate resolution format
+if [[ ! "${RESOLUTION}" =~ ^[0-9]+x[0-9]+$ ]]; then
+ error "Invalid resolution format: ${RESOLUTION}. Expected WxH (e.g. 1920x1080)."
+fi
+
+# ---------------------------------------------------------------------------
+# Write config
+# ---------------------------------------------------------------------------
+info "Writing ${CONFIG_FILE}"
+cat > "${CONFIG_FILE}" <> "${CONFIG_TXT}"
+ fi
+ BOOT_CHANGED=true
+ fi
+
+ # Resolution
+ if [[ "${RESOLUTION}" != "${CURRENT_RESOLUTION}" ]]; then
+ RES_W="${RESOLUTION%%x*}"
+ RES_H="${RESOLUTION##*x}"
+ HDMI_MODE=""
+ case "${RES_W}x${RES_H}" in
+ 1920x1080) HDMI_MODE=16 ;;
+ 1280x720) HDMI_MODE=4 ;;
+ 1680x1050) HDMI_MODE=58 ;;
+ 1280x1024) HDMI_MODE=35 ;;
+ *) warn "Unknown resolution ${RESOLUTION}, skipping hdmi_mode" ;;
+ esac
+ if [[ -n "${HDMI_MODE}" ]]; then
+ HDMI_GROUP=1
+ if [[ "${RES_W}x${RES_H}" == "1680x1050" ]] || [[ "${RES_W}x${RES_H}" == "1280x1024" ]]; then
+ HDMI_GROUP=2
+ fi
+ for KEY_VAL in "hdmi_group=${HDMI_GROUP}" "hdmi_mode=${HDMI_MODE}"; do
+ KEY="${KEY_VAL%%=*}"
+ if grep -q "^${KEY}=" "${CONFIG_TXT}"; then
+ sed -i "s/^${KEY}=.*/${KEY_VAL}/" "${CONFIG_TXT}"
+ else
+ echo "${KEY_VAL}" >> "${CONFIG_TXT}"
+ fi
+ done
+ BOOT_CHANGED=true
+ fi
+ fi
+fi
+
+# ---------------------------------------------------------------------------
+# Restart service if requested
+# ---------------------------------------------------------------------------
+if [[ "${DO_RESTART}" == true ]]; then
+ info "Restarting vigilar-kiosk service"
+ systemctl restart vigilar-kiosk.service
+ echo "Service restarted."
+fi
+
+# ---------------------------------------------------------------------------
+# Done
+# ---------------------------------------------------------------------------
+info "Configuration updated!"
+if [[ "${BOOT_CHANGED}" == true ]]; then
+ echo ""
+ echo " Boot config was changed. A reboot is required for display changes."
+ echo " Run: sudo reboot"
+elif [[ "${DO_RESTART}" != true ]]; then
+ echo ""
+ echo " To apply URL changes: sudo systemctl restart vigilar-kiosk"
+ echo " For display changes: sudo reboot"
+fi
diff --git a/scripts/backup.sh b/scripts/backup.sh
new file mode 100755
index 0000000..48137ee
--- /dev/null
+++ b/scripts/backup.sh
@@ -0,0 +1,93 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Vigilar — Backup script
+# Backs up SQLite database, config, and secrets to a dated tar.gz archive.
+# Suitable for cron: 0 3 * * * /opt/vigilar/scripts/backup.sh
+
+DATA_DIR="/var/vigilar/data"
+CONFIG_DIR="/etc/vigilar"
+BACKUP_DEST="${VIGILAR_BACKUP_DIR:-/var/vigilar/backups}"
+RETENTION_DAYS="${VIGILAR_BACKUP_RETENTION_DAYS:-30}"
+TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
+ARCHIVE_NAME="vigilar-backup-${TIMESTAMP}.tar.gz"
+
+info() { printf '\033[1;34m[INFO]\033[0m %s\n' "$*"; }
+ok() { printf '\033[1;32m[ OK ]\033[0m %s\n' "$*"; }
+warn() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*"; }
+fail() { printf '\033[1;31m[FAIL]\033[0m %s\n' "$*" >&2; exit 1; }
+
+main() {
+ info "=== Vigilar Backup ==="
+
+ # Create backup destination
+ sudo mkdir -p "$BACKUP_DEST"
+
+ # Collect files to back up
+ local items=()
+
+ # SQLite database
+ if [[ -d "$DATA_DIR" ]]; then
+ # Use sqlite3 .backup for a consistent snapshot if sqlite3 is available
+ local db_file="${DATA_DIR}/vigilar.db"
+ if [[ -f "$db_file" ]] && command -v sqlite3 &>/dev/null; then
+ local db_snapshot="/tmp/vigilar-backup-${TIMESTAMP}.db"
+ info "Creating consistent database snapshot"
+ sqlite3 "$db_file" ".backup '${db_snapshot}'"
+ items+=("$db_snapshot")
+ elif [[ -f "$db_file" ]]; then
+ warn "sqlite3 not found, copying database file directly (may be inconsistent if running)"
+ items+=("$db_file")
+ fi
+
+ # Also back up any WAL/SHM files
+ for f in "${db_file}-wal" "${db_file}-shm"; do
+ if [[ -f "$f" ]]; then
+ items+=("$f")
+ fi
+ done
+ else
+ warn "Data directory ${DATA_DIR} not found, skipping database"
+ fi
+
+ # Config directory (includes secrets, certs, toml)
+ if [[ -d "$CONFIG_DIR" ]]; then
+ items+=("$CONFIG_DIR")
+ else
+ warn "Config directory ${CONFIG_DIR} not found, skipping config"
+ fi
+
+ if [[ ${#items[@]} -eq 0 ]]; then
+ fail "Nothing to back up"
+ fi
+
+ # Create archive
+ local archive_path="${BACKUP_DEST}/${ARCHIVE_NAME}"
+ info "Creating archive: ${archive_path}"
+ sudo tar -czf "$archive_path" "${items[@]}" 2>/dev/null
+
+ # Secure the backup (contains secrets)
+ sudo chmod 0600 "$archive_path"
+ sudo chown root:root "$archive_path"
+
+ # Clean up temp db snapshot
+ if [[ -f "/tmp/vigilar-backup-${TIMESTAMP}.db" ]]; then
+ rm -f "/tmp/vigilar-backup-${TIMESTAMP}.db"
+ fi
+
+ local size
+ size="$(du -h "$archive_path" | cut -f1)"
+ ok "Backup complete: ${archive_path} (${size})"
+
+ # Prune old backups
+ if [[ "$RETENTION_DAYS" -gt 0 ]]; then
+ info "Pruning backups older than ${RETENTION_DAYS} days"
+ local pruned
+ pruned="$(sudo find "$BACKUP_DEST" -name 'vigilar-backup-*.tar.gz' -mtime +"$RETENTION_DAYS" -delete -print | wc -l)"
+ if [[ "$pruned" -gt 0 ]]; then
+ ok "Pruned ${pruned} old backup(s)"
+ fi
+ fi
+}
+
+main "$@"
diff --git a/scripts/gen_cert.sh b/scripts/gen_cert.sh
new file mode 100755
index 0000000..855c775
--- /dev/null
+++ b/scripts/gen_cert.sh
@@ -0,0 +1,126 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Vigilar — Self-signed TLS certificate generator
+# Uses mkcert if available, otherwise falls back to openssl.
+
+CONFIG_DIR="/etc/vigilar"
+CERT_DIR="${CONFIG_DIR}/certs"
+CERT_FILE="${CERT_DIR}/cert.pem"
+KEY_FILE="${CERT_DIR}/key.pem"
+CONFIG_FILE="${CONFIG_DIR}/vigilar.toml"
+VIGILAR_GROUP="vigilar"
+
+info() { printf '\033[1;34m[INFO]\033[0m %s\n' "$*"; }
+ok() { printf '\033[1;32m[ OK ]\033[0m %s\n' "$*"; }
+warn() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*"; }
+fail() { printf '\033[1;31m[FAIL]\033[0m %s\n' "$*" >&2; exit 1; }
+
+get_lan_ip() {
+ # Try to detect the primary LAN IP
+ ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src") print $(i+1)}' | head -1
+}
+
+generate_with_mkcert() {
+ local lan_ip="$1"
+ info "Using mkcert to generate certificate"
+
+ local san_args=("vigilar.local" "localhost" "127.0.0.1")
+ if [[ -n "$lan_ip" ]]; then
+ san_args+=("$lan_ip")
+ fi
+
+ mkcert -cert-file "$CERT_FILE" -key-file "$KEY_FILE" "${san_args[@]}"
+ ok "Certificate generated with mkcert"
+}
+
+generate_with_openssl() {
+ local lan_ip="$1"
+ info "Using openssl to generate self-signed certificate"
+
+ local san="DNS:vigilar.local,DNS:localhost,IP:127.0.0.1"
+ if [[ -n "$lan_ip" ]]; then
+ san="${san},IP:${lan_ip}"
+ fi
+
+ openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:prime256v1 \
+ -keyout "$KEY_FILE" \
+ -out "$CERT_FILE" \
+ -sha256 -days 3650 -nodes \
+ -subj "/CN=vigilar.local" \
+ -addext "subjectAltName=${san}" \
+ 2>/dev/null
+
+ ok "Self-signed certificate generated with openssl"
+}
+
+update_config() {
+ if [[ ! -f "$CONFIG_FILE" ]]; then
+ warn "Config file not found at ${CONFIG_FILE}, skipping config update"
+ return
+ fi
+
+ # Uncomment the tls_cert and tls_key lines if they are commented out
+ if grep -q '^# *tls_cert' "$CONFIG_FILE"; then
+ sudo sed -i 's|^# *tls_cert *=.*|tls_cert = "/etc/vigilar/certs/cert.pem"|' "$CONFIG_FILE"
+ sudo sed -i 's|^# *tls_key *=.*|tls_key = "/etc/vigilar/certs/key.pem"|' "$CONFIG_FILE"
+ ok "Config updated with TLS cert paths"
+ elif grep -q '^tls_cert' "$CONFIG_FILE"; then
+ ok "Config already has TLS cert paths"
+ else
+ # Append after the [web] section port line
+ sudo sed -i '/^\[web\]/,/^$/{/^port/a\tls_cert = "/etc/vigilar/certs/cert.pem"\ntls_key = "/etc/vigilar/certs/key.pem"
+ }' "$CONFIG_FILE"
+ ok "Config updated with TLS cert paths"
+ fi
+}
+
+main() {
+ info "=== Vigilar TLS Certificate Generator ==="
+
+ sudo mkdir -p "$CERT_DIR"
+
+ if [[ -f "$CERT_FILE" && -f "$KEY_FILE" ]]; then
+ warn "Certificates already exist at ${CERT_DIR}/"
+ read -rp "Overwrite? [y/N] " answer
+ if [[ ! "$answer" =~ ^[Yy]$ ]]; then
+ info "Keeping existing certificates"
+ exit 0
+ fi
+ fi
+
+ local lan_ip
+ lan_ip="$(get_lan_ip)" || lan_ip=""
+ if [[ -n "$lan_ip" ]]; then
+ info "Detected LAN IP: ${lan_ip}"
+ else
+ warn "Could not detect LAN IP, skipping IP SAN"
+ fi
+
+ if command -v mkcert &>/dev/null; then
+ generate_with_mkcert "$lan_ip"
+ elif command -v openssl &>/dev/null; then
+ generate_with_openssl "$lan_ip"
+ else
+ fail "Neither mkcert nor openssl found. Install one and retry."
+ fi
+
+ # Set permissions — readable by vigilar group
+ sudo chown root:"${VIGILAR_GROUP}" "$CERT_FILE" "$KEY_FILE"
+ sudo chmod 0640 "$KEY_FILE"
+ sudo chmod 0644 "$CERT_FILE"
+
+ update_config
+
+ echo
+ ok "TLS certificate ready"
+ info " Cert: ${CERT_FILE}"
+ info " Key: ${KEY_FILE}"
+ if [[ -n "$lan_ip" ]]; then
+ info " SANs: vigilar.local, localhost, 127.0.0.1, ${lan_ip}"
+ else
+ info " SANs: vigilar.local, localhost, 127.0.0.1"
+ fi
+}
+
+main "$@"
diff --git a/scripts/gen_vapid_keys.sh b/scripts/gen_vapid_keys.sh
new file mode 100755
index 0000000..2b9ab8e
--- /dev/null
+++ b/scripts/gen_vapid_keys.sh
@@ -0,0 +1,100 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Vigilar — VAPID key generator for Web Push notifications
+# Uses py-vapid (from the vigilar venv) or falls back to openssl.
+
+VENV_DIR="/opt/vigilar/venv"
+SECRETS_DIR="/etc/vigilar/secrets"
+PRIVATE_KEY_FILE="${SECRETS_DIR}/vapid_private.pem"
+PUBLIC_KEY_FILE="${SECRETS_DIR}/vapid_public.txt"
+VIGILAR_GROUP="vigilar"
+
+info() { printf '\033[1;34m[INFO]\033[0m %s\n' "$*"; }
+ok() { printf '\033[1;32m[ OK ]\033[0m %s\n' "$*"; }
+warn() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*"; }
+fail() { printf '\033[1;31m[FAIL]\033[0m %s\n' "$*" >&2; exit 1; }
+
+generate_with_py_vapid() {
+ info "Generating VAPID keys with py-vapid"
+ local python="${VENV_DIR}/bin/python"
+
+ "$python" -c "
+from py_vapid import Vapid
+import base64
+
+v = Vapid()
+v.generate_keys()
+v.save_key('${PRIVATE_KEY_FILE}')
+
+raw = v.public_key.public_bytes(
+ encoding=__import__('cryptography.hazmat.primitives.serialization', fromlist=['Encoding']).Encoding.X962,
+ format=__import__('cryptography.hazmat.primitives.serialization', fromlist=['PublicFormat']).PublicFormat.UncompressedPoint,
+)
+pub_b64 = base64.urlsafe_b64encode(raw).rstrip(b'=').decode()
+print(pub_b64)
+" | tee "$PUBLIC_KEY_FILE"
+}
+
+generate_with_openssl() {
+ info "Generating VAPID keys with openssl"
+
+ # Generate ECDSA P-256 private key in PEM format
+ openssl ecparam -name prime256v1 -genkey -noout -out "$PRIVATE_KEY_FILE" 2>/dev/null
+
+ # Extract the public key in uncompressed point format, base64url-encode it
+ local pub_b64
+ pub_b64="$(openssl ec -in "$PRIVATE_KEY_FILE" -pubout -outform DER 2>/dev/null \
+ | tail -c 65 \
+ | base64 -w 0 \
+ | tr '+/' '-_' \
+ | tr -d '=')"
+
+ echo "$pub_b64" | tee "$PUBLIC_KEY_FILE"
+}
+
+main() {
+ info "=== Vigilar VAPID Key Generator ==="
+
+ sudo mkdir -p "$SECRETS_DIR"
+
+ if [[ -f "$PRIVATE_KEY_FILE" ]]; then
+ warn "VAPID private key already exists at ${PRIVATE_KEY_FILE}"
+ read -rp "Overwrite? [y/N] " answer
+ if [[ ! "$answer" =~ ^[Yy]$ ]]; then
+ info "Keeping existing key"
+ if [[ -f "$PUBLIC_KEY_FILE" ]]; then
+ info "Public key (base64url):"
+ cat "$PUBLIC_KEY_FILE"
+ fi
+ exit 0
+ fi
+ fi
+
+ local public_key
+ if [[ -x "${VENV_DIR}/bin/python" ]] && "${VENV_DIR}/bin/python" -c "import py_vapid" 2>/dev/null; then
+ public_key="$(generate_with_py_vapid)"
+ elif command -v openssl &>/dev/null; then
+ public_key="$(generate_with_openssl)"
+ else
+ fail "Neither py-vapid nor openssl found."
+ fi
+
+ # Secure the private key
+ sudo chown root:root "$PRIVATE_KEY_FILE"
+ sudo chmod 0600 "$PRIVATE_KEY_FILE"
+
+ # Public key file is not sensitive
+ sudo chown root:"${VIGILAR_GROUP}" "$PUBLIC_KEY_FILE"
+ sudo chmod 0644 "$PUBLIC_KEY_FILE"
+
+ echo
+ ok "VAPID keys generated"
+ info " Private key: ${PRIVATE_KEY_FILE}"
+ info " Public key (base64url):"
+ echo " ${public_key}"
+ echo
+ info "Use the public key above in your web app's push subscription config."
+}
+
+main "$@"
diff --git a/scripts/install.sh b/scripts/install.sh
new file mode 100755
index 0000000..2a7549d
--- /dev/null
+++ b/scripts/install.sh
@@ -0,0 +1,232 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Vigilar Home Security — Installation Script
+# Supports Debian/Ubuntu (apt) and Arch Linux (pacman).
+
+VIGILAR_USER="vigilar"
+VIGILAR_GROUP="vigilar"
+INSTALL_DIR="/opt/vigilar"
+VENV_DIR="${INSTALL_DIR}/venv"
+CONFIG_DIR="/etc/vigilar"
+DATA_DIR="/var/vigilar"
+SYSTEMD_DIR="/etc/systemd/system"
+MOSQUITTO_CONF_DIR="/etc/mosquitto/conf.d"
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+info() { printf '\033[1;34m[INFO]\033[0m %s\n' "$*"; }
+warn() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*"; }
+ok() { printf '\033[1;32m[ OK ]\033[0m %s\n' "$*"; }
+fail() { printf '\033[1;31m[FAIL]\033[0m %s\n' "$*" >&2; exit 1; }
+
+need_cmd() {
+ command -v "$1" &>/dev/null || fail "Required command not found: $1"
+}
+
+detect_pkg_manager() {
+ if command -v apt-get &>/dev/null; then
+ echo "apt"
+ elif command -v pacman &>/dev/null; then
+ echo "pacman"
+ else
+ fail "Unsupported package manager. This script supports apt (Debian/Ubuntu) and pacman (Arch)."
+ fi
+}
+
+# ---------------------------------------------------------------------------
+# 1. System dependencies
+# ---------------------------------------------------------------------------
+
+install_system_deps() {
+ local pkg_mgr
+ pkg_mgr="$(detect_pkg_manager)"
+ info "Detected package manager: ${pkg_mgr}"
+
+ case "$pkg_mgr" in
+ apt)
+ sudo apt-get update -qq
+ sudo apt-get install -y -qq \
+ ffmpeg mosquitto python3 python3-venv python3-pip nut-client
+ ;;
+ pacman)
+ sudo pacman -Sy --needed --noconfirm \
+ ffmpeg mosquitto python python-virtualenv nut
+ ;;
+ esac
+ ok "System dependencies installed"
+}
+
+# ---------------------------------------------------------------------------
+# 2. System user & group
+# ---------------------------------------------------------------------------
+
+create_user() {
+ if id "$VIGILAR_USER" &>/dev/null; then
+ ok "User '${VIGILAR_USER}' already exists"
+ return
+ fi
+ info "Creating system user '${VIGILAR_USER}'"
+ sudo useradd --system --home-dir "$INSTALL_DIR" --shell /usr/sbin/nologin \
+ --create-home "$VIGILAR_USER"
+ ok "User '${VIGILAR_USER}' created"
+}
+
+# ---------------------------------------------------------------------------
+# 3. Directories & permissions
+# ---------------------------------------------------------------------------
+
+create_directories() {
+ info "Creating directories"
+
+ # Data directories — owned by vigilar
+ sudo mkdir -p "${DATA_DIR}/data" "${DATA_DIR}/recordings" "${DATA_DIR}/hls"
+ sudo chown -R "${VIGILAR_USER}:${VIGILAR_GROUP}" "$DATA_DIR"
+ sudo chmod -R 0750 "$DATA_DIR"
+
+ # Config directories
+ sudo mkdir -p "${CONFIG_DIR}/secrets" "${CONFIG_DIR}/certs"
+ sudo chown root:root "${CONFIG_DIR}"
+ sudo chmod 0755 "${CONFIG_DIR}"
+
+ # Secrets — root-owned, restricted
+ sudo chown root:root "${CONFIG_DIR}/secrets"
+ sudo chmod 0700 "${CONFIG_DIR}/secrets"
+
+ # Certs — readable by vigilar
+ sudo chown root:"${VIGILAR_GROUP}" "${CONFIG_DIR}/certs"
+ sudo chmod 0750 "${CONFIG_DIR}/certs"
+
+ # Install dir
+ sudo mkdir -p "$INSTALL_DIR"
+ sudo chown "${VIGILAR_USER}:${VIGILAR_GROUP}" "$INSTALL_DIR"
+
+ ok "Directories created"
+}
+
+# ---------------------------------------------------------------------------
+# 4. Python venv & package
+# ---------------------------------------------------------------------------
+
+install_venv() {
+ if [[ -d "$VENV_DIR" ]]; then
+ info "Venv already exists at ${VENV_DIR}, upgrading"
+ else
+ info "Creating Python venv at ${VENV_DIR}"
+ sudo -u "$VIGILAR_USER" python3 -m venv "$VENV_DIR"
+ fi
+
+ info "Installing vigilar package into venv"
+ sudo -u "$VIGILAR_USER" "${VENV_DIR}/bin/pip" install --upgrade pip setuptools wheel -q
+ sudo -u "$VIGILAR_USER" "${VENV_DIR}/bin/pip" install "${PROJECT_DIR}" -q
+
+ ok "Vigilar installed into ${VENV_DIR}"
+}
+
+# ---------------------------------------------------------------------------
+# 5. Storage encryption key
+# ---------------------------------------------------------------------------
+
+generate_storage_key() {
+ local key_file="${CONFIG_DIR}/secrets/storage.key"
+ if [[ -f "$key_file" ]]; then
+ ok "Storage encryption key already exists"
+ return
+ fi
+ info "Generating storage encryption key"
+ sudo dd if=/dev/urandom bs=32 count=1 2>/dev/null | sudo tee "$key_file" > /dev/null
+ sudo chmod 0600 "$key_file"
+ sudo chown root:root "$key_file"
+ ok "Storage key written to ${key_file}"
+}
+
+# ---------------------------------------------------------------------------
+# 6. Sample config
+# ---------------------------------------------------------------------------
+
+install_config() {
+ local dest="${CONFIG_DIR}/vigilar.toml"
+ if [[ -f "$dest" ]]; then
+ ok "Config already exists at ${dest}"
+ return
+ fi
+ info "Copying sample config"
+ sudo cp "${PROJECT_DIR}/config/vigilar.toml" "$dest"
+ sudo chmod 0644 "$dest"
+ sudo chown root:"${VIGILAR_GROUP}" "$dest"
+ ok "Config installed to ${dest}"
+}
+
+# ---------------------------------------------------------------------------
+# 7. Systemd units
+# ---------------------------------------------------------------------------
+
+install_systemd() {
+ info "Installing systemd service"
+ sudo cp "${PROJECT_DIR}/systemd/vigilar.service" "${SYSTEMD_DIR}/vigilar.service"
+ sudo chmod 0644 "${SYSTEMD_DIR}/vigilar.service"
+ sudo systemctl daemon-reload
+ sudo systemctl enable vigilar.service
+ ok "vigilar.service enabled"
+}
+
+# ---------------------------------------------------------------------------
+# 8. Mosquitto configuration
+# ---------------------------------------------------------------------------
+
+configure_mosquitto() {
+ local conf="${MOSQUITTO_CONF_DIR}/vigilar.conf"
+ info "Configuring mosquitto for localhost-only"
+ sudo mkdir -p "$MOSQUITTO_CONF_DIR"
+ sudo cp "${PROJECT_DIR}/systemd/vigilar-mosquitto.conf" "$conf"
+ sudo chmod 0644 "$conf"
+
+ sudo systemctl enable mosquitto.service
+ sudo systemctl restart mosquitto.service
+ ok "Mosquitto configured and restarted"
+}
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+
+main() {
+ info "=== Vigilar Home Security — Installer ==="
+ info "Project dir: ${PROJECT_DIR}"
+ echo
+
+ install_system_deps
+ create_user
+ create_directories
+ install_venv
+ generate_storage_key
+ install_config
+ install_systemd
+ configure_mosquitto
+
+ echo
+ ok "=== Installation complete ==="
+ echo
+ info "Summary:"
+ info " Service user: ${VIGILAR_USER}"
+ info " Venv: ${VENV_DIR}"
+ info " Config: ${CONFIG_DIR}/vigilar.toml"
+ info " Data: ${DATA_DIR}/"
+ info " Secrets: ${CONFIG_DIR}/secrets/"
+ info " Systemd unit: ${SYSTEMD_DIR}/vigilar.service"
+ echo
+ info "Next steps:"
+ info " 1. Edit /etc/vigilar/vigilar.toml — set camera RTSP URLs, passwords, etc."
+ info " 2. Run: sudo ${SCRIPT_DIR}/gen_cert.sh — generate TLS certs"
+ info " 3. Run: sudo ${SCRIPT_DIR}/gen_vapid_keys.sh — generate VAPID keys for push"
+ info " 4. Run: sudo ${SCRIPT_DIR}/setup_nut.sh — configure UPS monitoring"
+ info " 5. Start: sudo systemctl start vigilar"
+ info " 6. Open: https://vigilar.local:49735"
+}
+
+main "$@"
diff --git a/scripts/setup_nut.sh b/scripts/setup_nut.sh
new file mode 100755
index 0000000..e0e2b2e
--- /dev/null
+++ b/scripts/setup_nut.sh
@@ -0,0 +1,203 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Vigilar — NUT (Network UPS Tools) configuration helper
+# Detects USB UPS devices and configures NUT for standalone monitoring.
+
+info() { printf '\033[1;34m[INFO]\033[0m %s\n' "$*"; }
+ok() { printf '\033[1;32m[ OK ]\033[0m %s\n' "$*"; }
+warn() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*"; }
+fail() { printf '\033[1;31m[FAIL]\033[0m %s\n' "$*" >&2; exit 1; }
+
+detect_pkg_manager() {
+ if command -v apt-get &>/dev/null; then
+ echo "apt"
+ elif command -v pacman &>/dev/null; then
+ echo "pacman"
+ else
+ fail "Unsupported package manager."
+ fi
+}
+
+install_nut() {
+ if command -v upsc &>/dev/null; then
+ ok "NUT already installed"
+ return
+ fi
+
+ info "Installing NUT"
+ local pkg_mgr
+ pkg_mgr="$(detect_pkg_manager)"
+
+ case "$pkg_mgr" in
+ apt)
+ sudo apt-get update -qq
+ sudo apt-get install -y -qq nut nut-client nut-server
+ ;;
+ pacman)
+ sudo pacman -Sy --needed --noconfirm nut
+ ;;
+ esac
+ ok "NUT installed"
+}
+
+detect_ups() {
+ info "Scanning for USB UPS devices..."
+
+ local driver=""
+ local port=""
+ local desc=""
+
+ # Try nut-scanner first
+ if command -v nut-scanner &>/dev/null; then
+ local scan_output
+ scan_output="$(sudo nut-scanner -U 2>/dev/null)" || true
+ if [[ -n "$scan_output" ]]; then
+ driver="$(echo "$scan_output" | grep -oP 'driver\s*=\s*"\K[^"]+' | head -1)" || true
+ port="$(echo "$scan_output" | grep -oP 'port\s*=\s*"\K[^"]+' | head -1)" || true
+ desc="$(echo "$scan_output" | grep -oP 'desc\s*=\s*"\K[^"]+' | head -1)" || true
+ fi
+ fi
+
+ # Fallback: check for common USB UPS vendor IDs
+ if [[ -z "$driver" ]]; then
+ if lsusb 2>/dev/null | grep -qi "051d"; then
+ driver="usbhid-ups"
+ port="auto"
+ desc="APC UPS (auto-detected via lsusb)"
+ elif lsusb 2>/dev/null | grep -qi "0764"; then
+ driver="usbhid-ups"
+ port="auto"
+ desc="CyberPower UPS (auto-detected via lsusb)"
+ elif lsusb 2>/dev/null | grep -qi "0463"; then
+ driver="usbhid-ups"
+ port="auto"
+ desc="Eaton UPS (auto-detected via lsusb)"
+ elif lsusb 2>/dev/null | grep -qi "06da"; then
+ driver="usbhid-ups"
+ port="auto"
+ desc="Phoenixtec/Tripp Lite UPS (auto-detected via lsusb)"
+ fi
+ fi
+
+ if [[ -z "$driver" ]]; then
+ warn "No USB UPS detected. Using generic usbhid-ups driver with auto port."
+ warn "You may need to edit /etc/nut/ups.conf manually."
+ driver="usbhid-ups"
+ port="auto"
+ desc="UPS (not auto-detected)"
+ else
+ ok "Detected UPS: ${desc:-unknown}"
+ fi
+
+ # Export for use in config generation
+ UPS_DRIVER="$driver"
+ UPS_PORT="$port"
+ UPS_DESC="${desc:-UPS}"
+}
+
+generate_configs() {
+ info "Generating NUT configuration"
+
+ # /etc/nut/nut.conf — standalone mode
+ sudo tee /etc/nut/nut.conf > /dev/null <<'NUTCONF'
+# Vigilar NUT configuration — standalone mode
+MODE=standalone
+NUTCONF
+
+ # /etc/nut/ups.conf — UPS definition
+ sudo tee /etc/nut/ups.conf > /dev/null < /dev/null <<'UPSDCONF'
+# Vigilar upsd configuration
+LISTEN 127.0.0.1 3493
+UPSDCONF
+
+ # /etc/nut/upsd.users — local monitoring user
+ sudo tee /etc/nut/upsd.users > /dev/null <<'USERSCONF'
+[vigilar]
+ password = vigilar_local
+ upsmon master
+USERSCONF
+
+ # /etc/nut/upsmon.conf — monitoring config
+ sudo tee /etc/nut/upsmon.conf > /dev/null <<'MONCONF'
+# Vigilar upsmon configuration
+MONITOR ups@localhost 1 vigilar vigilar_local master
+MINSUPPLIES 1
+SHUTDOWNCMD "/sbin/shutdown -h +0"
+POLLFREQ 15
+POLLFREQALERT 5
+HOSTSYNC 15
+DEADTIME 45
+RBWARNTIME 43200
+NOCOMMWARNTIME 600
+FINALDELAY 5
+MONCONF
+
+ # Secure the config files
+ sudo chmod 0640 /etc/nut/ups.conf /etc/nut/upsd.conf /etc/nut/upsd.users /etc/nut/upsmon.conf
+ sudo chown root:nut /etc/nut/ups.conf /etc/nut/upsd.conf /etc/nut/upsd.users /etc/nut/upsmon.conf 2>/dev/null || true
+
+ ok "NUT configuration files written"
+}
+
+enable_services() {
+ info "Enabling and starting NUT services"
+
+ # Service names vary by distro
+ local driver_svc="nut-driver"
+ local server_svc="nut-server"
+ local monitor_svc="nut-monitor"
+
+ # On Arch, the services may be named differently
+ if ! systemctl list-unit-files "${server_svc}.service" &>/dev/null; then
+ if systemctl list-unit-files "upsd.service" &>/dev/null; then
+ server_svc="upsd"
+ monitor_svc="upsmon"
+ driver_svc="nut-driver-enumerator"
+ fi
+ fi
+
+ # Enable and start
+ for svc in "$driver_svc" "$server_svc" "$monitor_svc"; do
+ if systemctl list-unit-files "${svc}.service" &>/dev/null; then
+ sudo systemctl enable "${svc}.service" 2>/dev/null || true
+ sudo systemctl restart "${svc}.service" 2>/dev/null || warn "Could not start ${svc}.service — check UPS connection"
+ fi
+ done
+
+ ok "NUT services enabled"
+}
+
+main() {
+ info "=== Vigilar NUT Setup ==="
+ echo
+
+ install_nut
+
+ UPS_DRIVER=""
+ UPS_PORT=""
+ UPS_DESC=""
+ detect_ups
+
+ generate_configs
+ enable_services
+
+ echo
+ ok "=== NUT setup complete ==="
+ info "Test with: upsc ups@localhost"
+ info "Vigilar will monitor UPS at 127.0.0.1:3493 (ups name: 'ups')"
+}
+
+main "$@"
diff --git a/scripts/uninstall.sh b/scripts/uninstall.sh
new file mode 100755
index 0000000..5c837dc
--- /dev/null
+++ b/scripts/uninstall.sh
@@ -0,0 +1,130 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# Vigilar — Uninstall script
+# Stops services, removes venv, systemd units, and mosquitto config.
+# Data and config are preserved by default.
+
+VIGILAR_USER="vigilar"
+INSTALL_DIR="/opt/vigilar"
+VENV_DIR="${INSTALL_DIR}/venv"
+CONFIG_DIR="/etc/vigilar"
+DATA_DIR="/var/vigilar"
+SYSTEMD_DIR="/etc/systemd/system"
+MOSQUITTO_CONF="/etc/mosquitto/conf.d/vigilar.conf"
+
+info() { printf '\033[1;34m[INFO]\033[0m %s\n' "$*"; }
+ok() { printf '\033[1;32m[ OK ]\033[0m %s\n' "$*"; }
+warn() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*"; }
+
+# ---------------------------------------------------------------------------
+# 1. Stop and disable services
+# ---------------------------------------------------------------------------
+
+stop_services() {
+ info "Stopping and disabling services"
+
+ if systemctl is-active vigilar.service &>/dev/null; then
+ sudo systemctl stop vigilar.service
+ fi
+ if systemctl is-enabled vigilar.service &>/dev/null; then
+ sudo systemctl disable vigilar.service
+ fi
+
+ ok "Services stopped"
+}
+
+# ---------------------------------------------------------------------------
+# 2. Remove systemd units
+# ---------------------------------------------------------------------------
+
+remove_systemd() {
+ info "Removing systemd unit"
+ if [[ -f "${SYSTEMD_DIR}/vigilar.service" ]]; then
+ sudo rm -f "${SYSTEMD_DIR}/vigilar.service"
+ sudo systemctl daemon-reload
+ fi
+ ok "Systemd unit removed"
+}
+
+# ---------------------------------------------------------------------------
+# 3. Remove mosquitto config
+# ---------------------------------------------------------------------------
+
+remove_mosquitto_conf() {
+ info "Removing Vigilar mosquitto config"
+ if [[ -f "$MOSQUITTO_CONF" ]]; then
+ sudo rm -f "$MOSQUITTO_CONF"
+ sudo systemctl restart mosquitto.service 2>/dev/null || true
+ fi
+ ok "Mosquitto config removed"
+}
+
+# ---------------------------------------------------------------------------
+# 4. Remove venv and install dir
+# ---------------------------------------------------------------------------
+
+remove_venv() {
+ info "Removing venv at ${VENV_DIR}"
+ if [[ -d "$VENV_DIR" ]]; then
+ sudo rm -rf "$VENV_DIR"
+ fi
+ if [[ -d "$INSTALL_DIR" ]]; then
+ sudo rm -rf "$INSTALL_DIR"
+ fi
+ ok "Venv removed"
+}
+
+# ---------------------------------------------------------------------------
+# 5. Remove system user
+# ---------------------------------------------------------------------------
+
+remove_user() {
+ if id "$VIGILAR_USER" &>/dev/null; then
+ info "Removing system user '${VIGILAR_USER}'"
+ sudo userdel "$VIGILAR_USER" 2>/dev/null || true
+ ok "User removed"
+ fi
+}
+
+# ---------------------------------------------------------------------------
+# 6. Optionally remove data
+# ---------------------------------------------------------------------------
+
+remove_data() {
+ if [[ -d "$DATA_DIR" ]]; then
+ echo
+ warn "Data directory exists: ${DATA_DIR}"
+ warn "This contains recordings, database, and HLS segments."
+ read -rp "Delete data directory? [y/N] " answer
+ if [[ "$answer" =~ ^[Yy]$ ]]; then
+ sudo rm -rf "$DATA_DIR"
+ ok "Data directory removed"
+ else
+ info "Data preserved at ${DATA_DIR}"
+ fi
+ fi
+}
+
+# ---------------------------------------------------------------------------
+# Main
+# ---------------------------------------------------------------------------
+
+main() {
+ info "=== Vigilar Uninstaller ==="
+ echo
+
+ stop_services
+ remove_systemd
+ remove_mosquitto_conf
+ remove_venv
+ remove_user
+ remove_data
+
+ echo
+ ok "=== Uninstall complete ==="
+ info "Config and secrets preserved at ${CONFIG_DIR}/"
+ info "To remove config/secrets too: sudo rm -rf ${CONFIG_DIR}"
+}
+
+main "$@"
diff --git a/systemd/vigilar-mosquitto.conf b/systemd/vigilar-mosquitto.conf
new file mode 100644
index 0000000..f153f30
--- /dev/null
+++ b/systemd/vigilar-mosquitto.conf
@@ -0,0 +1,18 @@
+# Mosquitto configuration for Vigilar
+# Localhost-only, no authentication, no persistence.
+# Drop this file in /etc/mosquitto/conf.d/
+
+# Bind to loopback only — no network exposure
+listener 1883 127.0.0.1
+# No authentication needed for localhost
+allow_anonymous true
+
+# Disable persistence — Vigilar state lives in SQLite
+persistence false
+
+# Logging
+log_dest syslog
+log_type error
+log_type warning
+log_type notice
+connection_messages true
diff --git a/systemd/vigilar.service b/systemd/vigilar.service
new file mode 100644
index 0000000..db04293
--- /dev/null
+++ b/systemd/vigilar.service
@@ -0,0 +1,53 @@
+[Unit]
+Description=Vigilar Home Security System
+Documentation=https://github.com/vigilar/vigilar
+After=network.target mosquitto.service
+Requires=mosquitto.service
+Wants=nut-monitor.service
+
+[Service]
+Type=simple
+User=vigilar
+Group=vigilar
+
+Environment=VIGILAR_CONFIG=/etc/vigilar/vigilar.toml
+ExecStart=/opt/vigilar/venv/bin/vigilar start --config /etc/vigilar/vigilar.toml
+
+Restart=on-failure
+RestartSec=10
+WatchdogSec=120
+
+# Security hardening
+NoNewPrivileges=yes
+ProtectSystem=strict
+ProtectHome=yes
+PrivateTmp=yes
+PrivateDevices=yes
+ProtectKernelModules=yes
+ProtectKernelTunables=yes
+ProtectKernelLogs=yes
+ProtectControlGroups=yes
+ProtectClock=yes
+ProtectHostname=yes
+RestrictNamespaces=yes
+RestrictRealtime=yes
+RestrictSUIDSGID=yes
+LockPersonality=yes
+MemoryDenyWriteExecute=no
+SystemCallArchitectures=native
+SystemCallFilter=@system-service
+SystemCallFilter=~@privileged @resources
+
+# Allow write to data directories
+ReadWritePaths=/var/vigilar/data /var/vigilar/recordings /var/vigilar/hls
+
+# Read-only access to config and secrets
+ReadOnlyPaths=/etc/vigilar
+
+# Logging
+StandardOutput=journal
+StandardError=journal
+SyslogIdentifier=vigilar
+
+[Install]
+WantedBy=multi-user.target