diff --git a/Configurations/master.conf b/Configurations/master.conf index 0aba428..3cde8a7 100644 --- a/Configurations/master.conf +++ b/Configurations/master.conf @@ -256,6 +256,14 @@ # HOST1_DISCORD_WEBHOOK / HOST2_DISCORD_WEBHOOK # Allows different webhooks per server, or only one server notifying. +# Upgrade webhook — standalone PHP listener that receives OnUpgrade events from +# Sonarr/Radarr/Lidarr and immediately pushes the upgraded file to all mesh nodes. +# Bypasses Unraid nginx auth — the secret in the URL is the only gate. +# Run Tools/webhook_setup.sh once to register the connection in each arr. +# WEBHOOK_PORT 0 disables the listener. + WEBHOOK_PORT=7821 + WEBHOOK_SECRET="cdbfde3c13468b7ae1cc1adc466b0e3db28592dec9d99c33689702ce0431e073" # auto-generated on first start if empty + # ============================================================================================== # ── GIT / REPO ──────────────────────────────────────────────────────────────────────────────── # ============================================================================================== @@ -294,6 +302,7 @@ "System_Essentials/inotify_tuning.sh" # bump inotify limits — containers miss events if exhausted "Docker_Essentials/docker_network_connect.sh" # ensure networks exist + connect containers "Tools/claude_startup.sh" # persist Claude data + binary to appdata; re-symlink on boot + "Media/start_webhook_listener.sh" # arr upgrade webhook listener — continuous "Fallback/fallback.sh" # mutual failover — continuous ) diff --git a/Deployment/master.conf.template b/Deployment/master.conf.template index 50bb4e9..59d37b6 100644 --- a/Deployment/master.conf.template +++ b/Deployment/master.conf.template @@ -254,6 +254,14 @@ # HOST1_DISCORD_WEBHOOK / HOST2_DISCORD_WEBHOOK # Allows different webhooks per server, or only one server notifying. +# Upgrade webhook — standalone PHP listener that receives OnUpgrade events from +# Sonarr/Radarr/Lidarr and immediately pushes the upgraded file to all mesh nodes. +# Bypasses Unraid nginx auth — the secret in the URL is the only gate. +# Run Tools/webhook_setup.sh once to register the connection in each arr. +# WEBHOOK_PORT 0 disables the listener. + WEBHOOK_PORT=7821 + WEBHOOK_SECRET="" # auto-generated on first start if empty + # ============================================================================================== # ── GIT / REPO ──────────────────────────────────────────────────────────────────────────────── # ============================================================================================== @@ -289,6 +297,7 @@ "unRAID_Essentials/php_fpm_max_children.sh" # WebGUI performance tuning "unRAID_Essentials/inotify_tuning.sh" # bump inotify limits — containers miss events if exhausted "Docker_Essentials/docker_network_connect.sh" # ensure networks exist + connect containers + "Media/start_webhook_listener.sh" # arr upgrade webhook listener — continuous "Fallback/fallback.sh" # mutual failover — continuous ) diff --git a/Media/start_webhook_listener.sh b/Media/start_webhook_listener.sh new file mode 100755 index 0000000..7a421d3 --- /dev/null +++ b/Media/start_webhook_listener.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# ============================================================================================== +# ======================= Upgrade Webhook Listener (continuous) ================================ +# ============================================================================================== +# +# PURPOSE +# ───────────────────────────────────────────────────────────────────────────── +# Starts a standalone PHP HTTP server that receives Sonarr/Radarr/Lidarr +# OnUpgrade webhooks and dispatches upgrade_webhook_handler.sh. +# +# Runs outside Unraid nginx — no session auth required. The shared secret in +# the webhook URL is the only gate. Arrs on this host call: +# +# http://:/webhook?key= +# +# Runs as a continuous script started by array_started.sh. Execs php -S which +# replaces this process — the PID stays the same for array_started.sh's check. +# +# If WEBHOOK_SECRET is empty in master.conf: generates and saves one, then starts. +# If WEBHOOK_PORT is 0: exits cleanly (disables the listener). +# +# ============================================================================================== + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ECOSYSTEM_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +source "$ECOSYSTEM_ROOT/load_config.sh" + +[[ "${WEBHOOK_PORT:-0}" -eq 0 ]] && { + echo "[webhook] WEBHOOK_PORT=0 — listener disabled" + exit 0 +} + +# ── Auto-generate secret if not yet set ───────────────────────────────────── +if [[ -z "${WEBHOOK_SECRET:-}" ]]; then + GENERATED=$(openssl rand -hex 32) + MASTER_CONF="$ECOSYSTEM_ROOT/Configurations/master.conf" + sed -i "s/WEBHOOK_SECRET=\"\"/WEBHOOK_SECRET=\"$GENERATED\"/" "$MASTER_CONF" + WEBHOOK_SECRET="$GENERATED" + echo "[webhook] Generated WEBHOOK_SECRET — run Tools/webhook_setup.sh to register in arrs" +fi + +mkdir -p /var/log/varaverk + +exec node "$ECOSYSTEM_ROOT/Media/webhook_listener.js" \ + "$WEBHOOK_PORT" "$WEBHOOK_SECRET" "$ECOSYSTEM_ROOT" \ + >> /var/log/varaverk/upgrade_webhook.log 2>&1 diff --git a/Media/webhook_listener.js b/Media/webhook_listener.js new file mode 100644 index 0000000..a032c53 --- /dev/null +++ b/Media/webhook_listener.js @@ -0,0 +1,108 @@ +#!/usr/bin/env node +// Standalone arr upgrade webhook listener. +// Started by start_webhook_listener.sh via `node webhook_listener.js`. +// Lives outside Unraid nginx — no session auth. Secret in URL is the only gate. +// +// Usage: node webhook_listener.js + +'use strict'; + +const http = require('http'); +const { exec } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +const [,, port, secret, scriptsDir] = process.argv; + +if (!port || !secret || !scriptsDir) { + process.stderr.write('Usage: webhook_listener.js \n'); + process.exit(1); +} + +const LOG_FILE = '/var/log/varaverk/upgrade_webhook.log'; +const HANDLER = path.join(scriptsDir, 'Media', 'upgrade_webhook_handler.sh'); + +function log(msg) { + const ts = new Date().toTimeString().slice(0, 8); + const line = `[${ts}] ${msg}\n`; + fs.appendFile(LOG_FILE, line, () => {}); +} + +function send(res, code, obj) { + const body = JSON.stringify(obj); + res.writeHead(code, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }); + res.end(body); +} + +const server = http.createServer((req, res) => { + const url = new URL(req.url, `http://localhost`); + const key = url.searchParams.get('key'); + + if (key !== secret) { + send(res, 403, { ok: false, error: 'Forbidden' }); + return; + } + + if (req.method !== 'POST') { + send(res, 405, { ok: false, error: 'POST only' }); + return; + } + + let body = ''; + req.on('data', chunk => { body += chunk; }); + req.on('end', () => { + let payload; + try { payload = JSON.parse(body); } + catch (_) { send(res, 400, { ok: false, error: 'Invalid JSON' }); return; } + + const event = payload.eventType ?? ''; + const isUpgrade = payload.isUpgrade ?? false; + + if (event === 'Test') { + send(res, 200, { ok: true, message: 'Webhook connected — upgrade propagation active' }); + return; + } + + if (event !== 'Download' || !isUpgrade) { + send(res, 200, { ok: true, skipped: event }); + return; + } + + let arrType, itemPath; + if (payload.series?.path) { + arrType = 'sonarr'; + itemPath = payload.series.path; + } else if (payload.movie?.folderPath) { + arrType = 'radarr'; + itemPath = payload.movie.folderPath; + } else if (payload.artist?.path) { + arrType = 'lidarr'; + itemPath = payload.artist.path; + } else { + send(res, 400, { ok: false, error: 'Unrecognised payload structure' }); + return; + } + + if (!itemPath || !itemPath.startsWith('/') || itemPath.includes('..') || /[\x00\n\r]/.test(itemPath)) { + send(res, 400, { ok: false, error: 'Unsafe path' }); + return; + } + + log(`Upgrade: ${arrType} — ${path.basename(itemPath)}`); + send(res, 200, { ok: true, arr: arrType, path: itemPath }); + + const cmd = `bash ${JSON.stringify(HANDLER)} ${JSON.stringify(arrType)} ${JSON.stringify(itemPath)}`; + const out = fs.createWriteStream(LOG_FILE, { flags: 'a' }); + exec(cmd, { stdio: ['ignore', out, out] }); + }); +}); + +server.listen(parseInt(port, 10), '0.0.0.0', () => { + log(`Webhook listener started on port ${port}`); + process.stdout.write(`[webhook] Listening on port ${port}\n`); +}); + +server.on('error', err => { + process.stderr.write(`[webhook] Server error: ${err.message}\n`); + process.exit(1); +}); diff --git a/Tools/webhook_setup.sh b/Tools/webhook_setup.sh new file mode 100755 index 0000000..41b71de --- /dev/null +++ b/Tools/webhook_setup.sh @@ -0,0 +1,172 @@ +#!/bin/bash +# ============================================================================================== +# ========================= Upgrade Webhook Setup ============================================= +# ============================================================================================== +# +# PURPOSE +# ───────────────────────────────────────────────────────────────────────────── +# Registers the Varaverk upgrade webhook in Sonarr, Radarr, and Lidarr via +# their notification APIs. Idempotent — skips any arr that already has it. +# +# Run once after first install, or any time you add a new arr or host. +# The listener (start_webhook_listener.sh) must be running before arrs will +# actually deliver events, but this script can register the connection first. +# +# ── WHAT IT DOES ───────────────────────────────────────────────────────────── +# 1. Generates WEBHOOK_SECRET in master.conf if empty +# 2. Registers webhook in each local arr (Sonarr / Radarr / Lidarr) +# 3. SSHes to remote host and runs itself there (unless --local-only) +# +# ── USAGE ──────────────────────────────────────────────────────────────────── +# webhook_setup.sh — configure local + remote +# webhook_setup.sh --local-only — local arrs only (used internally for SSH) +# webhook_setup.sh --dry-run — show what would be registered +# +# ============================================================================================== + +set -uo pipefail + +LOCAL_ONLY=false +DRY_RUN=false + +for arg in "$@"; do + case "$arg" in + --local-only) LOCAL_ONLY=true ;; + --dry-run) DRY_RUN=true ;; + esac +done + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ECOSYSTEM_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +MASTER_CONF="$ECOSYSTEM_ROOT/Configurations/master.conf" + +source "$ECOSYSTEM_ROOT/load_config.sh" +detect_hosts + +WEBHOOK_NAME="Varaverk Upgrade" + +# ── Resolve or generate the secret ────────────────────────────────────────── +# OVERRIDE_SECRET env var is set when called recursively via SSH from the +# primary host, so both ends use the same secret. +if [[ -n "${OVERRIDE_SECRET:-}" ]]; then + if [[ -z "${WEBHOOK_SECRET:-}" ]]; then + sed -i "s/WEBHOOK_SECRET=\"\"/WEBHOOK_SECRET=\"$OVERRIDE_SECRET\"/" "$MASTER_CONF" + fi + WEBHOOK_SECRET="$OVERRIDE_SECRET" +fi + +if [[ -z "${WEBHOOK_SECRET:-}" ]]; then + if [[ "$DRY_RUN" == true ]]; then + WEBHOOK_SECRET="" + else + GENERATED=$(openssl rand -hex 32) + sed -i "s/WEBHOOK_SECRET=\"\"/WEBHOOK_SECRET=\"$GENERATED\"/" "$MASTER_CONF" + WEBHOOK_SECRET="$GENERATED" + echo "Generated WEBHOOK_SECRET — saved to master.conf" + fi +fi + +LOCAL_IP=$(hostname -I | awk '{print $1}') +WEBHOOK_URL="http://${LOCAL_IP}:${WEBHOOK_PORT}/webhook?key=${WEBHOOK_SECRET}" + +# ── Helper: register webhook in one arr ───────────────────────────────────── +_register() { + local label="$1" base_url="$2" api_key="$3" api_ver="$4" + local import_field="$5" # onDownload (Sonarr/Radarr) or onReleaseImport (Lidarr) + + if [[ "$DRY_RUN" == true ]]; then + echo " [$label] Would register → $WEBHOOK_URL" + return 0 + fi + + # Check if already registered by name + local existing + existing=$(curl -sf --max-time 5 \ + -H "X-Api-Key: $api_key" \ + "$base_url/api/$api_ver/notification" 2>/dev/null) || { + echo " [$label] Cannot reach arr — skipping" + return 1 + } + + if echo "$existing" | grep -q "\"name\":[[:space:]]*\"${WEBHOOK_NAME}\""; then + echo " [$label] Already registered — skipping" + return 0 + fi + + local payload + payload=$(printf '{ + "name": "%s", + "implementation": "Webhook", + "configContract": "WebhookSettings", + "onGrab": false, + "%s": true, + "onUpgrade": true, + "onRename": false, + "onHealthIssue": false, + "includeHealthWarnings": false, + "onApplicationUpdate": false, + "tags": [], + "fields": [ + {"name": "url", "value": "%s"}, + {"name": "method", "value": 1}, + {"name": "username", "value": ""}, + {"name": "password", "value": ""} + ] +}' "$WEBHOOK_NAME" "$import_field" "$WEBHOOK_URL") + + local http_code + http_code=$(curl -sf --max-time 5 -o /dev/null -w '%{http_code}' \ + -X POST \ + -H "X-Api-Key: $api_key" \ + -H 'Content-Type: application/json' \ + -d "$payload" \ + "$base_url/api/$api_ver/notification" 2>/dev/null) + + if [[ "$http_code" == "201" || "$http_code" == "200" ]]; then + echo " [$label] Registered ✅ → $WEBHOOK_URL" + else + echo " [$label] Failed (HTTP ${http_code:-timeout})" + return 1 + fi +} + +# ── Local arrs ─────────────────────────────────────────────────────────────── +echo "" +echo "━━━ Upgrade Webhook Setup — $MY_ID ($LOCAL_SERVER_NAME) ━━━" +echo " LAN IP : $LOCAL_IP" +echo " Port : $WEBHOOK_PORT" +echo " URL : $WEBHOOK_URL" +echo "" + +[[ -n "${SONARR_URL:-}" && -n "${SONARR_API_KEY:-}" ]] && \ + _register "Sonarr" "$SONARR_URL" "$SONARR_API_KEY" "v3" "onDownload" + +[[ -n "${RADARR_URL:-}" && -n "${RADARR_API_KEY:-}" ]] && \ + _register "Radarr" "$RADARR_URL" "$RADARR_API_KEY" "v3" "onDownload" + +[[ -n "${LIDARR_URL:-}" && -n "${LIDARR_API_KEY:-}" ]] && \ + _register "Lidarr" "$LIDARR_URL" "$LIDARR_API_KEY" "v1" "onReleaseImport" + +# ── Remote host ─────────────────────────────────────────────────────────────── +if [[ "$LOCAL_ONLY" == false && -n "${REMOTE_ID:-}" ]]; then + echo "" + echo "━━━ Configuring remote: $REMOTE_SERVER_NAME ━━━" + + remote_ip=$(resolve_tailscale_ip "$REMOTE_SERVER_NAME") || { + echo " Cannot resolve Tailscale IP for $REMOTE_SERVER_NAME — skipping" + echo " Run manually on that host: Tools/webhook_setup.sh --local-only" + echo "" + exit 0 + } + + remote_flags="--local-only" + [[ "$DRY_RUN" == true ]] && remote_flags="$remote_flags --dry-run" + + ssh -i "$SSH_KEY" -o ConnectTimeout=10 -o StrictHostKeyChecking=no \ + root@"$remote_ip" \ + "OVERRIDE_SECRET='$WEBHOOK_SECRET' bash /boot/config/plugins/varaverk/Tools/webhook_setup.sh $remote_flags" \ + || echo " Remote setup failed — check SSH and that Varaverk is installed on $REMOTE_SERVER_NAME" +fi + +echo "" +echo "Done."