feat: implement togglable debug log streaming for server stdout/stderr and client console logs

- Created server/utils/logCapture.js to intercept and buffer server output, stripping ANSI escape codes.
- Created server/middleware/logStreamAuth.js enforcing subnet IP filtering (LOG_ALLOW_SUBNETS), Emby session cookie, Basic Auth fallback, and X-Webhook-Secret header bypass.
- Created server/routes/debug.js with SSE streams /api/debug/server-logs, /api/debug/client-logs and batched POST /api/debug/client-logs. Exposes public configuration status at /api/debug/status.
- Integrated log capture and mounted debug routes in server/app.js and server/index.js.
- Implemented client/src/utils/clientLogCapture.js in the frontend SPA to hook console log/warn/error and flush batched console events.
- Documented all endpoints in OpenAPI server/openapi.yaml, ARCHITECTURE.md, and README.md.
- Wrote route integration tests and frontend console capture tests, with full validation in swagger-coverage.
This commit is contained in:
2026-05-24 11:31:36 +01:00
parent afc940aba7
commit 3c6791658c
12 changed files with 1127 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
// Copyright (c) 2026 Gordon Bolton. MIT License.
const axios = require('axios');
const crypto = require('crypto');
const ipaddr = require('ipaddr.js');
function getEmbyUrl() {
return process.env.EMBY_URL;
}
function isIpAllowed(clientIp, allowedSubnetsStr) {
if (!allowedSubnetsStr) return true;
try {
const clientIpParsed = ipaddr.parse(clientIp);
const subnets = allowedSubnetsStr.split(',').map(s => s.trim()).filter(Boolean);
for (const subnet of subnets) {
let rangeStr = subnet;
let bits = null;
if (subnet.includes('/')) {
const parts = subnet.split('/');
rangeStr = parts[0];
bits = parseInt(parts[1], 10);
}
const rangeIpParsed = ipaddr.parse(rangeStr);
if (bits === null) {
// Exact IP match
if (clientIpParsed.toString() === rangeIpParsed.toString()) {
return true;
}
// Handle IPv4 mapped IPv6 address case
if (clientIpParsed.kind() === 'ipv6' && clientIpParsed.isIPv4MappedAddress() && rangeIpParsed.kind() === 'ipv4') {
if (clientIpParsed.toIPv4Address().toString() === rangeIpParsed.toString()) {
return true;
}
}
continue;
}
// Match with subnet bits
if (clientIpParsed.kind() === rangeIpParsed.kind()) {
if (clientIpParsed.match(rangeIpParsed, bits)) {
return true;
}
} else if (clientIpParsed.kind() === 'ipv6' && clientIpParsed.isIPv4MappedAddress() && rangeIpParsed.kind() === 'ipv4') {
// Handle IPv4 mapped IPv6 address case matching IPv4 range
if (clientIpParsed.toIPv4Address().match(rangeIpParsed, bits)) {
return true;
}
}
}
} catch (err) {
console.error(`[logStreamAuth] IP parsing error for IP ${clientIp}:`, err.message);
}
return false;
}
async function logStreamAuth(req, res, next) {
// 1. Subnet IP Filtering (First Priority)
const allowedSubnets = process.env.LOG_ALLOW_SUBNETS;
if (allowedSubnets && !isIpAllowed(req.ip, allowedSubnets)) {
console.warn(`[logStreamAuth] Access denied from unauthorized IP: ${req.ip}`);
return res.status(403).json({ error: `Access denied from IP: ${req.ip}` });
}
// 2. Webhook Secret Bypass (High Priority)
const secretHeader = req.headers['x-webhook-secret'];
const configuredSecret = process.env.SOFARR_WEBHOOK_SECRET;
if (configuredSecret && secretHeader === configuredSecret) {
return next();
}
// 3. Session Cookie
const signed = !!process.env.COOKIE_SECRET;
const rawCookie = signed ? req.signedCookies.emby_user : req.cookies.emby_user;
if (rawCookie && rawCookie !== false) {
try {
const u = JSON.parse(rawCookie);
if (u && typeof u.id === 'string' && u.id && u.isAdmin === true) {
req.user = u;
return next();
}
} catch {
// Ignore JSON parse errors, fallback to basic auth
}
}
// 4. Basic Authentication Fallback
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Basic ')) {
try {
const credentialsBase64 = authHeader.substring(6);
const credentialsStr = Buffer.from(credentialsBase64, 'base64').toString('utf8');
const colonIdx = credentialsStr.indexOf(':');
if (colonIdx !== -1) {
const username = credentialsStr.substring(0, colonIdx).trim();
const password = credentialsStr.substring(colonIdx + 1);
if (username && password) {
const embyUrl = getEmbyUrl();
if (!embyUrl) {
console.error('[logStreamAuth] Emby auth fallback failed: EMBY_URL is not configured');
res.setHeader('WWW-Authenticate', 'Basic realm="sofarr log stream debug"');
return res.status(401).json({ error: 'Authentication service unavailable' });
}
// Authenticate with Emby using stable DeviceId derived from username
const stableDeviceId = 'sofarr-' + crypto.createHash('sha256').update(username.toLowerCase()).digest('hex').slice(0, 16);
console.log(`[logStreamAuth] Attempting Basic Auth login for: ${username}`);
const authResponse = await axios.post(`${embyUrl}/Users/authenticatebyname`, {
Username: username,
Pw: password
}, {
headers: {
'X-Emby-Authorization': `MediaBrowser Client="sofarr", Device="sofarr", DeviceId="${stableDeviceId}", Version="1.0.0"`
},
timeout: 5000
});
const authData = authResponse.data;
const userId = authData.User.Id || authData.User.id;
// Fetch detailed profile to verify administrator status
const userResponse = await axios.get(`${embyUrl}/Users/${userId}`, {
headers: {
'X-MediaBrowser-Token': authData.AccessToken
},
timeout: 5000
});
const user = userResponse.data;
const isAdmin = !!(user.Policy && user.Policy.IsAdministrator);
if (isAdmin) {
console.log(`[logStreamAuth] Basic Auth successful for administrator: ${user.Name}`);
req.user = { id: user.Id, name: user.Name, isAdmin: true };
return next();
} else {
console.warn(`[logStreamAuth] Basic Auth rejected: user ${user.Name} is not an administrator`);
}
}
}
} catch (err) {
console.error('[logStreamAuth] Emby authentication error:', err.message);
}
}
// 5. Unauthorized / Access Denied
res.setHeader('WWW-Authenticate', 'Basic realm="sofarr log stream debug"');
return res.status(401).json({ error: 'Unauthorized' });
}
module.exports = logStreamAuth;