// 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 at login, or // 2. a valid HTTP Basic credential from `config.users`. // // Browsers get a self-contained login page instead of the native Basic // dialog; API clients keep the classic 401 + WWW-Authenticate contract. // Login activity (success, failure, logout, rejected requests) is written // to a structured JSONL audit file. // // 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 // auditLogPath: '/root/.dsh/panel-auth-audit.jsonl' # optional // loginPath: '/panel-auth/login' # optional // logoutPath: '/panel-auth/logout' # optional // users: // - username: admin // passwordHash: '>' import { appendFileSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs' import { homedir } from 'node:os' import { resolve } from 'node:path' import { verifyPassword, hashPassword, signSession, verifySession } from './crypto.js' const DEFAULTS = { realm: 'DSH Panel', cookieName: 'dsh_panel', cookieTtlSeconds: 60 * 60 * 24 * 30, // 30 days loginPath: '/panel-auth/login', logoutPath: '/panel-auth/logout', changePasswordPath: '/panel-auth/change-password', auditMaxBytes: 5 * 1024 * 1024, } // ── audit log ──────────────────────────────────────────────────────────────── export function createAuditWriter(filePath) { function rotateIfNeeded() { try { if (statSync(filePath).size > DEFAULTS.auditMaxBytes) renameSync(filePath, `${filePath}.1`) } catch { /* first write or concurrent rotation — ignore */ } } return { path: filePath, write(entry) { try { appendFileSync(filePath, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n', { mode: 0o600 }) rotateIfNeeded() } catch (error) { console.error('[panel-auth] audit write failed:', error && error.message ? error.message : error) } }, } } export function defaultAuditPath() { const home = process.env.DSH_HOME || resolve(homedir(), '.dsh') return resolve(home, 'panel-auth-audit.jsonl') } /** * In-memory brute-force lockout keyed by client IP. * - After `maxFailures` failures inside `failureWindowSeconds`, the IP is * locked out for `lockoutBaseSeconds`; repeated lockouts double the * duration up to `lockoutMaxSeconds`. * - State is per-process only (a panel restart clears it — restarting the * panel requires root, so an attacker cannot reset it themselves). */ export function createLockout({ maxFailures = 5, lockoutBaseSeconds = 30, lockoutMaxSeconds = 3600, failureWindowSeconds = 300, enabled = true } = {}) { const records = new Map() const nowMs = () => Date.now() function prune() { const now = nowMs() const cutoff = now - failureWindowSeconds * 1000 for (const [ip, rec] of records) { if (rec.lockedUntil < now && rec.firstAt < cutoff) records.delete(ip) } if (records.size > 10000) { const it = records.keys() while (records.size > 8000) records.delete(it.next().value) } } function status(ip) { if (!enabled) return { locked: false, retryAfter: 0 } prune() const rec = records.get(ip) if (!rec) return { locked: false, retryAfter: 0 } const now = nowMs() if (rec.lockedUntil > now) { return { locked: true, retryAfter: Math.max(1, Math.ceil((rec.lockedUntil - now) / 1000)) } } return { locked: false, retryAfter: 0 } } function recordFailure(ip) { if (!enabled || !ip) return const now = nowMs() let rec = records.get(ip) if (!rec || now - rec.firstAt > failureWindowSeconds * 1000) { rec = { count: 0, firstAt: now, strikes: 0, lockedUntil: 0 } records.set(ip, rec) } rec.count += 1 if (rec.count >= maxFailures) { rec.strikes += 1 const lockMs = Math.min(lockoutBaseSeconds * 1000 * 2 ** (rec.strikes - 1), lockoutMaxSeconds * 1000) rec.lockedUntil = now + lockMs rec.count = 0 rec.firstAt = now } } function clear(ip) { records.delete(ip) } return { status, recordFailure, clear } } // ── guard ──────────────────────────────────────────────────────────────────── 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 checkCredentials(username, password) { const cfg = current() if (!cfg.enabled) return false const found = cfg.users.find((u) => u.username === username) if (found === undefined) return false return verifyPassword(password, found.passwordHash) } 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, checkCredentials, issueCookieValue, cookieSettings, realm } } // ── login page ─────────────────────────────────────────────────────────────── export function escapeHtml(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", ''') } export function renderLoginPage({ realm, next = '/', error = '', loginPath = DEFAULTS.loginPath }) { const safeNext = escapeHtml(next) const safeError = escapeHtml(error) const safeRealm = escapeHtml(realm) const safeAction = escapeHtml(loginPath) return ` ${safeRealm} · 登录

${safeRealm}

登录以继续 · Sign in to continue

${safeError ? `` : ''}

登录活动将被记录
Protected by panel-auth

` } // ── request helpers ────────────────────────────────────────────────────────── function jsonResponse(res, status, payload) { const body = JSON.stringify(payload) res.statusCode = status res.setHeader('Content-Type', 'application/json; charset=utf-8') res.setHeader('Cache-Control', 'no-store') res.setHeader('Content-Length', String(Buffer.byteLength(body))) res.end(body) } function clientIp(req) { const remote = req.socket?.remoteAddress ?? '' const loopback = remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1' if (loopback) { const forwarded = req.headers['x-forwarded-for'] if (typeof forwarded === 'string' && forwarded.length > 0) { const parts = forwarded.split(',').map((part) => part.trim()).filter((part) => part.length > 0) if (parts.length > 0) return parts[parts.length - 1] } } return remote } function acceptsHtml(req) { const accept = req.headers.accept return typeof accept === 'string' && accept.includes('text/html') } function sanitizeNext(next) { if (typeof next !== 'string' || next.length === 0 || next.length > 2048) return '/' if (!next.startsWith('/') || next.startsWith('//')) return '/' return next } function sameHost(originHeader, hostHeader) { try { const originHost = new URL(originHeader).hostname.toLowerCase() const hostHost = new URL(`http://${hostHeader}`).hostname.toLowerCase() return originHost === hostHost } catch { return false } } /** True when the Host header names the loopback authority (direct access or a * reverse proxy presenting a loopback Host upstream). */ function isLoopbackHost(hostHeader) { try { const hostname = new URL(`http://${hostHeader}`).hostname.toLowerCase() return hostname === 'localhost' || hostname === '[::1]' || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname) } catch { return false } } function readBody(req, maxBytes) { return new Promise((resolveBody) => { let size = 0 const chunks = [] let done = false const finish = () => { if (done) return done = true resolveBody(Buffer.concat(chunks).toString('utf8')) } req.on('data', (chunk) => { size += chunk.length if (size > maxBytes) { done = true resolveBody(null) req.destroy() return } chunks.push(chunk) }) req.on('end', finish) req.on('error', finish) }) } // ── server wrap ────────────────────────────────────────────────────────────── function sleep(ms) { return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)) } /** * 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, { audit, loginPath, logoutPath, changePasswordPath, changePassword, lockout, failedLoginDelayMs = 300 }) { 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-Type', 'application/json; charset=utf-8') res.setHeader('Cache-Control', 'no-store') res.setHeader('Content-Length', String(Buffer.byteLength('{"error":"authentication required"}'))) res.end('{"error":"authentication required"}') } const serveLogin = (req, res, error, status = 200, next = '/') => { const page = renderLoginPage({ realm: guard.realm(), next: sanitizeNext(next), error, loginPath }) res.statusCode = status res.setHeader('Content-Type', 'text/html; charset=utf-8') res.setHeader('Cache-Control', 'no-store') res.setHeader('Content-Length', String(Buffer.byteLength(page))) res.end(page) } const handleLogin = async (req, res) => { let queryNext = '/' try { queryNext = new URL(req.url ?? '/', 'http://x').searchParams.get('next') ?? '/' } catch { /* malformed URL */ } if (req.method !== 'POST') { serveLogin(req, res, '', 200, queryNext) return } // Cross-origin form posts are rejected: the login form must come from this host. // Hostname-level comparison tolerates port/case differences that proxies // or alternate listeners introduce, while still blocking foreign sites. // `Origin: null` (opaque contexts: sandboxed iframes, privacy proxies, // browser isolation) carries no usable origin information — treat it like // an absent header instead of rejecting real users. const origin = req.headers.origin const host = req.headers.host const ip = clientIp(req) const ua = req.headers['user-agent'] ?? '' if (typeof origin === 'string' && origin !== 'null' && typeof host === 'string' && !isLoopbackHost(host) && !sameHost(origin, host) ) { audit.write({ event: 'login-fail', username: '', ip, ua, reason: 'cross-origin', origin: String(origin).slice(0, 200), host: String(host).slice(0, 200), }) serveLogin(req, res, '非法请求来源', 403, queryNext) return } if (lockout) { const lock = lockout.status(ip) if (lock.locked) { audit.write({ event: 'login-fail', username: '', ip, ua, reason: 'rate-limited' }) res.setHeader('Retry-After', String(lock.retryAfter)) serveLogin(req, res, `尝试次数过多,请 ${lock.retryAfter} 秒后再试`, 429, queryNext) return } } const body = await readBody(req, 8192) if (body === null) { audit.write({ event: 'login-fail', username: '', ip: clientIp(req), ua: req.headers['user-agent'] ?? '', reason: 'body-too-large' }) serveLogin(req, res, '请求过大', 413, queryNext) return } const fields = new URLSearchParams(body) const username = fields.get('username') ?? '' const password = fields.get('password') ?? '' const next = sanitizeNext(fields.get('next') ?? queryNext) if (!username || !password) { audit.write({ event: 'login-fail', username, ip, ua, reason: 'missing-fields' }) serveLogin(req, res, '请输入用户名和密码', 403, next) return } if (guard.checkCredentials(username, password)) { const value = guard.issueCookieValue(username) const settings = guard.cookieSettings() if (lockout) lockout.clear(ip) audit.write({ event: 'login-ok', username, ip, ua }) res.statusCode = 303 res.setHeader('Set-Cookie', `${settings.name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${settings.ttl}`) res.setHeader('Location', next) res.setHeader('Content-Length', '0') res.end() return } if (lockout) lockout.recordFailure(ip) audit.write({ event: 'login-fail', username, ip, ua, reason: 'bad-credentials' }) if (failedLoginDelayMs > 0) await sleep(failedLoginDelayMs) serveLogin(req, res, '用户名或密码错误', 403, next) } const handleLogout = (req, res) => { const settings = guard.cookieSettings() audit.write({ event: 'logout', username: '', ip: clientIp(req), ua: req.headers['user-agent'] ?? '' }) res.statusCode = 303 res.setHeader('Set-Cookie', `${settings.name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`) res.setHeader('Location', loginPath) res.setHeader('Content-Length', '0') res.end() } const handleChangePassword = async (req, res) => { const ip = clientIp(req) const ua = req.headers['user-agent'] ?? '' const who = guard.authenticate(req) if (who === null || who.open) { audit.write({ event: 'login-fail', username: '', ip, ua, reason: 'unauthenticated-change' }) challenge(res) return } if (req.method !== 'POST') { res.statusCode = 405 res.setHeader('Allow', 'POST') res.setHeader('Content-Length', '0') res.end() return } if (lockout) { const lock = lockout.status(ip) if (lock.locked) { audit.write({ event: 'login-fail', username: who.username, ip, ua, reason: 'rate-limited' }) res.setHeader('Retry-After', String(lock.retryAfter)) jsonResponse(res, 429, { message: `尝试次数过多,请 ${lock.retryAfter} 秒后再试`, retryAfter: lock.retryAfter }) return } } const body = await readBody(req, 8192) if (body === null) { jsonResponse(res, 413, { message: '请求过大' }) return } const fields = new URLSearchParams(body) const oldPassword = fields.get('oldPassword') ?? '' const newPassword = fields.get('newPassword') ?? '' let outcome try { outcome = await changePassword({ username: who.username, oldPassword, newPassword }) } catch (error) { console.error('[panel-auth] change password failed:', error && error.message ? error.message : error) outcome = { status: 500, message: '内部错误,请查看面板日志', results: [] } } if (outcome.status >= 200 && outcome.status < 300) { if (lockout) lockout.clear(ip) audit.write({ event: 'password-change', username: who.username, ip, ua, results: outcome.results ?? [] }) } else if (outcome.status === 403) { if (lockout) lockout.recordFailure(ip) audit.write({ event: 'login-fail', username: who.username, ip, ua, reason: 'password-change-old-mismatch' }) } jsonResponse(res, outcome.status, { message: outcome.message, results: outcome.results ?? [] }) } const onRequest = (req, res) => { let pathname = '/' try { pathname = new URL(req.url ?? '/', 'http://x').pathname } catch { /* malformed URL → treat as anonymous */ } if (pathname === loginPath) { handleLogin(req, res) return } if (pathname === logoutPath) { handleLogout(req, res) return } if (changePasswordPath !== undefined && pathname === changePasswordPath) { handleChangePassword(req, res) return } const ip = clientIp(req) const hasCreds = typeof req.headers.authorization === 'string' // Check the lockout BEFORE verifying credentials: a locked-out IP must // not burn CPU on scrypt. if (hasCreds && lockout) { const lock = lockout.status(ip) if (lock.locked) { audit.write({ event: 'login-fail', username: '', ip, ua: req.headers['user-agent'] ?? '', reason: 'rate-limited' }) res.statusCode = 429 res.setHeader('Retry-After', String(lock.retryAfter)) res.setHeader('Content-Type', 'application/json; charset=utf-8') res.setHeader('Cache-Control', 'no-store') res.end(`{"error":"too many attempts","retryAfter":${lock.retryAfter}}`) return } } const who = guard.authenticate(req) if (who === null) { if (hasCreds && lockout) lockout.recordFailure(ip) if (acceptsHtml(req)) { audit.write({ event: 'challenge', username: '', ip, ua: req.headers['user-agent'] ?? '', path: pathname }) serveLogin(req, res, '', 200, req.url ?? '/') return } audit.write({ event: 'reject', username: '', ip, ua: req.headers['user-agent'] ?? '', method: req.method ?? '', path: pathname }) challenge(res) return } if (!who.open && who.via === 'basic') { if (lockout) lockout.clear(ip) 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) => { let pathname = '/' try { pathname = new URL(req.url ?? '/', 'http://x').pathname } catch { /* malformed URL → reject */ } if (pathname === loginPath || pathname === logoutPath || pathname === changePasswordPath) { socket.end('HTTP/1.1 405 Method Not Allowed\r\nConnection: close\r\nContent-Length: 0\r\n\r\n') socket.destroy() return } const who = guard.authenticate(req) if (who === null) { audit.write({ event: 'reject', username: '', ip: clientIp(req), ua: req.headers['user-agent'] ?? '', method: 'UPGRADE', path: pathname }) 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) } } function positiveInt(value, fallback) { return Number.isFinite(value) && value >= 0 ? Math.floor(value) : fallback } // ── plugin ─────────────────────────────────────────────────────────────────── export default function panelAuth(ctx, config) { const cfg = config ?? {} const guard = createGuard({ getConfig: () => cfg, logger: null }) const auditPath = typeof cfg.auditLogPath === 'string' && cfg.auditLogPath.length > 0 ? cfg.auditLogPath : defaultAuditPath() const audit = createAuditWriter(auditPath) const loginPath = typeof cfg.loginPath === 'string' && cfg.loginPath.length > 0 ? cfg.loginPath : DEFAULTS.loginPath const logoutPath = typeof cfg.logoutPath === 'string' && cfg.logoutPath.length > 0 ? cfg.logoutPath : DEFAULTS.logoutPath const changePasswordPath = typeof cfg.changePasswordPath === 'string' && cfg.changePasswordPath.length > 0 ? cfg.changePasswordPath : DEFAULTS.changePasswordPath const patchFilePath = typeof cfg.patchFilePath === 'string' && cfg.patchFilePath.length > 0 ? cfg.patchFilePath : '' /** * Verify the old password, hash the new one, persist it to the profile * patch file, and hot-apply it to the running loader entry. */ const changePassword = async ({ username, oldPassword, newPassword }) => { if (!guard.checkCredentials(username, oldPassword)) { return { status: 403, message: '当前密码错误', results: [] } } if (typeof newPassword !== 'string' || newPassword.length < 8) { return { status: 400, message: '新密码至少 8 位', results: [] } } if (newPassword === oldPassword) { return { status: 400, message: '新密码不能与当前密码相同', results: [] } } const newHash = hashPassword(newPassword) const users = (Array.isArray(cfg.users) ? cfg.users : []).map((u) => (u.username === username ? { ...u, passwordHash: newHash } : u)) const results = [] if (patchFilePath) { try { const content = readFileSync(patchFilePath, 'utf8') const oldHash = (Array.isArray(cfg.users) ? cfg.users : []).find((u) => u.username === username)?.passwordHash if (typeof oldHash === 'string' && content.includes(oldHash)) { writeFileSync(patchFilePath, content.split(oldHash).join(newHash), { mode: 0o600 }) results.push('file-updated') } else { results.push('file-unchanged') } } catch (error) { results.push('file-error') console.error('[panel-auth] patch file update failed:', error && error.message ? error.message : error) } } try { const loader = ctx.get('loader') if (loader) { let entryId = null for (const entry of loader.entries()) { if (entry.options && entry.options.name && String(entry.options.name).startsWith('./panel-auth/index.js')) { entryId = entry.id break } } if (entryId !== null) { await loader.update(entryId, { config: { ...cfg, users } }) results.push('live-updated') } } } catch (error) { results.push('live-error') console.error('[panel-auth] live config update failed:', error && error.message ? error.message : error) } return { status: 200, message: '密码已更新', results } } const lockout = createLockout({ enabled: cfg.bruteProtection !== false, maxFailures: Math.max(1, positiveInt(cfg.maxFailures, 5)), lockoutBaseSeconds: Math.max(1, positiveInt(cfg.lockoutBaseSeconds, 30)), lockoutMaxSeconds: Math.max(1, positiveInt(cfg.lockoutMaxSeconds, 3600)), failureWindowSeconds: Math.max(1, positiveInt(cfg.failureWindowSeconds, 300)), }) const failedLoginDelayMs = positiveInt(cfg.failedLoginDelayMs, 300) console.log('[panel-auth] audit log: ' + auditPath) 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, { audit, loginPath, logoutPath, changePasswordPath, changePassword, lockout, failedLoginDelayMs }) 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?.() } }