207 lines
7.4 KiB
JavaScript
207 lines
7.4 KiB
JavaScript
// 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: '<output of: node panel-auth/hash.js admin <password>>'
|
|
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?.()
|
|
}
|
|
}
|