3c6791658c
- 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.
157 lines
5.6 KiB
JavaScript
157 lines
5.6 KiB
JavaScript
// 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;
|