// panel-auth — password gate for the DSH web panel. // // A web-profile host plugin. It wraps the panel's node:http server // (the `webServer` service) and requires one of, on EVERY request and // WebSocket upgrade: // 1. a valid signed session cookie (dsh_panel), issued below, or // 2. a valid HTTP Basic credential from `config.users`. // // The cookie exists because browser WebSocket handshakes always carry // cookies but do not reliably carry Authorization headers. // // Fail-open by design: with no users or no secret configured the guard // passes everything through, so a broken config can never lock the panel // out. Config is re-read on every request, so password changes applied to // the row (HMR or restart) take effect without remounting the plugin. // // Row shape (profile cordis.patch.yml): // - insert: // - id: panel-auth // name: './panel-auth/index.js' // inject: [webServer] // config: // realm: 'DSH Panel' // secret: '<32+ chars of random text, keep private>' // cookieName: dsh_panel // cookieTtlSeconds: 2592000 // users: // - username: admin // passwordHash: '>' import { verifyPassword, signSession, verifySession } from './crypto.js' const DEFAULTS = { realm: 'DSH Panel', cookieName: 'dsh_panel', cookieTtlSeconds: 60 * 60 * 24 * 30, // 30 days } export function createGuard({ getConfig, logger }) { function current() { const cfg = getConfig() ?? {} const users = Array.isArray(cfg.users) ? cfg.users.filter((u) => u && typeof u.username === 'string' && typeof u.passwordHash === 'string') : [] const secret = typeof cfg.secret === 'string' ? cfg.secret : '' const cookieTtl = Number.isFinite(cfg.cookieTtlSeconds) && cfg.cookieTtlSeconds > 0 ? cfg.cookieTtlSeconds : DEFAULTS.cookieTtlSeconds return { users, secret, enabled: users.length > 0 && secret.length >= 16, realm: typeof cfg.realm === 'string' && cfg.realm.length > 0 ? cfg.realm : DEFAULTS.realm, cookieName: typeof cfg.cookieName === 'string' && cfg.cookieName.length > 0 ? cfg.cookieName : DEFAULTS.cookieName, ttl: cookieTtl, } } function basicUser(req, cfg) { const header = req.headers.authorization if (typeof header !== 'string' || !header.startsWith('Basic ')) return null let decoded try { decoded = Buffer.from(header.slice(6).trim(), 'base64').toString('utf8') } catch { return null } const colon = decoded.indexOf(':') if (colon <= 0) return null const username = decoded.slice(0, colon) const password = decoded.slice(colon + 1) const found = cfg.users.find((u) => u.username === username) if (found === undefined) return null return verifyPassword(password, found.passwordHash) ? username : null } function cookieUser(req, cfg) { const header = req.headers.cookie if (typeof header !== 'string') return null for (const part of header.split(';')) { const eq = part.indexOf('=') if (eq <= 0) continue const name = part.slice(0, eq).trim() if (name !== cfg.cookieName) continue const value = part.slice(eq + 1).trim() const username = verifySession(cfg.secret, value, Math.floor(Date.now() / 1000)) if (username !== null && cfg.users.some((u) => u.username === username)) return username return null } return null } /** * Identify the caller. * - { open: true } → config disabled, pass everything through (fail-open) * - { username, via } → accepted ('cookie' or 'basic') * - null → anonymous, challenge */ function authenticate(req) { const cfg = current() if (!cfg.enabled) return { open: true } const cookieIdentity = cookieUser(req, cfg) if (cookieIdentity !== null) return { username: cookieIdentity, via: 'cookie' } const basicIdentity = basicUser(req, cfg) if (basicIdentity !== null) return { username: basicIdentity, via: 'basic' } return null } function issueCookieValue(username) { const cfg = current() return signSession(cfg.secret, username, Math.floor(Date.now() / 1000) + cfg.ttl) } function cookieSettings() { const cfg = current() return { name: cfg.cookieName, ttl: cfg.ttl } } function realm() { return current().realm } return { authenticate, issueCookieValue, cookieSettings, realm } } /** * Atomically wrap the panel's http.Server: replace its 'request' and * 'upgrade' listeners with an auth gate that forwards to the originals. * Returns a disposer that restores the exact original listeners. */ export function installGuard(server, guard, { logger }) { const origRequest = server.listeners('request') const origUpgrade = server.listeners('upgrade') const challenge = (res) => { res.statusCode = 401 res.setHeader('WWW-Authenticate', `Basic realm="${guard.realm()}", charset="UTF-8"`) res.setHeader('Content-Length', '0') res.end() } const onRequest = (req, res) => { const who = guard.authenticate(req) if (who === null) { logger?.warn?.(`[panel-auth] rejected anonymous request ${req.method ?? '?'} ${req.url ?? ''} from ${req.socket?.remoteAddress ?? '?'}`) challenge(res) return } if (!who.open && who.via === 'basic') { const value = guard.issueCookieValue(who.username) const settings = guard.cookieSettings() const existing = res.getHeader('Set-Cookie') const cookieHeader = `${settings.name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${settings.ttl}` res.setHeader('Set-Cookie', existing === undefined ? cookieHeader : [].concat(existing, cookieHeader)) } for (const fn of origRequest) fn(req, res) } const onUpgrade = (req, socket, head) => { const who = guard.authenticate(req) if (who === null) { logger?.warn?.(`[panel-auth] rejected anonymous upgrade ${req.url ?? ''} from ${req.socket?.remoteAddress ?? '?'}`) socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n') socket.destroy() return } for (const fn of origUpgrade) fn(req, socket, head) } server.removeAllListeners('request') server.removeAllListeners('upgrade') server.on('request', onRequest) server.on('upgrade', onUpgrade) return () => { server.removeListener('request', onRequest) server.removeListener('upgrade', onUpgrade) for (const fn of origRequest) server.on('request', fn) for (const fn of origUpgrade) server.on('upgrade', fn) } } export default function panelAuth(ctx, config) { const cfg = config ?? {} const guard = createGuard({ getConfig: () => cfg, logger: null }) let disposer let timer let stopped = false ctx.effect(() => () => { stopped = true if (timer !== undefined) clearInterval(timer) if (disposer !== undefined) disposer() }) const tryWrap = () => { const server = ctx.webServer.server if (server === undefined) return false disposer = installGuard(server, guard, { logger: null }) console.log('[panel-auth] guard installed: panel now requires a password') return true } if (!tryWrap()) { // Cold boot: webServer's node:http server is created in Service.init, // which may not have run when this row applies. Poll briefly. timer = setInterval(() => { if (!stopped && tryWrap()) clearInterval(timer) }, 50) timer.unref?.() } }