Files
ubuntu-post-install/services/frigate.sh
T
Claude 9e06ed4b83 Bake cross-service port collision avoidance into every service script
With 70+ services sharing a handful of common default ports (emby and
jellyfin both default to 8096, changedetection and frigate both default
to 5000, arm and nextcloud both default to 8080...), nothing previously
checked whether a service's default port was actually free on the host.
Whichever service installed second would silently write a compose file
claiming an already-held port, only failing at `docker compose up` time.

Adds two shared helpers to lib/common.sh:
- port_in_use PORT [PROTO] — true if something's already listening
- find_free_port VARNAME START [PROTO] — scans upward, writes back the
  first free port

Every service that publishes a fixed host port now scans before writing
docker-compose.yml, on every install (not just when adding an explicit
additional instance). On a normal single-install host this is a silent
no-op; it only changes behavior when something else already holds the
port.

- The 19 services already given multi-instance support this session had
  their port scan moved out of the "add instance" branch to run
  unconditionally, since the same collision risk exists on a plain first
  install.
- 20 more services with previously-hardcoded ports gained scanning for
  the first time: archivebox, arm, calibre-web, changedetection,
  drum-rhythm-game, gatus, n8n, nextcloud, onlyoffice, stirling-pdf,
  uptimekuma, portainer, iopaint (both GPU/CPU compose branches), koha
  (paired), syncthing (paired), wg-easy (paired, plus WG_PORT env so
  generated peer configs keep the right Endpoint), homeassistant
  (bridge-mode only — host mode can only warn), frigate and
  frigate-audio (multi-port stacks, moved together).
- caddy.sh is the deliberate exception: 80/443 stay fixed and only warn
  on collision, since silently moving Caddy itself would leave nothing
  listening where any client actually looks.
- authelia.sh needs no change — it has no published host port at all.
- Every service's standalone bootstrap fallback (sudo bash services/x.sh
  with no sibling files) got the same two helpers duplicated into its
  stub block, matching how every other shared helper is already handled
  there.

Documents the full pattern in CLAUDE.md's new "Port collision avoidance"
section, including the quoted-heredoc/backtick-escaping gotcha and the
network_mode:host limitation (can only scan ports the app takes as a
configurable env var).

Verified via bash -n on every changed file, plus functional runs seeding
occupied ports for each collision shape used here (single, paired,
multi-port stacks) and confirming the scan/shift and generated
compose/README output are correct — including the emby/jellyfin,
nextcloud/arm, and frigate/changedetection collision scenarios that
originally motivated this.
2026-08-09 23:55:49 +00:00

802 lines
32 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:-}"
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 _site_block
_site_block="$(cat << CBLOCK
# $_name
${_domain} {
reverse_proxy ${_block_upstream}
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
}
${_extra}
}
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"
}
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"
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
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"
# 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.
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.
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
cat > .env << FRIGATE_ENV
FRIGATE_MEDIA=$FRIGATE_MEDIA
CADDY_NET=$SITE_CADDY_NET
${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"
configure_caddy_for_service "Frigate" "frigate:5000" "frigate"
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
## 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