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

946 lines
40 KiB
Bash

#!/bin/bash
# services/frigate.sh — AI-powered NVR for security cameras (Frigate).
# Part of the modular post-install system (sourced by setup.sh).
#
# Can also be run standalone on any machine:
# sudo bash frigate.sh
# (Docker must already be installed when run standalone)
#
# Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- FRIGATE NVR ----).
# Own ~/docker/frigate/ with a standalone docker-compose.yml + .env + config.yml.
# Auto-enables /dev/dri/renderD128 for hardware detection (Intel/AMD) when present.
# Interactively prompts for cameras — RTSP user/pass/IP go in .env as
# FRIGATE_* vars, which config.yml references via Frigate's {FRIGATE_VAR}
# substitution syntax so credentials never appear in the YAML directly.
# Skip the camera prompts to get a starter config.yml to edit by hand.
# ── 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
}
port_in_use() {
local _port="$1" _proto="${2:-tcp}"
local _flag="-tlnH"
[ "$_proto" = "udp" ] && _flag="-ulnH"
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
}
find_free_port() {
local _varname="$1" _port="$2" _proto="${3:-tcp}"
while port_in_use "$_port" "$_proto"; do
_port=$((_port + 1))
done
eval "$_varname='$_port'"
}
# 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}'"
}
configure_caddy_for_service() {
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" _rp_extra="${5:-}"
local _caddy_dir="$DOCKER_DIR/caddy"
local _caddyfile="$_caddy_dir/Caddyfile"
local _display_port="${_upstream##*:}"
# Determine mode: local Caddy, remote Caddy, or none
local _mode="none"
[[ -d "$_caddy_dir" ]] && _mode="local"
[[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote"
[[ "$_mode" == "none" ]] && {
log_info "Access $_name directly on port $_display_port."
return 0
}
echo ""
local _do_caddy=""
if [[ "$_mode" == "remote" ]]; then
log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})."
log_info "A snippet file will be saved to ~/docker/caddy-snippets/."
fi
read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy
[[ "${_do_caddy,,}" == "y" ]] || {
log_info "Skipping — access at: http://localhost:$_display_port"
return 0
}
# Domain prompt — pre-fill from SITE_DOMAIN when available
local _default_domain=""
if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then
_default_domain="${_subdomain}.${SITE_DOMAIN}"
log_info "Default: $_default_domain"
fi
local _domain=""
read -r -p " Domain [${_default_domain:-required}]: " _domain
_domain="${_domain:-$_default_domain}"
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
# Build upstream — remote Caddy uses host IP:port, not container name
local _block_upstream="$_upstream"
if [[ "$_mode" == "remote" ]]; then
_block_upstream="${CADDY_REMOTE_HOST}:${_display_port}"
fi
local _rp_line="reverse_proxy ${_block_upstream}"
if [[ -n "$_rp_extra" ]]; then
_rp_line="reverse_proxy ${_block_upstream} {
${_rp_extra}
}"
fi
local _site_block
_site_block="$(cat << CBLOCK
# $_name
${_domain} {
# Auth (if any) must come before reverse_proxy — see lib/common.sh's
# configure_caddy_for_service for why (reverse_proxy first would answer
# every request itself, making an auth block after it dead code).
${_extra}
${_rp_line}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "SAMEORIGIN"
Referrer-Policy "strict-origin-when-cross-origin"
}
log {
output file /var/log/caddy/${_domain}.log
format json
}
}
CBLOCK
)"
if [[ "$_mode" == "local" ]]; then
if [[ -f "$_caddyfile" ]]; then
local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
cp "$_caddyfile" "$_bk"
log_info "Backed up Caddyfile to $(basename "$_bk")"
else
touch "$_caddyfile"
fi
if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then
log_warning "$_domain already in Caddyfile"
local _ow=""
read -r -p " Overwrite? [y/N]: " _ow
[[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; }
sed -i "/^${_domain}/,/^}/d" "$_caddyfile"
fi
printf '%s\n' "$_site_block" >> "$_caddyfile"
log_success "Added $_domain to Caddyfile"
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
log_success "$_name accessible at: https://$_domain"
else
log_warning "Reload failed — check: docker logs caddy"
log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile"
fi
else
local _snippet_dir="$DOCKER_DIR/caddy-snippets"
local _snippet_file="$_snippet_dir/${_subdomain}.caddy"
mkdir -p "$_snippet_dir"
printf '%s\n' "$_site_block" > "$_snippet_file"
chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true
log_success "Snippet saved: $_snippet_file"
log_info "Copy to Caddy machine:"
log_info " scp $_snippet_file caddy-host:~/caddy-snippets/"
log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)"
fi
}
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}"
register_service() { :; } # no-op — no wizard to register into
_RUN_STANDALONE=1
fi
# ─────────────────────────────────────────────────────────────────────────────
register_service frigate cameras "AI-powered NVR — object detection on security cameras (Frigate)" 5000
# ── Existing-config parsing (best-effort) ────────────────────────────────────
# Populates the CAM_* arrays (declared by install_frigate) plus ENV_MAP by
# reading an already-generated config.yml + .env. Only understands the shape
# this installer itself writes; heavily hand-edited files may not parse
# cleanly — the caller always offers a backup-and-fresh option as a fallback.
_frigate_parse_existing() {
local cfg="$1" env="$2"
declare -gA ENV_MAP=()
local k v
while IFS='=' read -r k v; do
[[ "$k" =~ ^[[:space:]]*# ]] && continue
[[ -z "${k// /}" ]] && continue
ENV_MAP["$k"]="$v"
done < "$env"
local re_stream_key='^[[:space:]]{4}([a-z0-9_]+):[[:space:]]*$'
local re_rtsp_line='rtsp://\{([A-Za-z0-9_]+)\}:\{([A-Za-z0-9_]+)\}@\{([A-Za-z0-9_]+)\}:([0-9]+)(.*)$'
local re_cam_key='^[[:space:]]{2}([a-z0-9_]+):[[:space:]]*$'
local re_enabled='^[[:space:]]{4}enabled:[[:space:]]*(true|false)'
local re_notify_key='^[[:space:]]{4}notifications:'
local re_notify_val='^[[:space:]]{6}enabled:[[:space:]]*(true|false)'
declare -A _MAIN_OF=() _SUB_OF=() _PORT_OF=() _VU_OF=() _VP_OF=() _VI_OF=()
local _order=() _stream="" in_go2rtc=false line
while IFS= read -r line; do
if [[ "$line" =~ ^go2rtc: ]]; then in_go2rtc=true; continue; fi
if $in_go2rtc && [[ "$line" =~ ^[a-zA-Z] ]]; then in_go2rtc=false; fi
$in_go2rtc || continue
if [[ "$line" =~ $re_stream_key ]]; then _stream="${BASH_REMATCH[1]}"; continue; fi
if [[ -n "$_stream" ]] && [[ "$line" =~ $re_rtsp_line ]]; then
local vu="${BASH_REMATCH[1]}" vp="${BASH_REMATCH[2]}" vi="${BASH_REMATCH[3]}"
local port="${BASH_REMATCH[4]}" suffix="${BASH_REMATCH[5]}"
if [[ "$_stream" == *_sub ]]; then
_SUB_OF["${_stream%_sub}"]="$suffix"
else
_MAIN_OF["$_stream"]="$suffix"; _PORT_OF["$_stream"]="$port"
_VU_OF["$_stream"]="$vu"; _VP_OF["$_stream"]="$vp"; _VI_OF["$_stream"]="$vi"
_order+=("$_stream")
fi
_stream=""
fi
done < "$cfg"
declare -A _ENABLED_OF=() _NOTIFY_OF=()
local cam="" in_cameras=false in_notify=false
while IFS= read -r line; do
if [[ "$line" =~ ^cameras: ]]; then in_cameras=true; continue; fi
if $in_cameras && [[ "$line" =~ ^[a-zA-Z] ]]; then in_cameras=false; fi
$in_cameras || continue
if [[ "$line" =~ $re_cam_key ]]; then cam="${BASH_REMATCH[1]}"; in_notify=false; continue; fi
[[ -z "$cam" ]] && continue
if [[ "$line" =~ $re_notify_key ]]; then in_notify=true; continue; fi
if $in_notify && [[ "$line" =~ $re_notify_val ]]; then
_NOTIFY_OF["$cam"]="${BASH_REMATCH[1]}"; in_notify=false; continue
fi
if [[ "$line" =~ $re_enabled ]]; then _ENABLED_OF["$cam"]="${BASH_REMATCH[1]}"; fi
done < "$cfg"
CAM_NAME=() CAM_PORT=() CAM_VARUSER=() CAM_VARPASS=() CAM_VARIP=()
CAM_HASSUB=() CAM_MAIN_SUFFIX=() CAM_SUB_SUFFIX=() CAM_ENABLED=() CAM_NOTIFY=()
CAM_CRED_USER=() CAM_CRED_PASS=() CAM_CRED_IP=()
local name
for name in "${_order[@]}"; do
CAM_NAME+=("$name")
CAM_PORT+=("${_PORT_OF[$name]}")
CAM_VARUSER+=("${_VU_OF[$name]}")
CAM_VARPASS+=("${_VP_OF[$name]}")
CAM_VARIP+=("${_VI_OF[$name]}")
CAM_MAIN_SUFFIX+=("${_MAIN_OF[$name]}")
if [[ -n "${_SUB_OF[$name]:-}" ]]; then
CAM_HASSUB+=("true"); CAM_SUB_SUFFIX+=("${_SUB_OF[$name]}")
else
CAM_HASSUB+=("false"); CAM_SUB_SUFFIX+=("")
fi
CAM_ENABLED+=("${_ENABLED_OF[$name]:-false}")
CAM_NOTIFY+=("${_NOTIFY_OF[$name]:-false}")
CAM_CRED_USER+=("${ENV_MAP[${_VU_OF[$name]}]:-}")
CAM_CRED_PASS+=("${ENV_MAP[${_VP_OF[$name]}]:-}")
CAM_CRED_IP+=("${ENV_MAP[${_VI_OF[$name]}]:-}")
done
}
_frigate_backup_existing() {
local ts; ts="$(date +%Y%m%d-%H%M%S)"
local bdir="$FRIGATE_DIR/backup-$ts"
mkdir -p "$bdir"
[ -f config/config.yml ] && cp config/config.yml "$bdir/config.yml"
[ -f .env ] && cp .env "$bdir/.env"
[ -f docker-compose.yml ] && cp docker-compose.yml "$bdir/docker-compose.yml"
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$bdir" 2>/dev/null || true
log_success "Backed up existing config to $bdir"
}
_frigate_clear_cameras() {
CAM_NAME=() CAM_PORT=() CAM_VARUSER=() CAM_VARPASS=() CAM_VARIP=()
CAM_HASSUB=() CAM_MAIN_SUFFIX=() CAM_SUB_SUFFIX=() CAM_ENABLED=() CAM_NOTIFY=()
CAM_CRED_USER=() CAM_CRED_PASS=() CAM_CRED_IP=()
}
_frigate_remove_by_index() {
local nums="$1"
declare -A to_remove=()
local n
for n in $nums; do to_remove["$((n-1))"]=1; done
local _NAME=() _PORT=() _VU=() _VP=() _VI=() _HS=() _MS=() _SS=() _EN=() _NO=() _CU=() _CP=() _CI=()
local i removed=0
for i in "${!CAM_NAME[@]}"; do
if [[ -n "${to_remove[$i]:-}" ]]; then removed=$((removed+1)); continue; fi
_NAME+=("${CAM_NAME[$i]}"); _PORT+=("${CAM_PORT[$i]}")
_VU+=("${CAM_VARUSER[$i]}"); _VP+=("${CAM_VARPASS[$i]}"); _VI+=("${CAM_VARIP[$i]}")
_HS+=("${CAM_HASSUB[$i]}"); _MS+=("${CAM_MAIN_SUFFIX[$i]}"); _SS+=("${CAM_SUB_SUFFIX[$i]}")
_EN+=("${CAM_ENABLED[$i]}"); _NO+=("${CAM_NOTIFY[$i]}")
_CU+=("${CAM_CRED_USER[$i]}"); _CP+=("${CAM_CRED_PASS[$i]}"); _CI+=("${CAM_CRED_IP[$i]}")
done
CAM_NAME=("${_NAME[@]}"); CAM_PORT=("${_PORT[@]}"); CAM_VARUSER=("${_VU[@]}"); CAM_VARPASS=("${_VP[@]}")
CAM_VARIP=("${_VI[@]}"); CAM_HASSUB=("${_HS[@]}"); CAM_MAIN_SUFFIX=("${_MS[@]}"); CAM_SUB_SUFFIX=("${_SS[@]}")
CAM_ENABLED=("${_EN[@]}"); CAM_NOTIFY=("${_NO[@]}"); CAM_CRED_USER=("${_CU[@]}")
CAM_CRED_PASS=("${_CP[@]}"); CAM_CRED_IP=("${_CI[@]}")
log_success "Removed $removed camera(s)."
}
# Presents the numbered menu once existing cameras were parsed. Sets
# FRIGATE_SKIP_WIZARD=true when the operator chooses to leave everything
# untouched; mutates the CAM_* arrays in place for backup/remove/add.
_frigate_review_existing() {
echo ""
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ Existing Frigate cameras found ║"
echo "╚══════════════════════════════════════════════════════════════╝"
local i
for i in "${!CAM_NAME[@]}"; do
printf " %d) %-16s enabled=%-5s substream=%-5s ip=%s\n" \
"$((i+1))" "${CAM_NAME[$i]}" "${CAM_ENABLED[$i]}" "${CAM_HASSUB[$i]}" "${CAM_CRED_IP[$i]:-<unknown>}"
done
echo ""
echo " [1] Keep everything as-is (no changes)"
echo " [2] Backup existing config and start fresh"
echo " [3] Add more cameras (keep these)"
echo " [4] Remove cameras (choose numbers, or 'all'), then optionally add more"
echo ""
local CHOICE=""
prompt_text " Choice [1]:" "1" CHOICE
case "$CHOICE" in
2)
_frigate_backup_existing
_frigate_clear_cameras
FRIGATE_SKIP_WIZARD=false
;;
4)
local REMOVE=""
prompt_text " Remove which numbers (space-separated) or 'all':" "" REMOVE
if [ "$REMOVE" = "all" ]; then
_frigate_clear_cameras
elif [ -n "$REMOVE" ]; then
_frigate_remove_by_index "$REMOVE"
fi
FRIGATE_SKIP_WIZARD=false
;;
3)
FRIGATE_SKIP_WIZARD=false
;;
*)
FRIGATE_SKIP_WIZARD=true
;;
esac
}
# Next unused credential-var suffix, treating "no suffix" as 0. Kept cameras
# retain their existing var names untouched; new cameras never reuse one.
_frigate_next_suffix_int() {
local max=-1 re='^FRIGATE_RTSP_USER([0-9]*)$' v n i
for i in "${!CAM_VARUSER[@]}"; do
v="${CAM_VARUSER[$i]}"
[[ "$v" =~ $re ]] || continue
n="${BASH_REMATCH[1]}"; [ -z "$n" ] && n=0
[ "$n" -gt "$max" ] && max="$n"
done
echo "$((max+1))"
}
# Prompts to add cameras one at a time, appending to the CAM_* arrays.
_frigate_camera_wizard() {
while true; do
local ADD_CAM=""
if [ "${#CAM_NAME[@]}" -eq 0 ]; then
prompt_yn "Add a camera now? (y/n):" "y" ADD_CAM
else
prompt_yn "Add another camera? (y/n):" "n" ADD_CAM
fi
[[ "$ADD_CAM" =~ ^[Yy]$ ]] || break
local NC_NAME=""
prompt_text " Camera name (e.g. front_door):" "camera$((${#CAM_NAME[@]}+1))" NC_NAME
NC_NAME="$(echo "$NC_NAME" | tr '[:upper:] ' '[:lower:]_' | tr -cd 'a-z0-9_')"
NC_NAME="${NC_NAME:-camera$((${#CAM_NAME[@]}+1))}"
local NC_UPPER="${NC_NAME^^}"
local NC_IP=""
prompt_text " ${NC_NAME} RTSP IP address:" "" NC_IP
local NC_PORT=""
prompt_text " ${NC_NAME} RTSP port [554]:" "554" NC_PORT
local NC_USER=""
prompt_text " ${NC_NAME} RTSP username:" "admin" NC_USER
local NC_PASS=""
prompt_text " ${NC_NAME} RTSP password (blank = generate one):" "" NC_PASS
[ -z "$NC_PASS" ] && NC_PASS="$(generate_password 20)"
local NC_PATH=""
prompt_text " ${NC_NAME} RTSP path (use {sub} for main/sub-stream marker) [/cam/realmonitor?channel=1&subtype={sub}#backchannel=0]:" \
"/cam/realmonitor?channel=1&subtype={sub}#backchannel=0" NC_PATH
local NC_SUBSTREAM=""
prompt_yn " Add a lower-res sub-stream for detection? (y/n):" "y" NC_SUBSTREAM
local NC_ENABLED=""
prompt_yn " Enable ${NC_NAME} immediately? (y/n):" "y" NC_ENABLED
local NC_NOTIFY=""
prompt_yn " Enable notifications for ${NC_NAME}? (y/n):" "n" NC_NOTIFY
local _suffix_int; _suffix_int="$(_frigate_next_suffix_int)"
local IDX_SUFFIX=""; [ "$_suffix_int" -gt 0 ] && IDX_SUFFIX="$_suffix_int"
CAM_NAME+=("$NC_NAME")
CAM_PORT+=("$NC_PORT")
CAM_VARUSER+=("FRIGATE_RTSP_USER${IDX_SUFFIX}")
CAM_VARPASS+=("FRIGATE_RTSP_PASSWORD${IDX_SUFFIX}")
CAM_VARIP+=("FRIGATE_${NC_UPPER}_IP")
if [[ "$NC_SUBSTREAM" =~ ^[Yy]$ ]]; then CAM_HASSUB+=("true"); else CAM_HASSUB+=("false"); fi
CAM_MAIN_SUFFIX+=("${NC_PATH//\{sub\}/0}")
CAM_SUB_SUFFIX+=("${NC_PATH//\{sub\}/1}")
if [[ "$NC_ENABLED" =~ ^[Yy]$ ]]; then CAM_ENABLED+=("true"); else CAM_ENABLED+=("false"); fi
if [[ "$NC_NOTIFY" =~ ^[Yy]$ ]]; then CAM_NOTIFY+=("true"); else CAM_NOTIFY+=("false"); fi
CAM_CRED_USER+=("$NC_USER")
CAM_CRED_PASS+=("$NC_PASS")
CAM_CRED_IP+=("$NC_IP")
done
}
# Builds GO2RTC_BLOCK / CAMERAS_BLOCK / ENV_CAM_VARS from the CAM_* arrays.
_frigate_render_config() {
GO2RTC_BLOCK=""; CAMERAS_BLOCK=""; ENV_CAM_VARS=""
local i
for i in "${!CAM_NAME[@]}"; do
local nm="${CAM_NAME[$i]}" port="${CAM_PORT[$i]}"
local vu="${CAM_VARUSER[$i]}" vp="${CAM_VARPASS[$i]}" vi="${CAM_VARIP[$i]}"
local hassub="${CAM_HASSUB[$i]}" mainsuf="${CAM_MAIN_SUFFIX[$i]}" subsuf="${CAM_SUB_SUFFIX[$i]}"
local enabled="${CAM_ENABLED[$i]}" notify="${CAM_NOTIFY[$i]}"
local cu="${CAM_CRED_USER[$i]}" cp="${CAM_CRED_PASS[$i]}" ci="${CAM_CRED_IP[$i]}"
ENV_CAM_VARS="${ENV_CAM_VARS}${vu}=${cu}
${vp}=${cp}
${vi}=${ci}
"
GO2RTC_BLOCK="${GO2RTC_BLOCK} ${nm}:
- rtsp://{${vu}}:{${vp}}@{${vi}}:${port}${mainsuf}
"
local inputs_block
if [ "$hassub" = true ]; then
GO2RTC_BLOCK="${GO2RTC_BLOCK} ${nm}_sub:
- rtsp://{${vu}}:{${vp}}@{${vi}}:${port}${subsuf}
"
inputs_block=" - path: rtsp://127.0.0.1:8554/${nm}
input_args: preset-rtsp-restream
roles:
- record
- path: rtsp://127.0.0.1:8554/${nm}_sub
input_args: preset-rtsp-restream
roles:
- detect"
else
inputs_block=" - path: rtsp://127.0.0.1:8554/${nm}
input_args: preset-rtsp-restream
roles:
- detect
- record"
fi
GO2RTC_BLOCK="${GO2RTC_BLOCK}
"
CAMERAS_BLOCK="${CAMERAS_BLOCK} ${nm}:
enabled: ${enabled}
ffmpeg:
inputs:
${inputs_block}
detect:
enabled: true
width: 640
height: 480
fps: 5
notifications:
enabled: ${notify}
"
done
}
install_frigate() {
require_docker || return 1
local FRIGATE_DIR="$DOCKER_DIR/frigate"
local WEB_PORT="5000" RTSP_PORT="8554" WEBRTC_PORT="8555"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Frigate would:"
echo " - Create $FRIGATE_DIR with docker-compose.yml + .env + config/config.yml"
echo " - Auto-enable /dev/dri/renderD128 for GPU-assisted detection if present"
echo " - Expose ports 5000 (web), 8554 (RTSP restream), 8555 (WebRTC)"
echo " — all 3 auto-scanned/shifted together if occupied (5000 is also"
echo " changedetection's default)"
echo " - If already configured: show existing cameras and offer to keep,"
echo " back up + start fresh, add more, or remove some"
echo " - Prompt to add cameras interactively (RTSP creds go in .env)"
echo " or write a starter config.yml if none are added"
echo " - Offer a Caddy reverse proxy and to start the container"
echo " - If Authelia is installed: offer to protect Frigate with it —"
echo " disables Frigate's own login (auth.enabled: False) and pins a"
echo " proxy.auth_secret/X-Proxy-Secret handshake so only Caddy can"
echo " satisfy Frigate's proxy-auth trust"
return 0
fi
# Scan for free host ports, moving all 3 together — a plain install
# shouldn't silently claim a port another already-running service holds
# (changedetection.io also defaults to 5000). See CLAUDE.md's "Port
# collision avoidance" section. Internal go2rtc restream URLs elsewhere
# in this file (rtsp://127.0.0.1:8554/...) are container-internal
# loopback references, unaffected by the host-side port used here.
while port_in_use "$WEB_PORT" || port_in_use "$RTSP_PORT" \
|| port_in_use "$WEBRTC_PORT" || port_in_use "$WEBRTC_PORT" udp; do
WEB_PORT=$((WEB_PORT + 1))
RTSP_PORT=$((RTSP_PORT + 1))
WEBRTC_PORT=$((WEBRTC_PORT + 1))
done
mkdir -p "$FRIGATE_DIR"
ensure_docker_dir_ownership "$FRIGATE_DIR"
cd "$FRIGATE_DIR" || return 1
local CAM_NAME=() CAM_PORT=() CAM_VARUSER=() CAM_VARPASS=() CAM_VARIP=()
local CAM_HASSUB=() CAM_MAIN_SUFFIX=() CAM_SUB_SUFFIX=() CAM_ENABLED=() CAM_NOTIFY=()
local CAM_CRED_USER=() CAM_CRED_PASS=() CAM_CRED_IP=()
local FRIGATE_SKIP_WIZARD=false
declare -A ENV_MAP=()
if [ -f config/config.yml ] && [ -f .env ]; then
_frigate_parse_existing config/config.yml .env
if [ "${#CAM_NAME[@]}" -gt 0 ]; then
_frigate_review_existing
else
log_warning "Existing config.yml found but no cameras could be parsed from it."
local PROCEED_ANYWAY=""
prompt_yn " Back up existing config and continue with the wizard? (y/n):" "y" PROCEED_ANYWAY
if [[ "$PROCEED_ANYWAY" =~ ^[Yy]$ ]]; then
_frigate_backup_existing
else
log_info "Leaving existing config untouched."
return 0
fi
fi
fi
if [ "$FRIGATE_SKIP_WIZARD" = true ]; then
log_success "Keeping existing Frigate configuration unchanged ($DOCKER_DIR/frigate)."
else
local DEFAULT_MEDIA="${ENV_MAP[FRIGATE_MEDIA]:-$ACTUAL_HOME/frigate}"
local FRIGATE_MEDIA=""
prompt_text "Path for recordings/snapshots [$DEFAULT_MEDIA]:" "$DEFAULT_MEDIA" FRIGATE_MEDIA
FRIGATE_MEDIA="${FRIGATE_MEDIA/#\~/$ACTUAL_HOME}"; FRIGATE_MEDIA="${FRIGATE_MEDIA%/}"
local TZ_VAL; TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
# Hardware detection: include /dev/dri only when a render node exists
local DEVICE_BLOCK=""
if [ -e /dev/dri/renderD128 ]; then
DEVICE_BLOCK=" devices:
- /dev/dri/renderD128:/dev/dri/renderD128"
log_success "Render node found — enabling hardware-accelerated detection"
else
log_warning "No /dev/dri/renderD128 — Frigate will use CPU detection."
fi
# Mirrors configure_caddy_for_service's own mode resolution (lib/common.sh):
# explicit CADDY_MODE from the site config wins, then a local ~/docker/caddy,
# then the legacy CADDY_REMOTE_HOST var. Only "local" joins caddy_net — a
# remote Caddy box can't resolve container names on this host's bridge
# network anyway; it reaches this service via the host's published port.
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"
local _CADDY_NET_BLOCK=""
local _CADDY_NET_SECTION=""
if [ "$_CADDY_MODE" = "local" ]; then
_CADDY_NET_BLOCK=" networks:
- caddy_net
"
_CADDY_NET_SECTION="
networks:
caddy_net:
external: true
name: ${SITE_CADDY_NET:-caddy_net}
"
fi
backup_if_exists docker-compose.yml
cat > docker-compose.yml << FRIGATE_COMPOSE
name: frigate
services:
frigate:
image: ghcr.io/blakeblackshear/frigate:stable
container_name: frigate
hostname: frigate
restart: unless-stopped
privileged: true
shm_size: "256mb"
env_file:
- .env
environment:
- TZ=$TZ_VAL
$DEVICE_BLOCK
volumes:
- ./config:/config
- \${FRIGATE_MEDIA}:/media/frigate
- type: tmpfs
target: /tmp/cache
tmpfs:
size: 1000000000
ports:
- "${WEB_PORT}:5000"
- "${RTSP_PORT}:8554"
- "${WEBRTC_PORT}:8555/tcp"
- "${WEBRTC_PORT}:8555/udp"
${_CADDY_NET_BLOCK}${_CADDY_NET_SECTION}
FRIGATE_COMPOSE
mkdir -p config
mkdir -p "$FRIGATE_MEDIA"
# Authelia SSO — decided (and, if accepted, wired into Caddy) before
# config.yml is written, so the auth block baked into config.yml only
# ever reflects a gate that's actually in place (never "native login
# disabled, but nothing put in front of it instead"). Frigate has its
# own built-in login (username/password) separate from Authelia's —
# left alone it would show *after* Authelia's forward_auth already
# gated the domain: a redundant second login, and worse, a second
# session that can expire independently and force a re-login on its
# own schedule regardless of Authelia's "remember me" duration. The
# proxy.auth_secret/X-Proxy-Secret handshake (pinned into the Caddy
# reverse_proxy block) stops that trust from being spoofed by a
# request that reaches Frigate's published host port directly,
# bypassing Caddy/Authelia entirely.
# Whether to even OFFER this can't just check "$DOCKER_DIR/authelia"
# locally — Frigate's own box very often has no local Caddy at all
# (configure_caddy_for_service falls back to writing a snippet for
# a remote Caddy machine to pick up, confirmed live: this is the
# normal shape for a dedicated NVR box), in which case Authelia, if
# it exists anywhere, lives on THAT remote Caddy machine instead —
# a box this script has no filesystem access to inspect. Default to
# "y" only when local Authelia is actually confirmed; otherwise still
# offer it (default "n") and sort out local-vs-remote below once the
# admin says yes, rather than silently refusing to ask at all.
local FRIGATE_USE_AUTHELIA="n" FRIGATE_PROXY_SECRET="" AUTH_CONFIG_BLOCK=""
local _frigate_local_authelia="n"
[ -d "$DOCKER_DIR/authelia" ] && _frigate_local_authelia="y"
echo ""
prompt_yn "Protect Frigate with Authelia SSO (disables Frigate's own login)? (y/n):" "$_frigate_local_authelia" FRIGATE_USE_AUTHELIA
if [[ "$FRIGATE_USE_AUTHELIA" =~ ^[Yy]$ ]]; then
FRIGATE_PROXY_SECRET="${ENV_MAP[FRIGATE_PROXY_AUTH_SECRET]:-$(generate_password 32)}"
# Local Authelia snippet (import authelia) only actually exists
# in the Caddyfile it's imported into if Authelia is on THAT
# same machine. When it's on this box, that's this box's own
# Caddy — safe to assume. When Caddy itself turns out to be
# remote (below), "local" instead means "local to wherever
# Caddy is", which this script can't see — so ask, rather than
# silently emit an import that would fail Caddy's own reload
# with "file to import not found" on that other machine.
local _frigate_auth_block=" import authelia"
if [ "$_frigate_local_authelia" != "y" ]; then
echo ""
log_info "No local Authelia on this box — Caddy for Frigate may end up on a"
log_info "different machine (decided next)."
local _authelia_with_caddy=""
prompt_yn " Does Authelia run on that SAME machine as Caddy? (y/n):" "y" _authelia_with_caddy
if [[ ! "$_authelia_with_caddy" =~ ^[Yy]$ ]]; then
# Genuinely cross-machine: Authelia is a third box,
# different from both this one and wherever Caddy ends
# up. Needs the explicit header-pinned forward_auth form
# — see CLAUDE.md's "forward_auth to a remote Authelia"
# note for why the bare "authelia:9091" shortcut can't
# be used here and X-Forwarded-Host must be pinned
# explicitly (a second Caddy hop otherwise silently
# evaluates every domain as if it were auth's own
# portal domain — confirmed live, a real incident this
# exact snippet shape was written to prevent).
local _remote_authelia_domain=""
prompt_text " Authelia's own portal domain (e.g. authelia.example.com):" "" _remote_authelia_domain
if [ -n "$_remote_authelia_domain" ]; then
_frigate_auth_block=" forward_auth https://${_remote_authelia_domain} {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
header_up X-Forwarded-Method {method}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
header_up X-Forwarded-Uri {uri}
}"
else
log_warning "No domain entered — falling back to 'import authelia', which will fail"
log_warning "Caddy's reload unless Authelia is actually local to that Caddy machine."
fi
fi
fi
configure_caddy_for_service "Frigate" "frigate:5000" "frigate" \
"$_frigate_auth_block" \
" header_up X-Proxy-Secret ${FRIGATE_PROXY_SECRET}"
if [ "${CADDY_SERVICE_CONFIGURED:-false}" = true ]; then
AUTH_CONFIG_BLOCK="auth:
enabled: False # Authelia already gates the whole domain — its own login would be redundant
proxy:
auth_secret: \"{FRIGATE_PROXY_AUTH_SECRET}\" # must match the X-Proxy-Secret header Caddy sends
header_map:
user: remote-user
role: remote-groups
default_role: admin # anyone who passes Authelia gets full access, same as the disabled local login did
"
declare -F _authelia_scope_access >/dev/null 2>&1 && _authelia_scope_access "frigate" "$CADDY_SERVICE_DOMAIN"
else
log_warning "Caddy wasn't configured for Frigate — leaving Frigate's own login enabled (nothing else is gating access)."
FRIGATE_PROXY_SECRET=""
fi
else
configure_caddy_for_service "Frigate" "frigate:5000" "frigate"
fi
# Credentials/IPs go in .env as FRIGATE_* variables; Frigate substitutes
# any {FRIGATE_VAR} placeholder in config.yml from its container env at
# startup, so RTSP secrets never need to be typed into the YAML directly.
_frigate_camera_wizard
_frigate_render_config
if [ "${#CAM_NAME[@]}" -eq 0 ]; then
# No cameras entered — write a starter config the operator edits by hand.
cat > config/config.yml << FRIGATE_CONFIG
# Frigate Configuration — Docs: https://docs.frigate.video
#
# ⚠️ YOU MUST EDIT THIS FILE to add your cameras before starting Frigate.
${AUTH_CONFIG_BLOCK}mqtt:
enabled: false # Set to true and configure if you use Home Assistant
cameras:
# Example — replace with your camera details:
# front_door:
# ffmpeg:
# inputs:
# - path: rtsp://user:pass@192.168.1.100:554/stream
# roles: [detect, record]
# detect:
# width: 1280
# height: 720
# fps: 5
detectors:
default:
type: cpu # Change to 'edgetpu' for Coral TPU or 'openvino' for Intel GPU
record:
enabled: true
retain:
days: 7
mode: motion
snapshots:
enabled: true
retain:
default: 7
FRIGATE_CONFIG
else
cat > config/config.yml << FRIGATE_CONFIG
# Frigate Configuration — Docs: https://docs.frigate.video
#
# RTSP credentials/IPs come from .env — Frigate substitutes {FRIGATE_VAR}
# placeholders below from the container's environment at startup.
${AUTH_CONFIG_BLOCK}mqtt:
enabled: false # Set to true and configure if you use Home Assistant
go2rtc:
streams:
${GO2RTC_BLOCK}
cameras:
${CAMERAS_BLOCK}
detectors:
default:
type: cpu # Change to 'edgetpu' for Coral TPU or 'openvino' for Intel GPU
record:
enabled: true
retain:
days: 7
mode: motion
snapshots:
enabled: true
retain:
default: 7
FRIGATE_CONFIG
fi
backup_if_exists .env
cat > .env << FRIGATE_ENV
FRIGATE_MEDIA=$FRIGATE_MEDIA
CADDY_NET=$SITE_CADDY_NET
FRIGATE_PROXY_AUTH_SECRET=$FRIGATE_PROXY_SECRET
${ENV_CAM_VARS}
FRIGATE_ENV
chmod 600 .env
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FRIGATE_DIR"
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$FRIGATE_MEDIA" 2>/dev/null || true
log_success "Frigate configured at $FRIGATE_DIR"
local AUTH_README_SECTION=""
if [ -n "$AUTH_CONFIG_BLOCK" ]; then
AUTH_README_SECTION="
## Authelia SSO
Frigate's own login is disabled (\`auth.enabled: False\` in
\`config/config.yml\`) — Authelia gates the whole domain instead via Caddy's
\`import authelia\` plus a \`proxy.auth_secret\`/\`X-Proxy-Secret\` handshake
(the secret lives in \`.env\` as \`FRIGATE_PROXY_AUTH_SECRET\`) so that trust
can't be spoofed by a request that reaches Frigate's published port
directly, bypassing Caddy.
Everyone who passes Authelia gets full (admin) access to Frigate —
adjust \`config/config.yml\`'s \`proxy.role_map\`/\`default_role\` plus
Authelia's own group assignments if you want to give some users
view-only access instead.
To stop Authelia asking for a login again on repeat visits (e.g. from a
phone) for as long as possible, increase its \"remember me\" session
duration: \`sudo ./setup.sh authelia\` → \"Change 'remember me' session
duration\" (this affects every domain that instance protects, not just
Frigate).
"
fi
write_readme "$FRIGATE_DIR" << MD
# Frigate NVR
AI-powered network video recorder with real-time object detection for
security cameras. Detects people, cars, animals, and more.
- Web UI: http://localhost:${WEB_PORT}
- RTSP restream: port ${RTSP_PORT}
- WebRTC: port ${WEBRTC_PORT}
- Recordings: \`$FRIGATE_MEDIA\`
- Config: \`config/config.yml\` — cameras configured during install (${#CAM_NAME[@]} total)
- Credentials: \`.env\` — RTSP user/pass/IP per camera as FRIGATE_* variables
${AUTH_README_SECTION}
## Manage
\`\`\`bash
cd $FRIGATE_DIR
docker compose up -d # start
docker compose down # stop
docker compose logs -f # logs
docker compose pull && docker compose up -d # update
\`\`\`
## Adding/editing cameras later
Re-run the installer (\`sudo ./setup.sh frigate\`) — it detects your existing
config.yml/.env and lets you keep everything as-is, back up and start fresh,
add more cameras, or remove specific ones by number. Or edit
\`config/config.yml\` and \`.env\` by hand — camera credentials in config.yml
use Frigate's \`{FRIGATE_VAR}\` substitution syntax, resolved from \`.env\` at
container startup.
## First steps
1. Review \`config/config.yml\` — adjust detection zones, masks, retention
2. Start Frigate: \`docker compose up -d\`
3. Open http://localhost:${WEB_PORT} to view cameras and configure detection zones
## Hardware acceleration
- Intel/AMD GPU: uncomment the \`devices: [/dev/dri/renderD128]\` block
- Google Coral TPU: set \`detectors.default.type: edgetpu\` + add USB device
- Docs: https://docs.frigate.video/configuration/hardware_acceleration
MD
fi
echo ""
local START_DEFAULT="n"
if [ "${#CAM_NAME[@]}" -eq 0 ]; then
log_warning "No cameras added — edit config/config.yml to add camera RTSP streams before starting."
else
log_success "${#CAM_NAME[@]} camera(s) configured."
START_DEFAULT="y"
fi
echo ""
local START_FRIGATE=""
prompt_yn "Start Frigate now? (y/n):" "$START_DEFAULT" START_FRIGATE
if [ "$START_FRIGATE" = "y" ] || [ "$START_FRIGATE" = "Y" ]; then
docker compose up -d && log_success "Frigate started" || log_warning "Failed to start — check: docker compose logs"
fi
echo ""
echo " Access at: http://localhost:${WEB_PORT}"
echo " Config: $FRIGATE_DIR/config/config.yml"
echo ""
}
# Run immediately when executed directly (deferred until after function definition)
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_frigate