342 lines
10 KiB
Bash
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
|