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