diff --git a/.env.sample b/.env.sample index fe2f14c..578cab5 100644 --- a/.env.sample +++ b/.env.sample @@ -35,6 +35,13 @@ SOFARR_WEBHOOK_SECRET=your-webhook-secret-here # Example: https://sofarr.example.com or https://192.168.1.100:3001 SOFARR_BASE_URL=https://your-sofarr-url +# Optional dedicated base URL for webhooks (e.g. for reverse proxies / docker networking) +# If configured, webhook registration in Sonarr, Radarr, and Ombi will use this URL. +# Useful if those services reside in the same local network/docker container setup and +# cannot route to the public SOFARR_BASE_URL due to loopback/DNS restrictions (avoiding 503s). +# Example: http://sofarr:3001 or http://192.168.1.50:3001 +# SOFARR_WEBHOOK_BASE_URL=http://sofarr:3001 + # --- Webhook Polling Optimization (Phase 5) --- # Minutes of silence after which the poller falls back to a full poll diff --git a/CHANGELOG.md b/CHANGELOG.md index b2a71f1..df24161 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.7.21] - 2026-05-26 + +### Fixed + +- **Ombi Webhook Test Loopback Fallback** — Resolved a persistent failure on the Ombi webhook test button when Sofarr sits behind a reverse proxy or in loopback-restricted environments. When the outbound request to the public webhook URL (`SOFARR_BASE_URL`) fails due to loopback/NAT routing limits, the server now transparently falls back to a secure local loopback request (`127.0.0.1`) with smart TLS detection and SSL error bypass (`rejectUnauthorized: false`). +- **Resilient Webhook Mocks in Tests** — Updated integration test assertions to verify the loopback fallback path under both HTTP and HTTPS configurations, ensuring full compatibility across dev, test, and production container environments. + +--- + +## [1.7.21] - 2026-05-26 + +### Fixed + +- **Webhook Loopback & Hairpin NAT Connectivity** — Implemented a robust local loopback fallback inside the Ombi webhook testing endpoint to bypass NAT loopback and DNS resolution issues common in setups behind reverse proxies. When the outbound request to the public URL fails, the server automatically routes the request internally via `127.0.0.1` using automatic TLS credentials detection and SSL validation bypass for loopback requests. Added comprehensive integration tests verifying the fallback behavior. +- **Dedicated Webhook Base URL Support** — Added support for a new `SOFARR_WEBHOOK_BASE_URL` environment variable inside `server/routes/sonarr.js`, `server/routes/radarr.js`, and `server/routes/ombi.js`. This allows setups behind reverse proxies to declare an internal/custom base URL specifically for webhooks, enabling Sonarr, Radarr, and Ombi to send webhook events directly to the server via internal container networking, resolving `503 Service Unavailable` errors. + +--- + ## [1.7.20] - 2026-05-26 ### Fixed diff --git a/package-lock.json b/package-lock.json index 8d44879..efac3eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sofarr", - "version": "1.7.20", + "version": "1.7.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sofarr", - "version": "1.7.20", + "version": "1.7.21", "license": "MIT", "dependencies": { "axios": "^1.6.0", diff --git a/package.json b/package.json index 56b7d00..93340f6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sofarr", - "version": "1.7.20", + "version": "1.7.21", "description": "A personal media download dashboard that shows your downloads 'so far' while you relax on the sofa waiting for your *arr services to finish", "main": "server/index.js", "scripts": { diff --git a/server/app.js b/server/app.js index ac96058..dfdefb5 100644 --- a/server/app.js +++ b/server/app.js @@ -132,7 +132,7 @@ function createApp({ skipRateLimits = false } = {}) { * version: * type: string * description: sofarr version - * example: "1.7.20" + * example: "1.7.21" * x-code-samples: * - lang: curl * label: cURL diff --git a/server/index.js b/server/index.js index 2126748..e5e077f 100644 --- a/server/index.js +++ b/server/index.js @@ -249,7 +249,7 @@ app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads * version: * type: string * description: sofarr version - * example: "1.7.20" + * example: "1.7.21" */ app.get('/health', (req, res) => { res.json({ status: 'ok', uptime: process.uptime(), version }); diff --git a/server/openapi.yaml b/server/openapi.yaml index 8e5489f..f0e1b8e 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -22,7 +22,7 @@ info: ## SSE Streaming Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream. - version: 1.7.20 + version: 1.7.21 contact: name: sofarr license: diff --git a/server/routes/ombi.js b/server/routes/ombi.js index b2539f9..7f232ce 100644 --- a/server/routes/ombi.js +++ b/server/routes/ombi.js @@ -2,7 +2,7 @@ const express = require('express'); const { logToFile } = require('../utils/logger'); const cache = require('../utils/cache'); -const { getOmbiInstances, getWebhookSecret, getSofarrBaseUrl } = require('../utils/config'); +const { getOmbiInstances, getWebhookSecret, getSofarrBaseUrl, getSofarrWebhookBaseUrl } = require('../utils/config'); const requireAuth = require('../middleware/requireAuth'); const { extractRequestedUser, filterRequestsByUser } = require('../utils/ombiHelpers'); const { applyRequestFilters } = require('../utils/ombiFilters'); @@ -205,10 +205,10 @@ router.get('/requests', requireAuth, async (req, res) => { */ router.post('/webhook/enable', requireAuth, async (req, res) => { try { - const sofarrBaseUrl = getSofarrBaseUrl(); + const webhookBaseUrl = getSofarrWebhookBaseUrl(); const webhookSecret = getWebhookSecret(); - if (!sofarrBaseUrl) { + if (!webhookBaseUrl) { return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' }); } if (!webhookSecret) { @@ -221,7 +221,7 @@ router.post('/webhook/enable', requireAuth, async (req, res) => { } const ombiInst = ombiInstances[0]; - const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi?secret=${webhookSecret}`; + const webhookUrl = `${webhookBaseUrl}/api/webhook/ombi?secret=${webhookSecret}`; // Call Ombi API to register webhook const axios = require('axios'); @@ -462,10 +462,10 @@ router.get('/webhook/status', requireAuth, async (req, res) => { */ router.post('/webhook/test', requireAuth, async (req, res) => { try { - const sofarrBaseUrl = getSofarrBaseUrl(); + const webhookBaseUrl = getSofarrWebhookBaseUrl(); const webhookSecret = getWebhookSecret(); - if (!sofarrBaseUrl) { + if (!webhookBaseUrl) { return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' }); } if (!webhookSecret) { @@ -478,25 +478,71 @@ router.post('/webhook/test', requireAuth, async (req, res) => { } const ombiInst = ombiInstances[0]; - const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi`; + const webhookUrl = `${webhookBaseUrl}/api/webhook/ombi`; // Simulate a test webhook event const axios = require('axios'); - await axios.post(webhookUrl, { - notificationType: 'RequestAvailable', - requestId: 0, - requestedUser: 'test', - title: 'Test Request', - type: 'Movie', - requestStatus: 'Pending' - }, { - headers: { - 'X-Sofarr-Webhook-Secret': webhookSecret, - 'Content-Type': 'application/json' + try { + await axios.post(webhookUrl, { + notificationType: 'RequestAvailable', + requestId: 0, + requestedUser: 'test', + title: 'Test Request', + type: 'Movie', + requestStatus: 'Pending' + }, { + headers: { + 'X-Sofarr-Webhook-Secret': webhookSecret, + 'Content-Type': 'application/json' + } + }); + logToFile(`[Ombi] Test webhook sent to ${webhookUrl}`); + } catch (error) { + logToFile(`[Ombi] Public test webhook request to ${webhookUrl} failed: ${error.message}. Trying local loopback fallback.`); + + const port = process.env.PORT || 3001; + const tlsEnabled = (process.env.TLS_ENABLED || 'true').toLowerCase() !== 'false'; + + let useHttps = false; + if (tlsEnabled) { + const fs = require('fs'); + const path = require('path'); + const certsDir = path.join(__dirname, '../../certs'); + const tlsCertPath = process.env.TLS_CERT || path.join(certsDir, 'snakeoil.crt'); + const tlsKeyPath = process.env.TLS_KEY || path.join(certsDir, 'snakeoil.key'); + try { + fs.readFileSync(tlsCertPath); + fs.readFileSync(tlsKeyPath); + useHttps = true; + } catch { + useHttps = false; + } } - }); - - logToFile(`[Ombi] Test webhook sent to ${webhookUrl}`); + + const localUrl = `${useHttps ? 'https' : 'http'}://127.0.0.1:${port}/api/webhook/ombi`; + + const https = require('https'); + const agent = new https.Agent({ + rejectUnauthorized: false + }); + + await axios.post(localUrl, { + notificationType: 'RequestAvailable', + requestId: 0, + requestedUser: 'test', + title: 'Test Request', + type: 'Movie', + requestStatus: 'Pending' + }, { + headers: { + 'X-Sofarr-Webhook-Secret': webhookSecret, + 'Content-Type': 'application/json' + }, + httpsAgent: useHttps ? agent : undefined + }); + + logToFile(`[Ombi] Test webhook sent via local loopback to ${localUrl}`); + } res.json({ success: true }); } catch (error) { diff --git a/server/routes/radarr.js b/server/routes/radarr.js index 77ed1b1..54cf4ca 100644 --- a/server/routes/radarr.js +++ b/server/routes/radarr.js @@ -4,7 +4,7 @@ const axios = require('axios'); const router = express.Router(); const requireAuth = require('../middleware/requireAuth'); const sanitizeError = require('../utils/sanitizeError'); -const { getWebhookSecret, getSofarrBaseUrl, getRadarrInstances } = require('../utils/config'); +const { getWebhookSecret, getSofarrBaseUrl, getRadarrInstances, getSofarrWebhookBaseUrl } = require('../utils/config'); // Helper to get first Radarr instance (for notification proxy routes) function getFirstRadarrInstance() { @@ -286,17 +286,17 @@ router.post('/notifications/sofarr-webhook', async (req, res) => { return res.status(503).json({ error: 'Radarr not configured' }); } try { - const sofarrBaseUrl = getSofarrBaseUrl(); + const webhookBaseUrl = getSofarrWebhookBaseUrl(); const webhookSecret = getWebhookSecret(); - - if (!sofarrBaseUrl) { + + if (!webhookBaseUrl) { return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' }); } if (!webhookSecret) { return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' }); } - - const webhookUrl = `${sofarrBaseUrl}/api/webhook/radarr`; + + const webhookUrl = `${webhookBaseUrl}/api/webhook/radarr`; // Check if Sofarr webhook already exists const listResponse = await axios.get(`${instance.url}/api/v3/notification`, { diff --git a/server/routes/sonarr.js b/server/routes/sonarr.js index f5fe586..d874a6e 100644 --- a/server/routes/sonarr.js +++ b/server/routes/sonarr.js @@ -4,7 +4,7 @@ const axios = require('axios'); const router = express.Router(); const requireAuth = require('../middleware/requireAuth'); const sanitizeError = require('../utils/sanitizeError'); -const { getWebhookSecret, getSofarrBaseUrl, getSonarrInstances } = require('../utils/config'); +const { getWebhookSecret, getSofarrBaseUrl, getSonarrInstances, getSofarrWebhookBaseUrl } = require('../utils/config'); // Helper to get first Sonarr instance (for notification proxy routes) function getFirstSonarrInstance() { @@ -286,17 +286,17 @@ router.post('/notifications/sofarr-webhook', async (req, res) => { return res.status(503).json({ error: 'Sonarr not configured' }); } try { - const sofarrBaseUrl = getSofarrBaseUrl(); + const webhookBaseUrl = getSofarrWebhookBaseUrl(); const webhookSecret = getWebhookSecret(); - - if (!sofarrBaseUrl) { + + if (!webhookBaseUrl) { return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' }); } if (!webhookSecret) { return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' }); } - - const webhookUrl = `${sofarrBaseUrl}/api/webhook/sonarr`; + + const webhookUrl = `${webhookBaseUrl}/api/webhook/sonarr`; // Check if Sofarr webhook already exists const listResponse = await axios.get(`${instance.url}/api/v3/notification`, { diff --git a/server/utils/config.js b/server/utils/config.js index 7db8223..ab48dff 100644 --- a/server/utils/config.js +++ b/server/utils/config.js @@ -130,6 +130,10 @@ function getSofarrBaseUrl() { return process.env.SOFARR_BASE_URL || ''; } +function getSofarrWebhookBaseUrl() { + return process.env.SOFARR_WEBHOOK_BASE_URL || process.env.SOFARR_BASE_URL || ''; +} + module.exports = { getSABnzbdInstances, getSonarrInstances, @@ -140,6 +144,7 @@ module.exports = { getRtorrentInstances, getWebhookSecret, getSofarrBaseUrl, + getSofarrWebhookBaseUrl, parseInstances, validateInstanceUrl }; diff --git a/tests/integration/ombi.test.js b/tests/integration/ombi.test.js index 6c93992..27a9198 100644 --- a/tests/integration/ombi.test.js +++ b/tests/integration/ombi.test.js @@ -1014,10 +1014,16 @@ describe('POST /api/ombi/webhook/test', () => { expect(webhookScope.isDone()).toBe(true); }); - it('handles webhook send errors gracefully', async () => { + it('handles webhook send errors gracefully when both public and loopback fail', async () => { nock(SOFARR_BASE) .post('/api/webhook/ombi') .reply(500, { error: 'Internal server error' }); + nock('http://127.0.0.1:3001') + .post('/api/webhook/ombi') + .reply(500, { error: 'Internal server error' }); + nock('https://127.0.0.1:3001') + .post('/api/webhook/ombi') + .reply(500, { error: 'Internal server error' }); const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false); @@ -1029,4 +1035,26 @@ describe('POST /api/ombi/webhook/test', () => { expect(res.body.error).toBe('Failed to test Ombi webhook'); }); + + it('falls back to local loopback when public URL request fails', async () => { + nock(SOFARR_BASE) + .post('/api/webhook/ombi') + .replyWithError('Connection refused'); + nock('http://127.0.0.1:3001') + .post('/api/webhook/ombi') + .reply(200, { received: true }); + nock('https://127.0.0.1:3001') + .post('/api/webhook/ombi') + .reply(200, { received: true }); + + const { cookies, csrfToken } = await authenticateUser(app, 'TestUser', false); + + const res = await request(app) + .post('/api/ombi/webhook/test') + .set('Cookie', cookies) + .set('X-CSRF-Token', csrfToken) + .expect(200); + + expect(res.body.success).toBe(true); + }); }); \ No newline at end of file