Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fb00843ef | |||
| d2ac7731ca |
@@ -35,6 +35,13 @@ SOFARR_WEBHOOK_SECRET=your-webhook-secret-here
|
|||||||
# Example: https://sofarr.example.com or https://192.168.1.100:3001
|
# Example: https://sofarr.example.com or https://192.168.1.100:3001
|
||||||
SOFARR_BASE_URL=https://your-sofarr-url
|
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) ---
|
# --- Webhook Polling Optimization (Phase 5) ---
|
||||||
|
|
||||||
# Minutes of silence after which the poller falls back to a full poll
|
# Minutes of silence after which the poller falls back to a full poll
|
||||||
|
|||||||
@@ -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/).
|
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).
|
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
|
## [1.7.20] - 2026-05-26
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.20",
|
"version": "1.7.21",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sofarr",
|
"name": "sofarr",
|
||||||
"version": "1.7.20",
|
"version": "1.7.21",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sofarr",
|
"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",
|
"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",
|
"main": "server/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+1
-1
@@ -132,7 +132,7 @@ function createApp({ skipRateLimits = false } = {}) {
|
|||||||
* version:
|
* version:
|
||||||
* type: string
|
* type: string
|
||||||
* description: sofarr version
|
* description: sofarr version
|
||||||
* example: "1.7.20"
|
* example: "1.7.21"
|
||||||
* x-code-samples:
|
* x-code-samples:
|
||||||
* - lang: curl
|
* - lang: curl
|
||||||
* label: cURL
|
* label: cURL
|
||||||
|
|||||||
+1
-1
@@ -249,7 +249,7 @@ app.use(express.json({ limit: '64kb' })); // prevent oversized JSON payloads
|
|||||||
* version:
|
* version:
|
||||||
* type: string
|
* type: string
|
||||||
* description: sofarr version
|
* description: sofarr version
|
||||||
* example: "1.7.20"
|
* example: "1.7.21"
|
||||||
*/
|
*/
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
res.json({ status: 'ok', uptime: process.uptime(), version });
|
res.json({ status: 'ok', uptime: process.uptime(), version });
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@ info:
|
|||||||
|
|
||||||
## SSE Streaming
|
## SSE Streaming
|
||||||
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
|
Real-time updates are available via Server-Sent Events at GET /api/dashboard/stream.
|
||||||
version: 1.7.20
|
version: 1.7.21
|
||||||
contact:
|
contact:
|
||||||
name: sofarr
|
name: sofarr
|
||||||
license:
|
license:
|
||||||
|
|||||||
+67
-21
@@ -2,7 +2,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { logToFile } = require('../utils/logger');
|
const { logToFile } = require('../utils/logger');
|
||||||
const cache = require('../utils/cache');
|
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 requireAuth = require('../middleware/requireAuth');
|
||||||
const { extractRequestedUser, filterRequestsByUser } = require('../utils/ombiHelpers');
|
const { extractRequestedUser, filterRequestsByUser } = require('../utils/ombiHelpers');
|
||||||
const { applyRequestFilters } = require('../utils/ombiFilters');
|
const { applyRequestFilters } = require('../utils/ombiFilters');
|
||||||
@@ -205,10 +205,10 @@ router.get('/requests', requireAuth, async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
router.post('/webhook/enable', requireAuth, async (req, res) => {
|
router.post('/webhook/enable', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const sofarrBaseUrl = getSofarrBaseUrl();
|
const webhookBaseUrl = getSofarrWebhookBaseUrl();
|
||||||
const webhookSecret = getWebhookSecret();
|
const webhookSecret = getWebhookSecret();
|
||||||
|
|
||||||
if (!sofarrBaseUrl) {
|
if (!webhookBaseUrl) {
|
||||||
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
||||||
}
|
}
|
||||||
if (!webhookSecret) {
|
if (!webhookSecret) {
|
||||||
@@ -221,7 +221,7 @@ router.post('/webhook/enable', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ombiInst = ombiInstances[0];
|
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
|
// Call Ombi API to register webhook
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
@@ -462,10 +462,10 @@ router.get('/webhook/status', requireAuth, async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
router.post('/webhook/test', requireAuth, async (req, res) => {
|
router.post('/webhook/test', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const sofarrBaseUrl = getSofarrBaseUrl();
|
const webhookBaseUrl = getSofarrWebhookBaseUrl();
|
||||||
const webhookSecret = getWebhookSecret();
|
const webhookSecret = getWebhookSecret();
|
||||||
|
|
||||||
if (!sofarrBaseUrl) {
|
if (!webhookBaseUrl) {
|
||||||
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
||||||
}
|
}
|
||||||
if (!webhookSecret) {
|
if (!webhookSecret) {
|
||||||
@@ -478,25 +478,71 @@ router.post('/webhook/test', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ombiInst = ombiInstances[0];
|
const ombiInst = ombiInstances[0];
|
||||||
const webhookUrl = `${sofarrBaseUrl}/api/webhook/ombi`;
|
const webhookUrl = `${webhookBaseUrl}/api/webhook/ombi`;
|
||||||
|
|
||||||
// Simulate a test webhook event
|
// Simulate a test webhook event
|
||||||
const axios = require('axios');
|
const axios = require('axios');
|
||||||
await axios.post(webhookUrl, {
|
try {
|
||||||
notificationType: 'RequestAvailable',
|
await axios.post(webhookUrl, {
|
||||||
requestId: 0,
|
notificationType: 'RequestAvailable',
|
||||||
requestedUser: 'test',
|
requestId: 0,
|
||||||
title: 'Test Request',
|
requestedUser: 'test',
|
||||||
type: 'Movie',
|
title: 'Test Request',
|
||||||
requestStatus: 'Pending'
|
type: 'Movie',
|
||||||
}, {
|
requestStatus: 'Pending'
|
||||||
headers: {
|
}, {
|
||||||
'X-Sofarr-Webhook-Secret': webhookSecret,
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
const localUrl = `${useHttps ? 'https' : 'http'}://127.0.0.1:${port}/api/webhook/ombi`;
|
||||||
logToFile(`[Ombi] Test webhook sent to ${webhookUrl}`);
|
|
||||||
|
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 });
|
res.json({ success: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const axios = require('axios');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const requireAuth = require('../middleware/requireAuth');
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
const sanitizeError = require('../utils/sanitizeError');
|
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)
|
// Helper to get first Radarr instance (for notification proxy routes)
|
||||||
function getFirstRadarrInstance() {
|
function getFirstRadarrInstance() {
|
||||||
@@ -286,17 +286,17 @@ router.post('/notifications/sofarr-webhook', async (req, res) => {
|
|||||||
return res.status(503).json({ error: 'Radarr not configured' });
|
return res.status(503).json({ error: 'Radarr not configured' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const sofarrBaseUrl = getSofarrBaseUrl();
|
const webhookBaseUrl = getSofarrWebhookBaseUrl();
|
||||||
const webhookSecret = getWebhookSecret();
|
const webhookSecret = getWebhookSecret();
|
||||||
|
|
||||||
if (!sofarrBaseUrl) {
|
if (!webhookBaseUrl) {
|
||||||
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
||||||
}
|
}
|
||||||
if (!webhookSecret) {
|
if (!webhookSecret) {
|
||||||
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
|
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
|
// Check if Sofarr webhook already exists
|
||||||
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
|
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const axios = require('axios');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const requireAuth = require('../middleware/requireAuth');
|
const requireAuth = require('../middleware/requireAuth');
|
||||||
const sanitizeError = require('../utils/sanitizeError');
|
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)
|
// Helper to get first Sonarr instance (for notification proxy routes)
|
||||||
function getFirstSonarrInstance() {
|
function getFirstSonarrInstance() {
|
||||||
@@ -286,17 +286,17 @@ router.post('/notifications/sofarr-webhook', async (req, res) => {
|
|||||||
return res.status(503).json({ error: 'Sonarr not configured' });
|
return res.status(503).json({ error: 'Sonarr not configured' });
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const sofarrBaseUrl = getSofarrBaseUrl();
|
const webhookBaseUrl = getSofarrWebhookBaseUrl();
|
||||||
const webhookSecret = getWebhookSecret();
|
const webhookSecret = getWebhookSecret();
|
||||||
|
|
||||||
if (!sofarrBaseUrl) {
|
if (!webhookBaseUrl) {
|
||||||
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
return res.status(400).json({ error: 'SOFARR_BASE_URL not configured' });
|
||||||
}
|
}
|
||||||
if (!webhookSecret) {
|
if (!webhookSecret) {
|
||||||
return res.status(400).json({ error: 'SOFARR_WEBHOOK_SECRET not configured' });
|
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
|
// Check if Sofarr webhook already exists
|
||||||
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
|
const listResponse = await axios.get(`${instance.url}/api/v3/notification`, {
|
||||||
|
|||||||
@@ -130,6 +130,10 @@ function getSofarrBaseUrl() {
|
|||||||
return process.env.SOFARR_BASE_URL || '';
|
return process.env.SOFARR_BASE_URL || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSofarrWebhookBaseUrl() {
|
||||||
|
return process.env.SOFARR_WEBHOOK_BASE_URL || process.env.SOFARR_BASE_URL || '';
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getSABnzbdInstances,
|
getSABnzbdInstances,
|
||||||
getSonarrInstances,
|
getSonarrInstances,
|
||||||
@@ -140,6 +144,7 @@ module.exports = {
|
|||||||
getRtorrentInstances,
|
getRtorrentInstances,
|
||||||
getWebhookSecret,
|
getWebhookSecret,
|
||||||
getSofarrBaseUrl,
|
getSofarrBaseUrl,
|
||||||
|
getSofarrWebhookBaseUrl,
|
||||||
parseInstances,
|
parseInstances,
|
||||||
validateInstanceUrl
|
validateInstanceUrl
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1014,10 +1014,16 @@ describe('POST /api/ombi/webhook/test', () => {
|
|||||||
expect(webhookScope.isDone()).toBe(true);
|
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)
|
nock(SOFARR_BASE)
|
||||||
.post('/api/webhook/ombi')
|
.post('/api/webhook/ombi')
|
||||||
.reply(500, { error: 'Internal server error' });
|
.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);
|
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');
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
Reference in New Issue
Block a user