commit dde14311803bce6e158a85bd48d1bd30ea1f0e9c Author: dsh Date: Sun Aug 16 01:18:21 2026 -0400 panel-auth: DSH web panel password gate plugin (Basic auth + signed cookie, fail-open) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c9f0d57 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.generated +node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..c1fd0b2 --- /dev/null +++ b/README.md @@ -0,0 +1,54 @@ +# panel-auth — DSH 面板访问密码插件 + +给 `dsh web` 面板加一层原生 HTTP 认证(Basic + 签名 Cookie),挂载在 +`/root/.dsh/profiles/web/cordis.patch.yml` 的用户补丁层,升级 DSH 不会丢。 + +## 认证逻辑 + +- 每个 HTTP 请求 / WebSocket 升级都必须携带以下二者之一: + 1. 有效的 `dsh_panel` 签名 Cookie(登录成功后签发,浏览器自动带上,WS 握手也带); + 2. 有效的 HTTP Basic 凭据(`config.users` 中的用户名 + scrypt 哈希)。 +- 匿名请求返回 `401` + `WWW-Authenticate`,浏览器弹原生密码框。 +- 凭 Basic 成功登录时签发 Cookie(默认 30 天),之后无需重复输入。 +- **Fail-open**:`users` 为空或 `secret` 缺失/过短时插件不拦截任何请求, + 配置写错不会把面板锁死。 + +## 修改密码 + +```bash +cd /root/.dsh/profiles/web/panel-auth +node hash.js admin 新密码 # 输出新 passwordHash +``` + +把输出的 `passwordHash` 替换进 `../cordis.patch.yml` 后: + +```bash +systemctl restart dsh-web # 或利用 loader 对 cordis.patch.yml 的 HMR 自动生效 +``` + +## 新增用户 + +在 `cordis.patch.yml` 的 `users` 列表里再加一组 `username`/`passwordHash`。 + +## 更换签名密钥 + +```bash +node hash.js --secret # 生成新 secret +``` + +替换 `cordis.patch.yml` 的 `secret` 后重启。注意:更换密钥会使所有已签发 +Cookie 立即失效,所有人需重新输入密码。 + +## 应急解锁(忘记密码时) + +编辑 `/root/.dsh/profiles/web/cordis.patch.yml`: +- 临时把 `users` 置为 `[]`(fail-open,面板恢复无密码状态),或 +- 用上面的命令生成新哈希替换。 + +改动会由 loader 的用户补丁层 HMR 热应用,无需重启。 + +## 文件 + +- `index.js` — 插件本体(webServer 包装 + 认证守卫) +- `crypto.js` — scrypt 哈希 / HMAC Cookie 签名(无依赖,纯 node:crypto) +- `hash.js` — 生成密码哈希与随机密钥的 CLI diff --git a/crypto.js b/crypto.js new file mode 100644 index 0000000..38f0d58 --- /dev/null +++ b/crypto.js @@ -0,0 +1,57 @@ +// panel-auth — credential hashing helpers (pure functions, no harness deps). +import { createHmac, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto' + +/** Self-describing scrypt encoding: $scrypt$N=16384,r=8,p=1$$ */ +export function hashPassword(password, { N = 16384, r = 8, p = 1, keylen = 32 } = {}) { + const salt = randomBytes(16) + const hash = scryptSync(String(password), salt, keylen, { N, r, p }) + return `$scrypt$N=${N},r=${r},p=${p}$${salt.toString('base64')}$${hash.toString('base64')}` +} + +/** Constant-time verification against a hashPassword() encoding. */ +export function verifyPassword(password, encoded) { + if (typeof encoded !== 'string' || typeof password !== 'string') return false + const parts = encoded.split('$') + if (parts.length !== 5 || parts[1] !== 'scrypt') return false + const params = {} + for (const kv of parts[2].split(',')) { + const [k, v] = kv.split('=') + params[k] = Number(v) + } + if (!params.N || !params.r || !params.p) return false + try { + const expected = Buffer.from(parts[4], 'base64') + const actual = scryptSync(password, Buffer.from(parts[3], 'base64'), expected.length, { + N: params.N, + r: params.r, + p: params.p, + }) + return expected.length === actual.length && timingSafeEqual(actual, expected) + } catch { + return false + } +} + +/** HMAC-signed cookie payload: base64url(username).expirySeconds.signature */ +export function signSession(secret, username, expirySeconds) { + const payload = `${Buffer.from(username, 'utf8').toString('base64url')}.${expirySeconds}` + const sig = createHmac('sha256', secret).update(payload).digest('base64url') + return `${payload}.${sig}` +} + +/** Returns the username when the cookie is intact and unexpired, else null. */ +export function verifySession(secret, cookieValue, nowSeconds) { + if (typeof cookieValue !== 'string') return null + const dot = cookieValue.lastIndexOf('.') + if (dot <= 0) return null + const payload = cookieValue.slice(0, dot) + const sig = cookieValue.slice(dot + 1) + const expected = createHmac('sha256', secret).update(payload).digest('base64url') + if (sig.length !== expected.length || !timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null + const sep = payload.lastIndexOf('.') + if (sep <= 0) return null + const username = Buffer.from(payload.slice(0, sep), 'base64url').toString('utf8') + const expiry = Number(payload.slice(sep + 1)) + if (!Number.isFinite(expiry) || expiry <= nowSeconds) return null + return username +} diff --git a/hash.js b/hash.js new file mode 100644 index 0000000..499cd97 --- /dev/null +++ b/hash.js @@ -0,0 +1,19 @@ +#!/usr/bin/env node +// Usage: +// node hash.js → prints the cordis.patch.yml user entry +// node hash.js --secret → prints a random signing secret +import { hashPassword } from './crypto.js' +import { randomBytes } from 'node:crypto' + +const argv = process.argv.slice(2) +if (argv[0] === '--secret') { + console.log(randomBytes(32).toString('hex')) + process.exit(0) +} +const [username, password] = argv +if (!username || password === undefined) { + console.error('usage: node hash.js ') + console.error(' node hash.js --secret') + process.exit(1) +} +console.log(` - username: ${username}\n passwordHash: '${hashPassword(password)}'`) diff --git a/index.js b/index.js new file mode 100644 index 0000000..ecfc5e4 --- /dev/null +++ b/index.js @@ -0,0 +1,206 @@ +// 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?.() + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4fef439 --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "name": "panel-auth", + "version": "1.0.0", + "private": true, + "type": "module" +} diff --git a/test.mjs b/test.mjs new file mode 100644 index 0000000..21b9d61 --- /dev/null +++ b/test.mjs @@ -0,0 +1,100 @@ +// Throwaway verification of panel-auth against a server structured exactly +// like dsh-host-webserver: a createServer callback ('request') plus an +// 'upgrade' listener. Does not touch the running panel. +import { createServer } from 'node:http' +import { strict as assert } from 'node:assert' +import { createGuard, installGuard } from './index.js' +import { hashPassword, verifyPassword } from './crypto.js' + +const cfg = { + users: [{ username: 'admin', passwordHash: hashPassword('s3cret-pass') }], + secret: 'unit-test-secret-0123456789abcdef', + realm: 'Test Realm', + cookieName: 'test_cookie', + cookieTtlSeconds: 3600, +} +const guard = createGuard({ getConfig: () => cfg, logger: null }) + +let upgradeCount = 0 +const server = createServer((req, res) => { + res.statusCode = 200 + res.setHeader('Content-Type', 'text/plain') + res.end('panel') +}) +server.on('upgrade', (req, socket, head) => { + upgradeCount++ + socket.end('HTTP/1.1 101 Switching Protocols\r\nUpgrade: test\r\nConnection: Upgrade\r\n\r\n') +}) + +const disposer = installGuard(server, guard, { logger: null }) +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) +const base = `http://127.0.0.1:${server.address().port}` + +const raw = (path, headers = {}) => fetch(base + path, { headers, redirect: 'manual' }) +const upgrade = async (headers = []) => { + const net = await import('node:net') + const sock = net.connect(server.address().port, '127.0.0.1') + const response = await new Promise((resolve) => { + let buf = '' + sock.on('data', (d) => { + buf += d.toString() + if (buf.includes('\r\n\r\n')) resolve(buf) + }) + const h = ['GET /api/events.mux HTTP/1.1', 'Host: 127.0.0.1', 'Connection: Upgrade', 'Upgrade: websocket', ...headers] + sock.write(h.join('\r\n') + '\r\n\r\n') + }) + sock.destroy() + return response +} + +// 1. anonymous → 401 + challenge +let r = await raw('/') +assert.equal(r.status, 401) +assert.match(r.headers.get('www-authenticate'), /^Basic realm="Test Realm"/) + +// 2. wrong password → 401 +r = await raw('/', { authorization: 'Basic ' + Buffer.from('admin:wrong').toString('base64') }) +assert.equal(r.status, 401) + +// 3. correct password → 200 + Set-Cookie +r = await raw('/', { authorization: 'Basic ' + Buffer.from('admin:s3cret-pass').toString('base64') }) +assert.equal(r.status, 200) +assert.equal(await r.text(), 'panel') +const setCookie = r.headers.get('set-cookie') +assert.match(setCookie, /^test_cookie=.+; Path=\/; HttpOnly; SameSite=Lax; Max-Age=3600$/) +const cookieValue = setCookie.split(';')[0].slice('test_cookie='.length) + +// 4. cookie alone → 200, no new cookie +r = await raw('/', { cookie: `test_cookie=${cookieValue}` }) +assert.equal(r.status, 200) +assert.equal(r.headers.get('set-cookie'), null) + +// 5. tampered cookie → 401 +r = await raw('/', { cookie: 'test_cookie=' + cookieValue.slice(0, -2) + 'xx' }) +assert.equal(r.status, 401) + +// 6. websocket upgrade anonymous → 401 on socket +assert.match(await upgrade(), /^HTTP\/1\.1 401 /) + +// 7. websocket upgrade with cookie → passes through to original handler +assert.match(await upgrade([`Cookie: test_cookie=${cookieValue}`]), /^HTTP\/1\.1 101 /) +assert.equal(upgradeCount, 1) + +// 8. hot-disable via config → anonymous passes through (fail-open) +cfg.users = [] +r = await raw('/') +assert.equal(r.status, 200) +cfg.users = [{ username: 'admin', passwordHash: hashPassword('s3cret-pass') }] + +// 9. dispose restores original behavior (anonymous → 200 again) +disposer() +r = await raw('/') +assert.equal(r.status, 200) +assert.equal(await r.text(), 'panel') + +// 10. hash round-trip sanity +assert.equal(verifyPassword('s3cret-pass', cfg.users[0].passwordHash), true) +assert.equal(verifyPassword('other', cfg.users[0].passwordHash), false) + +server.close() +console.log('ALL PANEL-AUTH TESTS PASSED')