Post-review fixes for the 8-commit playout-mcr drop: - Scheduler self-calls (callSelf -> /recorders, /playout) carried no auth, so under AUTH_ENABLED=true requireUiHeader 403'd every mutating POST. This broke playout failover AND scheduled recordings. Add a per-boot in-process service token (x-internal-token) the scheduler attaches; requireAuth/requireUiHeader treat it as the seeded admin. No env/compose config needed. - Failover deadlocked: restartChannel set status='starting' then the scheduler called the guarded /start route, which 409s on 'starting'. Extract the spawn body into spawnChannelSidecar() shared by /start and restartChannel; failover now spawns directly with no self-call. - Phase A playlist stalled after 2 clips: _scheduleAdvance cued the next clip via LOADBG AUTO but never advanced the pointer. Pass asset_duration_ms in the /play payload and arm a duration-based timer that advances currentIndex and cues subsequent clips, keeping as-run in sync for arbitrary-length playlists. - CasparCG consumer syntax was invalid: "ADD <ch> FFMPEG" is the producer name, not a consumer keyword, and old -vcodec/-acodec short args are rejected. Use STREAM/FILE with -codec:v / -codec:a / -preset:v / -tune:v and a format=yuv420p filter ahead of libx264 (channel output is RGBA). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
134 lines
5.9 KiB
JavaScript
134 lines
5.9 KiB
JavaScript
import crypto from 'crypto';
|
|
import pool from '../db/pool.js';
|
|
import { parseBearer, hashToken } from '../auth/tokens.js';
|
|
|
|
// In-process service token for the scheduler's loopback self-calls
|
|
// (scheduler.js -> /recorders|/playout). The scheduler runs in THIS process, so
|
|
// a per-boot random constant needs no env/compose config and is never exposed:
|
|
// it only travels over the loopback fetch inside the same process. Multi-replica
|
|
// is safe because each replica's scheduler only ever calls 127.0.0.1 (itself),
|
|
// matching that replica's token. Requests bearing it are treated as the seeded
|
|
// admin (DEV_USER) so RBAC + FK-bearing routes work.
|
|
export const INTERNAL_TOKEN = crypto.randomBytes(32).toString('hex');
|
|
const INTERNAL_HEADER = 'x-internal-token';
|
|
|
|
function isInternalCall(req) {
|
|
const got = req.headers[INTERNAL_HEADER];
|
|
if (typeof got !== 'string' || got.length !== INTERNAL_TOKEN.length) return false;
|
|
return crypto.timingSafeEqual(Buffer.from(got), Buffer.from(INTERNAL_TOKEN));
|
|
}
|
|
|
|
// Stable UUID matching migration 023's seeded dev user.
|
|
/** UUID of the seeded dev-mode placeholder. NOT a sentinel for "any unauthenticated user". */
|
|
export const DEV_USER_ID = '00000000-0000-4000-8000-000000000000';
|
|
// role 'admin' so dev mode (AUTH_ENABLED=false) keeps full access through the
|
|
// RBAC v2 gates — matches migration 023's seeded dev row.
|
|
export const DEV_USER = { id: DEV_USER_ID, username: 'dev', display_name: 'Dev (AUTH_ENABLED=false)', role: 'admin' };
|
|
|
|
const ABSOLUTE_MS = 8 * 3600 * 1000;
|
|
const IDLE_MS = 1 * 3600 * 1000;
|
|
|
|
async function destroyAnd401(req, res) {
|
|
if (req.session?.destroy) {
|
|
await new Promise(r => req.session.destroy(() => r()));
|
|
}
|
|
return res.status(401).json({ error: 'unauthorized' });
|
|
}
|
|
|
|
async function loadUser(id) {
|
|
const { rows } = await pool.query(
|
|
`SELECT id, username, display_name, role, totp_enabled FROM users WHERE id = $1`, [id]);
|
|
return rows[0] || null;
|
|
}
|
|
|
|
export async function requireAuth(req, res, next) {
|
|
// Internal loopback self-call (scheduler). Acts as the seeded admin so RBAC
|
|
// and FK-bearing routes work, regardless of AUTH_ENABLED.
|
|
if (isInternalCall(req)) {
|
|
req.user = DEV_USER;
|
|
return next();
|
|
}
|
|
|
|
// Dev mode — attach the seeded dev user so FK-bearing routes work.
|
|
if (process.env.AUTH_ENABLED !== 'true') {
|
|
req.user = DEV_USER;
|
|
return next();
|
|
}
|
|
|
|
// 1. Session
|
|
if (req.session?.user_id) {
|
|
const now = Date.now();
|
|
const first = req.session.first_seen_at || 0;
|
|
const last = req.session.last_seen_at || 0;
|
|
if (now - first > ABSOLUTE_MS) return destroyAnd401(req, res);
|
|
if (now - last > IDLE_MS) return destroyAnd401(req, res);
|
|
const u = await loadUser(req.session.user_id);
|
|
if (!u) return destroyAnd401(req, res);
|
|
req.session.last_seen_at = now; // stamp only after user is confirmed; avoids extending idle window if loadUser throws or the user was deleted
|
|
req.user = u;
|
|
return next();
|
|
}
|
|
|
|
// 2. Bearer
|
|
const bearer = parseBearer(req.headers.authorization);
|
|
if (bearer) {
|
|
const hash = hashToken(bearer);
|
|
const { rows } = await pool.query(
|
|
`SELECT t.id AS token_id, t.user_id, t.expires_at, t.bound_hostname,
|
|
u.username, u.display_name, u.role
|
|
FROM api_tokens t JOIN users u ON u.id = t.user_id
|
|
WHERE t.token_hash = $1`, [hash]);
|
|
if (rows.length && (!rows[0].expires_at || rows[0].expires_at > new Date())) {
|
|
pool.query(`UPDATE api_tokens SET last_used_at = NOW() WHERE id = $1`, [rows[0].token_id])
|
|
.catch(err => console.error('[auth] token last_used_at update failed:', err.message));
|
|
req.user = {
|
|
id: rows[0].user_id,
|
|
username: rows[0].username,
|
|
display_name: rows[0].display_name,
|
|
role: rows[0].role,
|
|
};
|
|
// Per migration 019: tokens with a bound_hostname can only be used by
|
|
// node-agents reporting that hostname. The /cluster/heartbeat handler
|
|
// enforces this; we just surface the binding here.
|
|
if (rows[0].bound_hostname) req.tokenBoundHostname = rows[0].bound_hostname;
|
|
return next();
|
|
}
|
|
}
|
|
|
|
// 3. Nothing matched
|
|
return res.status(401).json({ error: 'unauthorized' });
|
|
}
|
|
|
|
// Gate a route to admins only. requireAuth must run first (it sets req.user).
|
|
// 401 when unauthenticated, 403 when authenticated but not an admin.
|
|
export function requireAdmin(req, res, next) {
|
|
if (!req.user) return res.status(401).json({ error: 'unauthorized' });
|
|
if (req.user.role !== 'admin') return res.status(403).json({ error: 'forbidden' });
|
|
return next();
|
|
}
|
|
|
|
// Belt-and-suspenders CSRF: SameSite=Lax already blocks most cross-site
|
|
// cookie sends, but a custom header that no <form> can produce hardens
|
|
// against the edge cases. Applied to mutating verbs only.
|
|
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
const REQUIRED_HEADER = 'dragonflight-ui';
|
|
|
|
// Paths exempt from the CSRF header check. The bearer-auth exemption (above)
|
|
// already covers node-agent because it sends Authorization: Bearer; this set
|
|
// is the belt for any future service path that might call us without a
|
|
// bearer header. Today it just lets an unauthenticated heartbeat probe
|
|
// surface as a clean 401 from requireAuth instead of a confusing 403 CSRF.
|
|
const CSRF_EXEMPT_PATHS = new Set(['/cluster/heartbeat']);
|
|
|
|
export function requireUiHeader(req, res, next) {
|
|
if (!MUTATING.has(req.method)) return next();
|
|
// Internal loopback self-call (scheduler) — not a browser, can't be drive-by'd.
|
|
if (isInternalCall(req)) return next();
|
|
// Bearer-authed requests (Premiere panel, scripts) are exempt — they're not
|
|
// browsers and can't be drive-by'd from another origin.
|
|
if (req.headers.authorization?.toLowerCase().startsWith('bearer ')) return next();
|
|
// Service path carve-outs (e.g. node-agent heartbeat — not a browser).
|
|
if (CSRF_EXEMPT_PATHS.has(req.path)) return next();
|
|
if (req.headers['x-requested-with'] === REQUIRED_HEADER) return next();
|
|
return res.status(403).json({ error: 'missing X-Requested-With header' });
|
|
}
|