Files
Krystie e99413150e
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
[glm-grade=B] fix: dead dashboard WS, corrupted error.log, auth-polling storm, re-auth freeze + CVE bumps
Adversarial audit 2026-08-16 (GLM-5.3 delegate, 2 rounds, 141 tool calls):

P0-1: Dashboard WebSocket (/api/v1/ws) dead on EVERY boot since DC-076.
server.js passed module exports (DependencyManager class, {AutoRestartManager}
namespace, SSLMonitor class) instead of createApp()'s live instances — first
.on() threw ERR_INVALID_ARG_TYPE, catch swallowed it. Fix: app.locals.ctx
exposed in src/app.js; server.js passes all 8 real EventEmitter instances.

P0-2: error.log corrupted since 2026-07-14. errorMiddleware called
logError(FILE, SIZE, path, err, meta) — 5 args into a 3-arg wrapper —
logging 'Error: 5242880' garbage every ~60s and DISCARDING the real error
object. Fix: correct 3-arg call + legacy-shape guard in logErrorWrapper +
~74 log.error sites swept to pass real error objects (AST-verified scope-
safe 71/71, 29/29 modules load clean).

P0-3: auth-polling storm (stranded grade=B commit never landed in prod):
401/403 behind TOTP gate hammered /api/v1/services/status + SSE reconnect
every 2-8s, with misleading direct-probe fallback marking services 'up'.
Fix landed + B-round MEDIUM follow-up: TOTP re-auth success now clears
_dcAuthLost, resumes SSE (new _sseResume clears the latch), and refreshes.

Also: eslintignore static-sites/ (33→0 errors); nodemailer 8→9.0.5 and
sharp 0.33→0.35.3 (3 high CVEs killed; jest green on new majors);
dockerode@5/uuid deferred (semver-major, Docker API surface).

Verification: 80/80 suites, 1837/1837 tests; ESLint 0 errors/743 warnings;
node --check all changed files; bundles rebuilt + SW cache bumped.

Judges: Codex quota-dead until Aug 19 (verified live) — GLM adversarial
delegate per operator directive 2026-08-07. Round 1: 98-call mechanical
verification (timed out pre-verdict). Round 2 (this grade): B, one MEDIUM
(re-auth freeze) — fixed in this commit as prescribed.
2026-08-16 04:18:07 -07:00

312 lines
11 KiB
JavaScript

const { APP_PORTS } = require('../../src/utilities/constants');
/**
* Arr helpers factory
* @param {Object} deps - Explicit dependencies
* @param {Object} deps.docker - Docker client wrapper
* @param {Object} deps.credentialManager - Credential manager
* @param {Function} deps.fetchT - Timeout-wrapped fetch
* @param {Object} deps.log - Logger instance
* @returns {Object} Helper functions
*/
module.exports = function({ docker, credentialManager, fetchT, log }) {
// Helper: Extract API key from arr service config.xml
async function getArrApiKey(containerName) {
try {
const container = await docker.findContainer(containerName);
if (!container) return null;
const dockerContainer = docker.client.getContainer(container.Id);
const exec = await dockerContainer.exec({
Cmd: ['cat', '/config/config.xml'],
AttachStdout: true,
AttachStderr: true
});
const stream = await exec.start();
return new Promise((resolve) => {
let data = '';
stream.on('data', chunk => data += chunk.toString());
stream.on('end', () => {
// Extract API key from XML
const match = data.match(/<ApiKey>([^<]+)<\/ApiKey>/);
resolve(match ? match[1] : null);
});
stream.on('error', () => resolve(null));
});
} catch (error) {
log.error('docker', error, null, { note: 'Failed to get API key', containerName });
return null;
}
}
// Helper: Get Plex token from container or config
async function getPlexToken(containerName) {
try {
const containers = await docker.client.listContainers({ all: false });
const container = containers.find(c =>
c.Names.some(n => n.toLowerCase().includes(containerName.toLowerCase()) || n.toLowerCase().includes('plex'))
);
if (!container) return null;
const dockerContainer = docker.client.getContainer(container.Id);
const exec = await dockerContainer.exec({
Cmd: ['cat', '/config/Library/Application Support/Plex Media Server/Preferences.xml'],
AttachStdout: true,
AttachStderr: true
});
const stream = await exec.start();
return new Promise((resolve) => {
let data = '';
stream.on('data', chunk => data += chunk.toString());
stream.on('end', () => {
const match = data.match(/PlexOnlineToken="([^"]+)"/);
resolve(match ? match[1] : null);
});
stream.on('error', () => resolve(null));
});
} catch (error) {
log.error('docker', error, null, { note: 'Failed to get Plex token' });
return null;
}
}
// Helper: Get container URL (internal Docker network or host)
function getServiceUrl(containerName, port, useTailscale = false) {
// For Docker containers, use localhost since they're on the same host
const host = useTailscale ? (process.env.HOST_TAILSCALE_IP || 'localhost') : 'localhost';
return `http://${host}:${port}`;
}
// Helper: Get authenticated Seerr/Overseerr session via Plex token
// Seerr requires Plex-based auth for admin endpoints (settings, configuration)
async function getOverseerrSession() {
const seerrUrl = `http://host.docker.internal:${APP_PORTS.seerr}`;
try {
// Try getting Plex token from running container first
let plexToken = await getPlexToken('plex');
// Fall back to stored Plex token in credential manager
if (!plexToken) {
plexToken = await credentialManager.retrieve('arr.plex.token');
}
if (!plexToken) {
log.error('arr', 'Could not get Plex token for Seerr auth (no container, no stored token)');
return null;
}
// Authenticate with Seerr via Plex token
const authRes = await fetchT(`${seerrUrl}/api/v1/auth/plex`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ authToken: plexToken }),
signal: AbortSignal.timeout(10000)
});
if (!authRes.ok) {
log.error('arr', 'Seerr Plex auth failed', { status: authRes.status });
return null;
}
const setCookie = authRes.headers.get('set-cookie');
if (!setCookie) {
log.error('arr', 'No session cookie returned from Seerr');
return null;
}
const sessionCookie = setCookie.split(';')[0];
return { cookie: sessionCookie, plexToken };
} catch (e) {
log.error('arr', e, null, { note: 'Could not get Seerr session' });
return null;
}
}
// Helper: Connect Plex to Overseerr
// Uses session cookie auth (Overseerr requires Plex-based admin session for settings)
async function connectPlexToOverseerr(plexUrl, plexToken, overseerrUrl, sessionCookie) {
// 1. Get Plex server identity (for return info)
const identityRes = await fetchT(`${plexUrl}/identity`, {
headers: { 'X-Plex-Token': plexToken, 'Accept': 'application/json' },
signal: AbortSignal.timeout(10000)
});
if (!identityRes.ok) throw new Error('Cannot reach Plex server');
const identity = await identityRes.json();
const serverName = identity.MediaContainer?.friendlyName || 'Plex';
// 2. Configure Plex server connection in Overseerr
// Only send writable fields — name, machineId, libraries are read-only (auto-discovered by Overseerr)
const plexConfig = {
ip: 'host.docker.internal',
port: APP_PORTS.plex,
useSsl: false
};
const configRes = await fetchT(`${overseerrUrl}/api/v1/settings/plex`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Cookie': sessionCookie
},
body: JSON.stringify(plexConfig)
});
if (!configRes.ok) {
throw new Error(`Overseerr Plex config failed: ${await configRes.text()}`);
}
// 3. Trigger library sync — Overseerr will use the admin's Plex token to discover libraries
try {
await fetchT(`${overseerrUrl}/api/v1/settings/plex/sync`, {
method: 'POST',
headers: { 'Cookie': sessionCookie },
signal: AbortSignal.timeout(10000)
});
} catch (e) {
log.warn('arr', 'Plex library sync trigger failed (non-fatal)', { error: e.message });
}
// 4. Get discovered libraries
let libraries = [];
try {
const libRes = await fetchT(`${overseerrUrl}/api/v1/settings/plex`, {
headers: { 'Cookie': sessionCookie },
signal: AbortSignal.timeout(5000)
});
if (libRes.ok) {
const plexSettings = await libRes.json();
libraries = plexSettings.libraries || [];
}
} catch (e) { /* non-fatal */ }
return { success: true, libraries, serverName, machineId: identity.MediaContainer?.machineIdentifier };
}
// Helper: Configure Prowlarr connected apps (Radarr/Sonarr)
async function configureProwlarrApps(prowlarrUrl, prowlarrApiKey, apps) {
const results = {};
// Check existing apps to avoid duplicates
let existingApps = [];
try {
const existingRes = await fetchT(`${prowlarrUrl}/api/v1/applications`, {
headers: { 'X-Api-Key': prowlarrApiKey },
signal: AbortSignal.timeout(10000)
});
existingApps = existingRes.ok ? await existingRes.json() : [];
} catch (e) {
log.warn('arr', 'Could not fetch existing Prowlarr apps', { error: e.message });
}
for (const [appName, config] of Object.entries(apps)) {
const implementation = appName.charAt(0).toUpperCase() + appName.slice(1); // "Radarr", "Sonarr"
// Skip if already configured
if (existingApps.some(a => a.implementation === implementation)) {
results[appName] = 'already_configured';
continue;
}
const syncCategories = appName === 'radarr'
? [2000, 2010, 2020, 2030, 2040, 2045, 2050, 2060]
: [5000, 5010, 5020, 5030, 5040, 5045, 5050];
const payload = {
name: implementation,
syncLevel: 'fullSync',
implementation: implementation,
configContract: `${implementation}Settings`,
fields: [
{ name: 'prowlarrUrl', value: prowlarrUrl },
{ name: 'baseUrl', value: config.url },
{ name: 'apiKey', value: config.apiKey },
{ name: 'syncCategories', value: syncCategories }
]
};
try {
const res = await fetchT(`${prowlarrUrl}/api/v1/applications`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Api-Key': prowlarrApiKey
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(10000)
});
results[appName] = res.ok ? 'configured' : `failed: ${await res.text()}`;
} catch (e) {
results[appName] = `error: ${e.message}`;
}
}
return results;
}
// Helper: Test a service connection (reusable logic)
async function testServiceConnection(service, url, apiKey) {
const baseUrl = url.replace(/\/+$/, '');
let apiEndpoint, headers;
if (service === 'radarr' || service === 'sonarr' || service === 'lidarr') {
apiEndpoint = `${baseUrl}/api/v3/system/status`;
headers = { 'X-Api-Key': apiKey, 'Accept': 'application/json' };
} else if (service === 'prowlarr') {
apiEndpoint = `${baseUrl}/api/v1/system/status`;
headers = { 'X-Api-Key': apiKey, 'Accept': 'application/json' };
} else if (service === 'plex') {
apiEndpoint = `${baseUrl}/identity`;
headers = { 'X-Plex-Token': apiKey, 'Accept': 'application/json' };
} else {
return { success: false, error: `Unknown service: ${service}` };
}
try {
const response = await fetchT(apiEndpoint, {
method: 'GET',
headers,
signal: AbortSignal.timeout(15000)
});
if (response.ok) {
const data = await response.json();
if (service === 'plex') {
return { success: true, version: data.MediaContainer?.version, appName: 'Plex' };
}
return { success: true, version: data.version, appName: data.appName };
} else if (response.status === 401) {
return { success: false, error: 'Invalid API key' };
} else {
return { success: false, error: `HTTP ${response.status}` };
}
} catch (e) {
if (e.cause?.code === 'ECONNREFUSED') return { success: false, error: 'Connection refused' };
if (e.name === 'AbortError') return { success: false, error: 'Connection timeout' };
return { success: false, error: e.message };
}
}
// Helper: Get Overseerr API key (convenience wrapper)
async function getOverseerrApiKey() {
const session = await getOverseerrSession();
return session;
}
return {
getArrApiKey,
getPlexToken,
getServiceUrl,
getOverseerrSession,
getOverseerrApiKey,
connectPlexToOverseerr,
configureProwlarrApps,
testServiceConnection
};
};