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:
@@ -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;
|
||||
Reference in New Issue
Block a user