Files
Claude bec9228c55 Back up existing files before every service overwrites them
Confirmed live: install_frigate()'s fresh-install path overwrote a
working, hand-crafted docker-compose.yml (Frigate + mosquitto +
frigate-notify) with zero backup, because that file's shape didn't match
what frigate.sh's own "existing install" detection knew how to recognize.
Every service's own detection is a judgment call about what counts as
"already installed" and can miss a real setup built outside this repo's
conventions.

lib/common.sh gains backup_if_exists(FILE) — copies FILE to
FILE.bak.<timestamp> if it exists, no-ops otherwise (including DRY_RUN).
Applied before every service's own `cat > docker-compose.yml`/`cat > .env`
write across all 60 services that do one (115 call sites), plus a matching
standalone-mode stub added to every service's own bootstrap block, same
convention already used for port_in_use/find_free_port. This doesn't
replace a service's own update/fresh-reinstall detection — it's the safety
net underneath it, so a wrong detection costs a .bak file to restore from
instead of the original silently disappearing.

Also fixes the actual gap that surfaced this: services/frigate.sh's
Authelia offer only checked for Authelia installed locally on Frigate's
own box, which is never true for a dedicated NVR box with no local Caddy
either (the common shape — Caddy lives elsewhere, snippet-generation mode
already handles that). Now offers Authelia protection unconditionally and,
when Authelia isn't local, asks whether it lives on the same machine as
Caddy (still "import authelia", since that's local to wherever Caddy ends
up) or on a genuinely separate third machine (the explicit
header-pinned forward_auth form, per CLAUDE.md's "forward_auth to a remote
Authelia" note, needed because a bare authelia:9091 shortcut only works
one hop).
2026-08-26 17:26:17 +00:00

236 lines
9.3 KiB
Bash

#!/bin/bash
# services/watchyourlan.sh — WatchYourLAN network device tracker.
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash watchyourlan.sh
# (Docker must already be installed when run standalone)
#
# Continuously scans the network for connected devices, tracks history,
# and can alert on new/unknown devices. Uses network_mode: host so it
# can see the physical network directly (required for ARP scanning).
# ── Standalone bootstrap ──────────────────────────────────────────────────────
# Detected when the script is executed directly rather than sourced by setup.sh.
# Sets up helpers and globals, then defers execution until after the function
# definition at the bottom of this file.
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_COMMON="$_SELF_DIR/../lib/common.sh"
if [[ -f "$_COMMON" ]]; then
# Full repo present — use the real helpers (picks up ~/docker/.config too)
# shellcheck source=../lib/common.sh
source "$_COMMON"
else
# One-off copy — inline minimal stubs so the script works without the repo
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
require_docker() {
command -v docker &>/dev/null || {
log_error "Docker not found. Install it first:"
log_error " curl -fsSL https://get.docker.com | sudo sh"
return 1
}
docker compose version &>/dev/null || {
log_error "Docker Compose plugin missing:"
log_error " sudo apt-get install -y docker-compose-plugin"
return 1
}
}
ensure_docker_dir_ownership() {
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
}
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
prompt_text() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
prompt_yn() {
local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
read -r -p " $_q " _r
eval "$_var='${_r:-$_def}'"
}
write_readme() {
local _dir="$1"; shift
mkdir -p "$_dir"
cat > "$_dir/README.md"
}
backup_if_exists() {
local _file="$1"
[ -f "$_file" ] || return 0
cp -p "$_file" "${_file}.bak.$(date +%Y%m%d-%H%M%S)" 2>/dev/null
}
fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
# ($HOME under sudo is /root, not the real user's home)
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
DRY_RUN="${DRY_RUN:-false}"
UNATTENDED="${UNATTENDED:-false}"
SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}"
register_service() { :; } # no-op — no wizard to register into
_RUN_STANDALONE=1
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service watchyourlan utilities "Network device tracker (WatchYourLAN)" 8840
install_watchyourlan() {
require_docker || return 1
log_info "Installing WatchYourLAN..."
local WYL_DIR="$DOCKER_DIR/watchyourlan"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $WYL_DIR (watchyourlan_data/)"
echo "[DRY-RUN] Would deploy aceberg/watchyourlan:latest (network_mode: host)"
echo "[DRY-RUN] Port 8840 on host, needs network interface name"
return 0
fi
mkdir -p "$WYL_DIR/watchyourlan_data"
ensure_docker_dir_ownership "$WYL_DIR"
cd "$WYL_DIR" || return 1
local TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
# Auto-detect primary network interface
local DEFAULT_IFACE
DEFAULT_IFACE=$(ip route show default 2>/dev/null | awk '/default/ {print $5; exit}')
[ -z "$DEFAULT_IFACE" ] && DEFAULT_IFACE="eth0"
echo ""
echo " WatchYourLAN needs to know which network interface to scan."
echo " Your detected primary interface: $DEFAULT_IFACE"
echo ""
ip link show 2>/dev/null | awk -F: '/^[0-9]+: / && !/lo/ {gsub(/ /,"",$2); print " •", $2}' || true
echo ""
local SCAN_IFACE=""
prompt_text "Network interface to scan:" "$DEFAULT_IFACE" SCAN_IFACE
[ -z "$SCAN_IFACE" ] && SCAN_IFACE="$DEFAULT_IFACE"
local GUI_PORT="8840"
prompt_text "GUI port [8840]:" "8840" GUI_PORT
[ -z "$GUI_PORT" ] && GUI_PORT="8840"
backup_if_exists docker-compose.yml
cat > docker-compose.yml << 'WYL_COMPOSE'
name: watchyourlan
services:
watchyourlan:
image: aceberg/watchyourlan:latest
container_name: watchyourlan
hostname: watchyourlan
restart: unless-stopped
network_mode: host
env_file: .env
volumes:
- ./watchyourlan_data:/data
WYL_COMPOSE
backup_if_exists .env
cat > .env << WYL_ENV
# ── General ───────────────────────────────────────────────────────────────────
TZ=$TZ_VAL
# ── WatchYourLAN ──────────────────────────────────────────────────────────────
# Network interface to scan (ARP scanning requires the physical interface)
IFACE=$SCAN_IFACE
# GUI bind address and port (network_mode: host — binds directly to the host)
GUIIP=0.0.0.0
GUIPORT=$GUI_PORT
# Web UI theme (darkly, cosmo, lumen, sandstone, etc.)
THEME=darkly
WYL_ENV
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$WYL_DIR"
log_success "WatchYourLAN configured at $WYL_DIR"
# WatchYourLAN uses network_mode: host, so Caddy container-name routing
# can't reach it via caddy_net regardless of where Caddy runs. Access is
# directly on host port $GUI_PORT. Mirrors configure_caddy_for_service's
# own mode resolution (lib/common.sh) purely to word this note correctly.
local _CADDY_MODE="${CADDY_MODE:-none}"
[ "$_CADDY_MODE" = "none" ] && [ -d "$DOCKER_DIR/caddy" ] && _CADDY_MODE="local"
[ "$_CADDY_MODE" = "none" ] && [ -n "${CADDY_REMOTE_HOST:-}" ] && _CADDY_MODE="remote"
if [ "$_CADDY_MODE" != "none" ]; then
echo ""
log_info "Note: WatchYourLAN uses host networking (needed for ARP scanning)."
log_info "It cannot join caddy_net. To put it behind Caddy, add this block manually:"
echo ""
echo " yourdomain.com {"
echo " reverse_proxy <HOST_IP>:$GUI_PORT"
echo " }"
echo ""
if [ "$_CADDY_MODE" = "remote" ]; then
echo " where HOST_IP is this server's real network IP (Caddy is on a different box)."
else
echo " where HOST_IP is this server's IP on the Docker bridge (usually 172.17.0.1)."
fi
fi
write_readme "$WYL_DIR" << MD
# WatchYourLAN — network device tracker
Scans the network continuously for connected devices, tracks history,
and alerts on new or unknown devices joining the network.
## Access
- URL: http://localhost:$GUI_PORT (or http://<server-ip>:$GUI_PORT from LAN)
## Scanning interface
Configured to scan: **$SCAN_IFACE**
Change \`IFACE\` in .env and restart if you need to scan a different interface.
## Network mode note
WatchYourLAN uses \`network_mode: host\` to see real ARP traffic.
This means it cannot be added to caddy_net for reverse proxy via container name.
To put it behind Caddy, use the host's IP directly in the Caddyfile:
\`reverse_proxy 172.17.0.1:$GUI_PORT\` (adjust IP to your Docker bridge gateway).
## Manage
\`\`\`bash
cd $WYL_DIR
docker compose up -d # start
docker compose down # stop
docker compose logs -f # logs
docker compose pull && docker compose up -d # update
\`\`\`
MD
local START_WYL=""
prompt_yn "Start WatchYourLAN now? (y/n):" "y" START_WYL
if [ "$START_WYL" = "y" ] || [ "$START_WYL" = "Y" ]; then
docker compose up -d \
&& log_success "WatchYourLAN started" \
|| log_warning "Failed to start — check: docker compose logs"
fi
echo " Access at: http://localhost:$GUI_PORT"
echo " Scanning: interface $SCAN_IFACE"
echo ""
}
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_watchyourlan