BirdWatch-Relay/setup.sh
2026-05-03 22:47:38 -05:00

342 lines
10 KiB
Bash

#!/usr/bin/env bash
# SPDX-License-Identifier: AGPL-3.0-or-later
#
# birdwatch-relay interactive setup. Run on the NVR (the same Linux machine
# that runs Frigate). Asks the questions you need to answer once, generates
# secrets, writes config, builds the image, starts the container, and prints
# the exact strings to paste into the BirdWatch app.
set -euo pipefail
BOLD='\033[1m'
DIM='\033[2m'
GREEN='\033[32m'
RED='\033[31m'
YELLOW='\033[33m'
RESET='\033[0m'
err() { printf "${RED}error:${RESET} %s\n" "$*" >&2; }
warn() { printf "${YELLOW}warn:${RESET} %s\n" "$*"; }
ok() { printf "${GREEN}ok:${RESET} %s\n" "$*"; }
ask() { printf "${BOLD}%s${RESET}" "$*"; }
hr() { printf "${DIM}--------------------------------------------------------${RESET}\n"; }
echo
printf "${BOLD}BirdWatch Relay — interactive setup${RESET}\n"
hr
echo
cd "$(dirname "$0")"
# --- Pre-flight ----------------------------------------------------------
if ! command -v docker >/dev/null 2>&1; then
err "Docker is not installed. Install Docker first:"
err " https://docs.docker.com/engine/install/"
exit 1
fi
if docker compose version >/dev/null 2>&1; then
DC="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
DC="docker-compose"
else
err "Docker Compose v2 is required. Install: https://docs.docker.com/compose/install/"
exit 1
fi
if ! command -v openssl >/dev/null 2>&1; then
err "openssl is required (used to generate the pairing code)."
exit 1
fi
if ! command -v curl >/dev/null 2>&1; then
err "curl is required (used to verify the relay is healthy)."
exit 1
fi
if ! command -v ss >/dev/null 2>&1; then
err "ss is required (install iproute2)."
exit 1
fi
ok "Docker: $(docker --version)"
ok "Compose: $($DC version --short 2>/dev/null || $DC --version)"
echo
# is_port_free returns 0 if no process is listening on $1, non-zero otherwise.
is_port_free() {
! ss -tlnH 2>/dev/null | awk '{print $4}' | grep -qE ":$1$"
}
# find_free_port walks upward from $1 to find a port nothing is listening on.
find_free_port() {
local p=$1
while [ "$p" -le 65535 ]; do
if is_port_free "$p"; then
echo "$p"
return 0
fi
p=$((p + 1))
done
return 1
}
# --- Step 1: Firebase service-account key --------------------------------
printf "${BOLD}Step 1 of 5 — Firebase service-account key${RESET}\n"
hr
cat <<'EOF'
The relay needs a Firebase service-account JSON file. This is a small file you
download from Google's Firebase Console — it lets the relay send pushes via
Firebase Cloud Messaging.
If you don't have one yet:
1. Open https://console.firebase.google.com
2. Click "Add project". Name it (e.g. "birdwatch-home"). Skip Analytics.
3. Once created, gear icon → "Project settings" → "Service accounts".
4. Click "Generate new private key" → confirm. A .json file downloads.
5. Move that file to this NVR.
Paste the file's full path below.
EOF
echo
while true; do
ask "Path to Firebase service-account JSON file: "
read -r FB_KEY_PATH
FB_KEY_PATH="${FB_KEY_PATH/#\~/$HOME}"
if [ -z "$FB_KEY_PATH" ]; then
err "Path cannot be empty."
continue
fi
if [ ! -f "$FB_KEY_PATH" ]; then
err "File not found: $FB_KEY_PATH"
continue
fi
if ! grep -q '"type"' "$FB_KEY_PATH" 2>/dev/null || \
! grep -q '"private_key"' "$FB_KEY_PATH" 2>/dev/null; then
err "That doesn't look like a Firebase service-account JSON."
continue
fi
break
done
cp "$FB_KEY_PATH" ./firebase-key.json
chmod 600 ./firebase-key.json
ok "Copied to ./firebase-key.json (chmod 600)."
echo
# --- Step 2: relay port + public URL -------------------------------------
printf "${BOLD}Step 2 of 5 — Network${RESET}\n"
hr
PREFERRED_PORT=8080
if is_port_free $PREFERRED_PORT; then
DEFAULT_PORT=$PREFERRED_PORT
ok "Port $PREFERRED_PORT is free on this machine."
else
SUGGESTIONS=()
p=$((PREFERRED_PORT + 1))
while [ ${#SUGGESTIONS[@]} -lt 5 ] && [ $p -le 65535 ]; do
if is_port_free $p; then
SUGGESTIONS+=("$p")
fi
p=$((p + 1))
done
if [ ${#SUGGESTIONS[@]} -eq 0 ]; then
err "Could not find any free port at or above $PREFERRED_PORT."
exit 1
fi
warn "Port $PREFERRED_PORT is already in use on this machine."
echo "Available nearby ports: ${SUGGESTIONS[*]}"
DEFAULT_PORT=${SUGGESTIONS[0]}
fi
echo
cat <<EOF
Which port should the relay listen on?
- Press ENTER to accept the suggested port: $DEFAULT_PORT
- Or type a different number (1-65535) and press ENTER.
EOF
ask "Port [$DEFAULT_PORT]: "
read -r RELAY_PORT
RELAY_PORT="${RELAY_PORT:-$DEFAULT_PORT}"
if ! [[ "$RELAY_PORT" =~ ^[0-9]+$ ]] || [ "$RELAY_PORT" -lt 1 ] || [ "$RELAY_PORT" -gt 65535 ]; then
err "Invalid port: $RELAY_PORT (must be a number between 1 and 65535)."
exit 1
fi
if ! is_port_free "$RELAY_PORT"; then
err "Port $RELAY_PORT is already in use. Pick another and re-run."
exit 1
fi
ok "Will use port $RELAY_PORT."
echo
cat <<EOF
What URL will the BirdWatch app on your phone use to reach the relay?
Examples:
https://relay.example.com if you have a TLS reverse proxy in front
http://192.168.1.50:$RELAY_PORT if it's LAN-only with no proxy
Type the FULL URL with scheme (http or https), no trailing slash. If you have
a proxy in front (recommended for any remote access), the proxy's URL is what
phones use — not this machine's port-mapped URL.
EOF
DEFAULT_LANIP="$(hostname -I 2>/dev/null | awk '{print $1}')"
DEFAULT_PUB_URL="http://${DEFAULT_LANIP:-192.168.1.x}:$RELAY_PORT"
ask "Public URL [$DEFAULT_PUB_URL]: "
read -r RELAY_BASE_URL
RELAY_BASE_URL="${RELAY_BASE_URL:-$DEFAULT_PUB_URL}"
if [[ ! "$RELAY_BASE_URL" =~ ^https?://[^[:space:]]+$ ]]; then
err "Public URL must start with http:// or https://"
exit 1
fi
RELAY_BASE_URL="${RELAY_BASE_URL%/}"
ok "Phones will connect to: $RELAY_BASE_URL"
echo
# --- Step 3: Frigate connection ------------------------------------------
printf "${BOLD}Step 3 of 5 — Frigate${RESET}\n"
hr
cat <<EOF
The relay subscribes to Frigate's /ws WebSocket as a long-lived event consumer.
It needs your Frigate base URL and (if Frigate has auth enabled) a username
and password to log in.
Recommended: create a dedicated Frigate user just for the relay (e.g.
"birdwatch-relay") in Frigate's Auth UI. Limits blast radius if these
credentials are exposed.
EOF
DEFAULT_FRIGATE_URL="http://localhost:5000"
ask "Frigate base URL [$DEFAULT_FRIGATE_URL]: "
read -r FRIGATE_URL
FRIGATE_URL="${FRIGATE_URL:-$DEFAULT_FRIGATE_URL}"
if [[ ! "$FRIGATE_URL" =~ ^https?://[^[:space:]]+$ ]]; then
err "Frigate URL must start with http:// or https://"
exit 1
fi
FRIGATE_URL="${FRIGATE_URL%/}"
ask "Frigate username (leave blank if Frigate auth is disabled): "
read -r FRIGATE_USER
FRIGATE_PASSWORD=""
if [ -n "$FRIGATE_USER" ]; then
ask "Frigate password (input hidden): "
read -rs FRIGATE_PASSWORD
echo
if [ -z "$FRIGATE_PASSWORD" ]; then
err "Password cannot be empty when a username is set."
exit 1
fi
fi
ok "Frigate URL: $FRIGATE_URL (user: ${FRIGATE_USER:-<none>})"
echo
# --- Step 4: pairing code ------------------------------------------------
printf "${BOLD}Step 4 of 5 — Pairing code${RESET}\n"
hr
PAIRING_CODE=$(openssl rand -hex 3)
ok "Pairing code generated."
echo
# --- Step 5: write config + build + start --------------------------------
printf "${BOLD}Step 5 of 5 — Write config and start${RESET}\n"
hr
if [ -f .env ]; then
warn "Existing .env will be backed up to .env.bak."
mv .env .env.bak
fi
cat > .env <<EOF
# Generated by setup.sh on $(date -u +"%Y-%m-%dT%H:%M:%SZ").
# Keep this file out of git and off public storage.
RELAY_PAIRING_CODE=$PAIRING_CODE
FRIGATE_URL=$FRIGATE_URL
FRIGATE_USER=$FRIGATE_USER
FRIGATE_PASSWORD=$FRIGATE_PASSWORD
EOF
chmod 600 .env
if [ -f docker-compose.yml ]; then
warn "Existing docker-compose.yml will be backed up to docker-compose.yml.bak."
mv docker-compose.yml docker-compose.yml.bak
fi
HOST_UID=$(id -u)
HOST_GID=$(id -g)
cat > docker-compose.yml <<EOF
services:
relay:
build: .
container_name: birdwatch-relay
restart: unless-stopped
user: "$HOST_UID:$HOST_GID"
network_mode: host
volumes:
- ./data:/data
- ./firebase-key.json:/secrets/firebase-key.json:ro
environment:
RELAY_FIREBASE_KEY_PATH: /secrets/firebase-key.json
RELAY_PAIRING_CODE: \${RELAY_PAIRING_CODE}
RELAY_DB_PATH: /data/relay.db
RELAY_PORT: "$RELAY_PORT"
FRIGATE_URL: \${FRIGATE_URL}
FRIGATE_USER: \${FRIGATE_USER}
FRIGATE_PASSWORD: \${FRIGATE_PASSWORD}
EOF
ok "Wrote .env and docker-compose.yml."
mkdir -p ./data
echo
echo "Building image (first build downloads Go and dependencies, ~1 minute)..."
$DC up -d --build
echo
echo -n "Waiting for relay to come up"
for i in $(seq 1 30); do
if curl -sf "http://localhost:$RELAY_PORT/v1/health" >/dev/null 2>&1; then
echo " — ok"
break
fi
echo -n "."
sleep 1
if [ "$i" -eq 30 ]; then
echo
err "Relay did not become healthy in 30 seconds."
err "Check logs: $DC logs relay"
exit 1
fi
done
# --- Done -----------------------------------------------------------------
echo
printf "${GREEN}========================================${RESET}\n"
printf "${GREEN}Relay is running and connected to Frigate.${RESET}\n"
printf "${GREEN}========================================${RESET}\n"
echo
echo "One thing left to do — pair your phone:"
echo
printf "${BOLD}In the BirdWatch Android app${RESET}\n"
echo " Settings → \"Battery-saver push notifications\" → toggle ON."
echo " When asked, paste:"
echo " Relay URL: $RELAY_BASE_URL"
echo " Pairing code: $PAIRING_CODE"
echo
echo "(No Frigate config edit is needed. The relay logs into Frigate as $FRIGATE_USER"
echo "and subscribes to its event stream directly.)"
echo
hr
echo "Useful commands:"
echo " Watch logs: $DC logs -f relay"
echo " Restart relay: $DC restart relay"
echo " Stop relay: $DC down"
echo " Re-run setup: ./setup.sh"
echo
echo "Files written by this setup (keep them private — never commit them):"
echo " ./firebase-key.json — your Firebase service-account key"
echo " ./.env — pairing code + Frigate credentials"
echo " ./docker-compose.yml — runtime config"
echo " ./data/ — SQLite database of registered devices"
echo