@lvxianchao/dsh-web-guard 2.1.1 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -9,130 +9,126 @@
9
9
  * Origin rewritten — which the fence accepts by design — while injecting the
10
10
  * harness cookie the browser never sees.
11
11
  *
12
- * Zero runtime dependencies: node standard library only, so `dsh plugin add`
13
- * works without a build step and without a version-pinned toolchain.
12
+ * v3 adds a JSON API under /__guard/api for the native settings page. The API
13
+ * and the server-rendered form routes share one set of mutators, so behaviour
14
+ * cannot drift; the JSON contract lives in src/shared/api.ts.
14
15
  */
15
- import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from 'node:fs'
16
- import { join, dirname } from 'node:path'
17
- import { homedir } from 'node:os'
18
- import { randomBytes } from 'node:crypto'
19
- import { createListener, forward, forwardUpgrade, upstreamAgent } from './proxy.js'
20
- import { hashPassword, verifyPassword, SessionStore } from './auth.js'
21
- import { loginPage, logoutPage } from './page.js'
22
- import { adminPage } from './admin.js'
23
- import { injectUi } from './inject.js'
24
- import { createLocaleReader, normalize, t } from './i18n.js'
25
-
26
- export const name = 'dsh-web-guard'
27
- export const inject = ['webServer', 'connection']
28
-
29
- const COOKIE = 'dsh_guard'
30
- const MAX_FORM = 8 * 1024
31
- const MAX_BODY = 8 * 1024
32
-
16
+ import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
17
+ import { join, dirname } from 'node:path';
18
+ import { homedir } from 'node:os';
19
+ import { randomBytes } from 'node:crypto';
20
+ import { createListener, forward, forwardUpgrade, upstreamAgent } from './proxy.js';
21
+ import { hashPassword, verifyPassword, SessionStore } from './auth.js';
22
+ import { loginPage, logoutPage } from './page.js';
23
+ import { injectUi } from './inject.js';
24
+ import { createLocaleReader, normalize, t } from './i18n.js';
25
+ import { createPreferenceReader } from './preferences.js';
26
+ import { GUARD_API } from './shared/api.js';
27
+ export const name = 'dsh-web-guard';
28
+ export const inject = ['webServer', 'connection'];
29
+ const COOKIE = 'dsh_guard';
30
+ const MAX_FORM = 8 * 1024;
31
+ const MAX_BODY = 8 * 1024;
33
32
  const DEFAULTS = {
34
- listenHost: '0.0.0.0',
35
- listenPort: 3081,
36
- sessionDays: 7,
37
- polyfill: true,
38
- title: 'DSH Web Guard',
39
- repo: 'https://github.com/lvxianchao/dsh-web-guard',
40
- password: '',
41
- passwordHash: '',
42
- maxAttempts: 5,
43
- lockMinutes: 5,
44
- tls: null,
45
- publicUrl: '',
46
- }
47
-
33
+ listenHost: '0.0.0.0',
34
+ listenPort: 3081,
35
+ sessionDays: 7,
36
+ polyfill: true,
37
+ title: 'DSH Web Guard',
38
+ repo: 'https://github.com/lvxianchao/dsh-web-guard',
39
+ password: '',
40
+ passwordHash: '',
41
+ maxAttempts: 5,
42
+ lockMinutes: 5,
43
+ tls: null,
44
+ };
48
45
  /**
49
- * Guard state (password hash + admin-page settings) belongs to one profile:
46
+ * Guard state (password hash + settings) belongs to one profile:
50
47
  * several profiles can run this plugin and must not share a port or a password.
51
48
  * Reading prefers the profile-local file, then the legacy
52
49
  * $DSH_HOME/dsh-web-guard.json written by earlier versions; writes always use the
53
50
  * profile-local path.
54
51
  */
55
52
  function legacyStatePath() {
56
- return join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'dsh-web-guard.json')
53
+ return join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'dsh-web-guard.json');
57
54
  }
58
-
59
55
  function statePath(profileDir) {
60
- return join(profileDir, 'data', 'dsh-web-guard.json')
56
+ return join(profileDir, 'data', 'dsh-web-guard.json');
61
57
  }
62
-
63
58
  /**
64
59
  * Session cache lives beside the password but keyed by listener port: a shadow
65
60
  * instance (dev.yml, port 3100) runs against the same profile and must not
66
61
  * fight the live one over one file. Ports are exclusive, so this is a safe key.
67
62
  */
68
63
  function sessionsPath(profileDir, port) {
69
- return join(profileDir, 'data', `dsh-web-guard-sessions-${port}.json`)
64
+ return join(profileDir, 'data', `dsh-web-guard-sessions-${port}.json`);
70
65
  }
71
-
72
66
  function readState(profileDir) {
73
- for (const path of [statePath(profileDir), legacyStatePath()]) {
74
- try {
75
- return JSON.parse(readFileSync(path, 'utf8'))
76
- } catch {
77
- /* try the next location */
67
+ for (const path of [statePath(profileDir), legacyStatePath()]) {
68
+ try {
69
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
70
+ if (parsed !== null && typeof parsed === 'object')
71
+ return parsed;
72
+ }
73
+ catch {
74
+ /* try the next location */
75
+ }
78
76
  }
79
- }
80
- return {}
77
+ return {};
81
78
  }
82
-
83
79
  function writeState(profileDir, state) {
84
- const path = statePath(profileDir)
85
- mkdirSync(dirname(path), { recursive: true })
86
- writeFileSync(path, JSON.stringify(state, null, 2), { mode: 0o600 })
87
- // One-time migration: once this profile owns a local state file, the shared
88
- // legacy file must go, or the next profile to start would inherit this
89
- // profile's port and password.
90
- const legacy = legacyStatePath()
91
- if (legacy !== path) {
92
- try {
93
- rmSync(legacy)
94
- } catch {
95
- /* already gone */
80
+ const path = statePath(profileDir);
81
+ mkdirSync(dirname(path), { recursive: true });
82
+ writeFileSync(path, JSON.stringify(state, null, 2), { mode: 0o600 });
83
+ // One-time migration: once this profile owns a local state file, the shared
84
+ // legacy file must go, or the next profile to start would inherit this
85
+ // profile's port and password.
86
+ const legacy = legacyStatePath();
87
+ if (legacy !== path) {
88
+ try {
89
+ rmSync(legacy);
90
+ }
91
+ catch {
92
+ /* already gone */
93
+ }
96
94
  }
97
- }
98
95
  }
99
-
100
96
  /** Generate a memorable-but-strong password (base32-ish, no ambiguous chars). */
101
97
  function randomPassword() {
102
- const alphabet = 'abcdefghjkmnpqrstuvwxyz23456789'
103
- const bytes = randomBytes(16)
104
- let out = ''
105
- for (let i = 0; i < 16; i++) out += alphabet[bytes[i] % alphabet.length]
106
- return `${out.slice(0, 4)}-${out.slice(4, 8)}-${out.slice(8, 12)}-${out.slice(12, 16)}`
98
+ const alphabet = 'abcdefghjkmnpqrstuvwxyz23456789';
99
+ const bytes = randomBytes(16);
100
+ let out = '';
101
+ for (let i = 0; i < 16; i++)
102
+ out += alphabet[bytes[i] % alphabet.length];
103
+ return `${out.slice(0, 4)}-${out.slice(4, 8)}-${out.slice(8, 12)}-${out.slice(12, 16)}`;
107
104
  }
108
-
109
105
  function parseCookies(header) {
110
- const out = {}
111
- if (typeof header !== 'string') return out
112
- for (const part of header.split(';')) {
113
- const at = part.indexOf('=')
114
- if (at === -1) continue
115
- out[part.slice(0, at).trim()] = decodeURIComponent(part.slice(at + 1).trim())
116
- }
117
- return out
106
+ const out = {};
107
+ if (typeof header !== 'string')
108
+ return out;
109
+ for (const part of header.split(';')) {
110
+ const at = part.indexOf('=');
111
+ if (at === -1)
112
+ continue;
113
+ out[part.slice(0, at).trim()] = decodeURIComponent(part.slice(at + 1).trim());
114
+ }
115
+ return out;
118
116
  }
119
-
120
117
  function clientIp(req) {
121
- const forwarded = req.headers['x-forwarded-for']
122
- if (typeof forwarded === 'string' && forwarded.length > 0) return forwarded.split(',')[0].trim()
123
- return req.socket.remoteAddress ?? 'unknown'
118
+ const forwarded = req.headers['x-forwarded-for'];
119
+ if (typeof forwarded === 'string' && forwarded.length > 0)
120
+ return forwarded.split(',')[0].trim();
121
+ return req.socket.remoteAddress ?? 'unknown';
124
122
  }
125
-
126
123
  function send(res, status, headers, body) {
127
- res.writeHead(status, headers)
128
- res.end(body)
124
+ res.writeHead(status, headers);
125
+ res.end(body);
129
126
  }
130
-
131
- const json = (res, status, payload) => send(res, status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, JSON.stringify(payload))
132
- const html = (res, status, body) => send(res, status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }, body)
133
- const redirect = (res, location) => send(res, 302, { location, 'cache-control': 'no-store' }, '')
134
-
135
- /** Same-origin check for state-changing form posts.
127
+ const json = (res, status, payload) => send(res, status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, JSON.stringify(payload));
128
+ const html = (res, status, body) => send(res, status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' }, body);
129
+ const redirect = (res, location) => send(res, 302, { location, 'cache-control': 'no-store' }, '');
130
+ /**
131
+ * Same-origin check for state-changing form posts.
136
132
  *
137
133
  * Compares hostnames only: a TLS-terminating proxy commonly forwards `Host`
138
134
  * without the public port while the browser's Origin still carries it, so a
@@ -140,386 +136,496 @@ const redirect = (res, location) => send(res, 302, { location, 'cache-control':
140
136
  * on the session cookie remains the primary CSRF defence.
141
137
  */
142
138
  function originAllowed(req) {
143
- const origin = req.headers.origin
144
- if (origin === undefined) return true // non-browser client; password still required
145
- try {
146
- const parsed = new URL(origin)
147
- const host = String(req.headers.host ?? '').toLowerCase()
148
- const hostname = host.includes(']') ? host.slice(0, host.indexOf(']') + 1) : host.split(':')[0]
149
- return parsed.hostname.toLowerCase() === hostname
150
- } catch {
151
- return false
152
- }
139
+ const origin = req.headers.origin;
140
+ if (origin === undefined)
141
+ return true; // non-browser client; password still required
142
+ try {
143
+ const parsed = new URL(origin);
144
+ const host = String(req.headers.host ?? '').toLowerCase();
145
+ const hostname = host.includes(']') ? host.slice(0, host.indexOf(']') + 1) : host.split(':')[0];
146
+ return parsed.hostname.toLowerCase() === hostname;
147
+ }
148
+ catch {
149
+ return false;
150
+ }
151
+ }
152
+ function isRecord(value) {
153
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
154
+ }
155
+ function isApiPath(path) {
156
+ return path === '/__guard/api' || path.startsWith('/__guard/api/');
153
157
  }
154
-
155
158
  export function apply(ctx, config = {}) {
156
- // Precedence: file-managed settings (written by the admin page) > plugin
157
- // config in cordis.patch.yml > built-in defaults.
158
- const patchPath = ctx.profileContext?.patchPath
159
- ?? ctx.get?.('profileContext')?.patchPath
160
- ?? join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'profiles', 'web', 'cordis.patch.yml')
161
- const profileDir = dirname(patchPath)
162
-
163
- const persisted = readState(profileDir).settings ?? {}
164
- const cfg = { ...DEFAULTS, ...config, ...persisted }
165
- // Write straight to the process streams: cordis loggers can swallow lines
166
- // depending on level, and the generated password must never be lost.
167
- const log = (line) => console.log(`[dsh-web-guard] ${line}`)
168
- const warn = (line) => console.error(`[dsh-web-guard] ${line}`)
169
-
170
- // ── password resolution: hash > plaintext > generated-and-persisted ────────
171
- const state = readState(profileDir)
172
- let passwordHash = cfg.passwordHash || ''
173
- let generated = ''
174
- if (!passwordHash && cfg.password) {
175
- passwordHash = hashPassword(String(cfg.password))
176
- log('password taken from plugin config')
177
- }
178
- if (!passwordHash) {
179
- passwordHash = state.passwordHash || ''
180
- }
181
- if (!passwordHash) {
182
- generated = randomPassword()
183
- passwordHash = hashPassword(generated)
184
- writeState(profileDir, { ...state, passwordHash, createdAt: new Date().toISOString() })
185
- log(`no password configured — generated one and saved to ${statePath(profileDir)}`)
186
- log(`PASSWORD: ${generated}`)
187
- }
188
-
189
- const sessions = new SessionStore({
190
- sessionDays: Number(cfg.sessionDays),
191
- maxAttempts: Number(cfg.maxAttempts),
192
- lockMinutes: Number(cfg.lockMinutes),
193
- file: sessionsPath(profileDir, cfg.listenPort),
194
- })
195
- // Restore sessions from the previous process: a restart (upgrade, config
196
- // change) must not sign every browser out. Expired rows are dropped on load.
197
- sessions.load()
198
-
199
- // Locale follows the harness: its language picker writes `preference` into
200
- // the profile patch file. Uncached reads would hit the filesystem on every
201
- // request, so the file is polled at most every few seconds.
202
- // Event-driven: no filesystem work on the request path.
203
- const localeReader = createLocaleReader(patchPath)
204
- function localeFor(req) {
205
- const explicit = localeReader.get()
206
- if (explicit) return explicit
207
- const header = String(req?.headers?.['accept-language'] ?? '')
208
- return normalize(header.split(',')[0]?.trim()) || 'en'
209
- }
210
-
211
- const targetOrigin = ctx.webServer.getOrigin()
212
- const upstream = new URL(targetOrigin)
213
- const listenPort = Number(cfg.listenPort)
214
- if (listenPort === Number(upstream.port || 80)) {
215
- throw new Error(`dsh-web-guard: listenPort ${listenPort} collides with the harness listener ${targetOrigin}`)
216
- }
217
- const secureCookie = Boolean(cfg.tls?.certFile && cfg.tls?.keyFile)
218
-
219
- // ── harness cookie: exchanged in-process, never exposed to the browser ─────
220
- let cookieCache = { value: '', at: 0 }
221
- let inflight = null
222
- async function harnessCookie(force = false) {
223
- if (!force && cookieCache.value && Date.now() - cookieCache.at < 30 * 60 * 1000) return cookieCache.value
224
- if (inflight) return inflight
225
- inflight = (async () => {
226
- try {
227
- const url = ctx.connection.authenticatedUrl(`${targetOrigin}/`)
228
- const res = await fetch(url, { redirect: 'manual' })
229
- const parts = res.headers.getSetCookie?.() ?? []
230
- const value = parts.map((line) => line.split(';')[0]).filter(Boolean).join('; ')
231
- if (!value) throw new Error(`launch-token exchange returned ${res.status} without a cookie`)
232
- cookieCache = { value, at: Date.now() }
233
- log('harness session cookie exchanged')
234
- return value
235
- } finally {
236
- inflight = null
237
- }
238
- })()
239
- return inflight
240
- }
241
-
242
- function sessionOf(req) {
243
- const token = parseCookies(req.headers.cookie)[COOKIE]
244
- if (!token) return undefined
245
- const row = sessions.resolve(token)
246
- if (!row) return undefined
247
- return { token, row }
248
- }
249
-
250
- function setSessionCookie(res, token) {
251
- const attrs = [`${COOKIE}=${token}`, 'Path=/', 'HttpOnly', 'SameSite=Lax']
252
- if (Number(cfg.sessionDays) > 0) attrs.push(`Max-Age=${Math.floor(Number(cfg.sessionDays) * 86400)}`)
253
- if (secureCookie) attrs.push('Secure')
254
- res.setHeader('set-cookie', attrs.join('; '))
255
- }
256
-
257
- function clearSessionCookie(res) {
258
- const attrs = [`${COOKIE}=`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0']
259
- if (secureCookie) attrs.push('Secure')
260
- res.setHeader('set-cookie', attrs.join('; '))
261
- }
262
-
263
- // ── request handling ──────────────────────────────────────────────────────
264
- async function handleLogin(req, res, locale) {
265
- const ip = clientIp(req)
266
- if (sessions.isLocked(ip)) {
267
- return html(res, 429, loginPage({ title: cfg.title, locked: true, repo: cfg.repo, locale }))
268
- }
269
- if (!originAllowed(req)) {
270
- return html(res, 403, loginPage({ title: cfg.title, error: t(locale).errOrigin, repo: cfg.repo, locale }))
271
- }
272
- const body = await readBody(req).catch(() => '')
273
- const form = new URLSearchParams(body)
274
- const password = form.get('password') ?? ''
275
- const next = form.get('next') ?? '/'
276
- const safeNext = next.startsWith('/') && !next.startsWith('//') ? next : '/'
277
- if (!verifyPassword(password, passwordHash)) {
278
- const left = sessions.recordFailure(ip)
279
- log(`login failed from ${ip} (${left} attempts left)`)
280
- return html(res, 401, loginPage({
281
- title: cfg.title,
282
- error: left > 0 ? t(locale).errWrong : t(locale).errLocked,
283
- next: safeNext,
284
- repo: cfg.repo,
285
- locale,
286
- }))
287
- }
288
- sessions.clearFailures(ip)
289
- const token = sessions.issue(ip, String(req.headers['user-agent'] ?? ''))
290
- setSessionCookie(res, token)
291
- log(`login ok from ${ip}`)
292
- redirect(res, safeNext)
293
- }
294
-
295
- // ── management routes (authenticated) ─────────────────────────────────────
296
- const clampInt = (value, min, max, fallback) => {
297
- const n = Number.parseInt(String(value ?? ''), 10)
298
- if (!Number.isFinite(n) || n < min || n > max) return fallback
299
- return n
300
- }
301
-
302
- function currentSettings() {
303
- return {
304
- sessionDays: cfg.sessionDays,
305
- maxAttempts: cfg.maxAttempts,
306
- lockMinutes: cfg.lockMinutes,
307
- repo: cfg.repo, // footer link only — not editable from the UI
308
- listenHost: cfg.listenHost,
309
- listenPort: cfg.listenPort,
310
- }
311
- }
312
-
313
- function renderAdmin(res, session, { status = 200, notice = '', error = '', restartNote = '', locale = 'zh' } = {}) {
314
- const currentId = sessions.idOf(session.token)
315
- const rows = sessions.list().map((x) => ({ ...x, current: x.id === currentId }))
316
- const page = adminPage({
317
- settings: currentSettings(),
318
- sessions: rows,
319
- notice,
320
- error,
321
- restartNote,
322
- locale,
323
- })
324
- return html(res, status, page)
325
- }
326
-
327
- async function handleGuard(req, res, path, session, locale) {
328
- if (path === '/__guard/admin') {
329
- if (req.method === 'GET') return renderAdmin(res, session, { locale })
330
- if (req.method !== 'POST') return json(res, 405, { error: 'method_not_allowed' })
331
- if (!originAllowed(req)) return renderAdmin(res, session, { status: 403, error: t(locale).errOrigin, locale })
332
-
333
- const form = new URLSearchParams(await readBody(req).catch(() => ''))
334
- const problems = []
335
-
336
- // Password change: any of the three fields present means "intent to change".
337
- const cur = form.get('current') ?? ''
338
- const nextPassword = form.get('next') ?? ''
339
- const confirm = form.get('confirm') ?? ''
340
- let passwordChanged = false
341
- if (cur || nextPassword || confirm) {
342
- if (!verifyPassword(cur, passwordHash)) problems.push(t(locale).errCurrent)
343
- else if (nextPassword.length < 6) problems.push(t(locale).errShort)
344
- else if (nextPassword !== confirm) problems.push(t(locale).errMismatch)
345
- else {
346
- passwordHash = hashPassword(nextPassword)
347
- passwordChanged = true
159
+ // Precedence: file-managed settings (written by the settings page) > plugin
160
+ // config in cordis.patch.yml > built-in defaults.
161
+ const patchPath = ctx.profileContext?.patchPath
162
+ ?? ctx.get?.('profileContext')?.patchPath
163
+ ?? join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'profiles', 'web', 'cordis.patch.yml');
164
+ const profileDir = dirname(patchPath);
165
+ const state = readState(profileDir);
166
+ const persisted = state.settings ?? {};
167
+ const cfg = { ...DEFAULTS, ...config, ...persisted };
168
+ // Write straight to the process streams: cordis loggers can swallow lines
169
+ // depending on level, and the generated password must never be lost.
170
+ const log = (line) => console.log(`[dsh-web-guard] ${line}`);
171
+ const warn = (line) => console.error(`[dsh-web-guard] ${line}`);
172
+ // ── password resolution: hash > plaintext > generated-and-persisted ────────
173
+ let passwordHash = cfg.passwordHash || '';
174
+ if (!passwordHash && cfg.password) {
175
+ passwordHash = hashPassword(String(cfg.password));
176
+ log('password taken from plugin config');
177
+ }
178
+ if (!passwordHash) {
179
+ passwordHash = typeof state.passwordHash === 'string' ? state.passwordHash : '';
180
+ }
181
+ if (!passwordHash) {
182
+ const generated = randomPassword();
183
+ passwordHash = hashPassword(generated);
184
+ writeState(profileDir, { ...state, passwordHash, createdAt: new Date().toISOString() });
185
+ log(`no password configured — generated one and saved to ${statePath(profileDir)}`);
186
+ log(`PASSWORD: ${generated}`);
187
+ }
188
+ const sessions = new SessionStore({
189
+ sessionDays: Number(cfg.sessionDays),
190
+ maxAttempts: Number(cfg.maxAttempts),
191
+ lockMinutes: Number(cfg.lockMinutes),
192
+ file: sessionsPath(profileDir, Number(cfg.listenPort)),
193
+ });
194
+ // Restore sessions from the previous process: a restart (upgrade, config
195
+ // change) must not sign every browser out. Expired rows are dropped on load.
196
+ sessions.load();
197
+ // Locale follows the harness: its language picker writes `preference` into
198
+ // the profile patch file. Event-driven: no filesystem work on the request path.
199
+ const localeReader = createLocaleReader(patchPath);
200
+ // The Appearance control writes `- id: ui-theme` → `config.preference` into
201
+ // the same patch file. Reading it lets the login/sign-out documents follow
202
+ // the operator's harness choice before the harness page exists; `system` and
203
+ // a missing row leave them on `prefers-color-scheme`.
204
+ const themeReader = createPreferenceReader(patchPath, 'ui-theme');
205
+ const harnessTheme = () => themeReader.get();
206
+ function localeFor(req) {
207
+ const explicit = localeReader.get();
208
+ if (explicit)
209
+ return explicit;
210
+ const header = String(req?.headers?.['accept-language'] ?? '');
211
+ return normalize(header.split(',')[0]?.trim()) || 'en';
212
+ }
213
+ const targetOrigin = ctx.webServer.getOrigin();
214
+ const upstream = new URL(targetOrigin);
215
+ const listenPort = Number(cfg.listenPort);
216
+ if (listenPort === Number(upstream.port || 80)) {
217
+ throw new Error(`dsh-web-guard: listenPort ${listenPort} collides with the harness listener ${targetOrigin}`);
218
+ }
219
+ const secureCookie = Boolean(cfg.tls?.certFile && cfg.tls?.keyFile);
220
+ // The listener binds once at boot. Settings surfaces (native page, JSON API)
221
+ // settings page, JSON API) report the *configured* values and flag a pending
222
+ // restart when they disagree with what is actually listening, so saving a new
223
+ // port shows up immediately instead of appearing to be lost.
224
+ const runningListener = { host: String(cfg.listenHost), port: Number(cfg.listenPort) };
225
+ const configuredListener = { host: String(cfg.listenHost), port: Number(cfg.listenPort) };
226
+ const listenerRestartRequired = () => configuredListener.host !== runningListener.host || configuredListener.port !== runningListener.port;
227
+ // ── harness cookie: exchanged in-process, never exposed to the browser ─────
228
+ let cookieCache = { value: '', at: 0 };
229
+ let inflight = null;
230
+ async function harnessCookie(force = false) {
231
+ if (!force && cookieCache.value && Date.now() - cookieCache.at < 30 * 60 * 1000)
232
+ return cookieCache.value;
233
+ if (inflight)
234
+ return inflight;
235
+ const pending = (async () => {
236
+ try {
237
+ const url = ctx.connection.authenticatedUrl(`${targetOrigin}/`);
238
+ const res = await fetch(url, { redirect: 'manual' });
239
+ const parts = res.headers.getSetCookie();
240
+ const value = parts.map((line) => line.split(';')[0]).filter(Boolean).join('; ');
241
+ if (!value)
242
+ throw new Error(`launch-token exchange returned ${res.status} without a cookie`);
243
+ cookieCache = { value, at: Date.now() };
244
+ log('harness session cookie exchanged');
245
+ return value;
246
+ }
247
+ finally {
248
+ inflight = null;
249
+ }
250
+ })();
251
+ inflight = pending;
252
+ return pending;
253
+ }
254
+ function sessionOf(req) {
255
+ const token = parseCookies(req.headers.cookie)[COOKIE];
256
+ if (!token)
257
+ return undefined;
258
+ const row = sessions.resolve(token);
259
+ if (!row)
260
+ return undefined;
261
+ return { token, row };
262
+ }
263
+ function setSessionCookie(res, token) {
264
+ const attrs = [`${COOKIE}=${token}`, 'Path=/', 'HttpOnly', 'SameSite=Lax'];
265
+ if (Number(cfg.sessionDays) > 0)
266
+ attrs.push(`Max-Age=${Math.floor(Number(cfg.sessionDays) * 86400)}`);
267
+ if (secureCookie)
268
+ attrs.push('Secure');
269
+ res.setHeader('set-cookie', attrs.join('; '));
270
+ }
271
+ function clearSessionCookie(res) {
272
+ const attrs = [`${COOKIE}=`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0'];
273
+ if (secureCookie)
274
+ attrs.push('Secure');
275
+ res.setHeader('set-cookie', attrs.join('; '));
276
+ }
277
+ function currentSettings() {
278
+ return {
279
+ sessionDays: Number(cfg.sessionDays),
280
+ maxAttempts: Number(cfg.maxAttempts),
281
+ lockMinutes: Number(cfg.lockMinutes),
282
+ listenHost: configuredListener.host,
283
+ listenPort: configuredListener.port,
284
+ title: String(cfg.title),
285
+ repo: String(cfg.repo),
286
+ };
287
+ }
288
+ function persistGuardState() {
289
+ const settings = {
290
+ sessionDays: Number(cfg.sessionDays),
291
+ maxAttempts: Number(cfg.maxAttempts),
292
+ lockMinutes: Number(cfg.lockMinutes),
293
+ listenHost: configuredListener.host,
294
+ listenPort: configuredListener.port,
295
+ };
296
+ writeState(profileDir, { ...readState(profileDir), passwordHash, settings });
297
+ }
298
+ // ── shared mutators: form routes and JSON API must not drift ──────────────
299
+ /** Apply a validated settings change; listener values need a restart. */
300
+ function updateSettings(input) {
301
+ const sessionDays = input.sessionDays ?? Number(cfg.sessionDays);
302
+ const maxAttempts = input.maxAttempts ?? Number(cfg.maxAttempts);
303
+ const lockMinutes = input.lockMinutes ?? Number(cfg.lockMinutes);
304
+ configuredListener.host = input.listenHost ?? configuredListener.host;
305
+ configuredListener.port = input.listenPort ?? configuredListener.port;
306
+ // Hot-reloadable values apply in place; listener values are persisted for
307
+ // the next boot and the surfaces report the configured value with a
308
+ // restart notice until the running gate matches it.
309
+ cfg.sessionDays = sessionDays;
310
+ cfg.maxAttempts = maxAttempts;
311
+ cfg.lockMinutes = lockMinutes;
312
+ sessions.sessionMs = sessionDays * 24 * 60 * 60 * 1000;
313
+ sessions.maxAttempts = maxAttempts;
314
+ sessions.lockMs = lockMinutes * 60 * 1000;
315
+ persistGuardState();
316
+ const restartRequired = listenerRestartRequired();
317
+ log(`settings updated${restartRequired ? ' — restart required for listener changes' : ''}`);
318
+ return { settings: currentSettings(), restartRequired };
319
+ }
320
+ /** Validate and apply a password change; returns the error code or null. */
321
+ function changePassword(current, next, confirm) {
322
+ if (!verifyPassword(current, passwordHash))
323
+ return 'wrong_current';
324
+ if (next.length < 6)
325
+ return 'too_short';
326
+ if (next !== confirm)
327
+ return 'mismatch';
328
+ passwordHash = hashPassword(next);
329
+ persistGuardState();
330
+ log('password changed');
331
+ return null;
332
+ }
333
+ /** Revoke one session; false when the id is unknown or already gone. */
334
+ function kickSession(id) {
335
+ const removed = sessions.removeById(id);
336
+ log(`session revoked from the settings page: ${id.slice(0, 8)}… ${removed ? 'ok' : 'not found'}`);
337
+ return removed;
338
+ }
339
+ function revokeAllSessions() {
340
+ sessions.revokeAll();
341
+ log('all sessions revoked from the settings page');
342
+ }
343
+ // ── request handling: form routes ─────────────────────────────────────────
344
+ async function handleLogin(req, res, locale, search) {
345
+ const ip = clientIp(req);
346
+ if (sessions.isLocked(ip)) {
347
+ return html(res, 429, loginPage({ title: cfg.title, locked: true, repo: cfg.repo, locale, search, themePreference: harnessTheme() }));
348
348
  }
349
- }
350
-
351
- const sessionDays = clampInt(form.get('sessionDays'), 0, 365, cfg.sessionDays)
352
- const maxAttempts = clampInt(form.get('maxAttempts'), 1, 50, cfg.maxAttempts)
353
- const lockMinutes = clampInt(form.get('lockMinutes'), 1, 1440, cfg.lockMinutes)
354
- const listenHost = (form.get('listenHost') ?? '').trim().slice(0, 64) || cfg.listenHost
355
- const listenPort = clampInt(form.get('listenPort'), 1, 65535, cfg.listenPort)
356
-
357
- if (problems.length > 0) {
358
- return renderAdmin(res, session, { status: 400, error: problems.join(locale === 'zh' ? ';' : '; '), locale })
359
- }
360
-
361
- const restartNeeded = String(listenHost) !== String(cfg.listenHost) || Number(listenPort) !== Number(cfg.listenPort)
362
-
363
- // Apply the hot-reloadable values in place.
364
- cfg.sessionDays = sessionDays
365
- cfg.maxAttempts = maxAttempts
366
- cfg.lockMinutes = lockMinutes
367
- sessions.sessionMs = sessionDays * 24 * 60 * 60 * 1000
368
- sessions.maxAttempts = maxAttempts
369
- sessions.lockMs = lockMinutes * 60 * 1000
370
-
371
- writeState(profileDir, {
372
- ...readState(profileDir),
373
- passwordHash,
374
- settings: { sessionDays, maxAttempts, lockMinutes, listenHost, listenPort },
375
- })
376
-
377
- log(`settings updated${passwordChanged ? ' (password changed)' : ''}${restartNeeded ? ' — restart required for listener changes' : ''}`)
378
- return renderAdmin(res, session, {
379
- notice: passwordChanged ? t(locale).savedPassword : t(locale).saved,
380
- restartNote: restartNeeded ? t(locale).restartNote : '',
381
- locale,
382
- })
383
- }
384
-
385
- if (path === '/__guard/logout-all') {
386
- if (!originAllowed(req)) return json(res, 403, { error: 'forbidden' })
387
- sessions.revokeAll()
388
- clearSessionCookie(res)
389
- log('all sessions revoked from the admin page')
390
- return redirect(res, '/login')
391
- }
392
-
393
- if (path === '/__guard/kill') {
394
- if (!originAllowed(req)) return json(res, 403, { error: 'forbidden' })
395
- const form = new URLSearchParams(await readBody(req).catch(() => ''))
396
- const id = form.get('id') ?? ''
397
- const removed = sessions.removeById(id)
398
- log(`session revoked from the admin page: ${id.slice(0, 8)}… ${removed ? 'ok' : 'not found'}`)
399
- return renderAdmin(res, session, { notice: removed ? t(locale).kicked : t(locale).kickMissing, locale })
400
- }
401
-
402
- return json(res, 404, { error: 'not_found' })
403
- }
404
-
405
- async function handler(req, res) {
406
- const url = new URL(req.url ?? '/', 'http://guard.invalid')
407
- const path = url.pathname
408
- const locale = localeFor(req)
409
-
410
- if (path === '/__guard/health') return json(res, 200, { ok: true, service: 'dsh-web-guard' })
411
-
412
- // Authenticated probe: 200 when this browser still holds a live session,
413
- // 401 when it does not. The injected client uses it to leave the
414
- // "reconnecting" spinner for the login page instead of retrying forever.
415
- if (path === '/__guard/session') {
416
- return sessionOf(req) ? json(res, 200, { ok: true }) : json(res, 401, { ok: false })
417
- }
418
-
419
- if (path === '/login' && req.method === 'GET') {
420
- if (sessionOf(req)) return redirect(res, '/')
421
- return html(res, 200, loginPage({ title: cfg.title, next: url.searchParams.get('next') ?? '/', repo: cfg.repo, locale }))
422
- }
423
- if (path === '/login' && req.method === 'POST') return handleLogin(req, res, locale)
424
-
425
- if (path === '/logout') {
426
- const s = sessionOf(req)
427
- if (s) sessions.revoke(s.token)
428
- clearSessionCookie(res)
429
- return html(res, 200, logoutPage({ title: cfg.title, locale }))
430
- }
431
-
432
- const session = sessionOf(req)
433
- if (!session) {
434
- if (path.startsWith('/api')) return json(res, 401, { error: 'unauthorized', login: '/login' })
435
- // Management paths reveal nothing to anonymous visitors: no `next` echo.
436
- if (path.startsWith('/__guard/')) return redirect(res, '/login')
437
- const next = encodeURIComponent(url.pathname + url.search)
438
- return redirect(res, `/login?next=${next}`)
439
- }
440
-
441
- if (path.startsWith('/__guard/')) return handleGuard(req, res, path, session, locale)
442
-
443
- let cookie
444
- try {
445
- cookie = await harnessCookie()
446
- } catch (err) {
447
- warn(`harness cookie unavailable: ${err.message}`)
448
- return json(res, 503, { error: 'harness_unavailable', detail: err.message })
449
- }
450
- forward(req, res, { targetOrigin, cookie, agent: upstreamAgent, log })
451
- }
452
-
453
- function onUpgrade(req, socket, head) {
454
- if (!sessionOf(req)) {
455
- socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n')
456
- return
457
- }
458
- harnessCookie()
459
- .then((cookie) => forwardUpgrade(req, socket, head, { targetOrigin, cookie, log }))
460
- .catch((err) => {
461
- warn(`upgrade blocked: ${err.message}`)
462
- socket.end('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n')
463
- })
464
- }
465
-
466
- // ── wiring ────────────────────────────────────────────────────────────────
467
- ctx.effect(() => {
468
- if (cfg.polyfill) {
469
- return ctx.webServer.tapIndex(injectUi)
470
- }
471
- return undefined
472
- }, 'dsh-web-guard: index transform')
473
-
474
- const listener = createListener({
475
- host: String(cfg.listenHost),
476
- port: listenPort,
477
- tlsCert: cfg.tls?.certFile || null,
478
- tlsKey: cfg.tls?.keyFile || null,
479
- handler,
480
- onUpgrade,
481
- log,
482
- })
483
-
484
- ctx.effect(() => {
485
- listener.listen().then(
486
- () => {
487
- log(`listening on ${secureCookie ? 'https' : 'http'}://${cfg.listenHost}:${listenPort} -> ${targetOrigin}`)
488
- const bind = ctx.webServer.config?.host
489
- if (typeof bind === 'string' && !isLoopback(bind)) {
490
- warn(`harness is bound to ${bind}; clients can bypass this gate by connecting there directly — bind it to 127.0.0.1`)
349
+ if (!originAllowed(req)) {
350
+ return html(res, 403, loginPage({ title: cfg.title, error: t(locale).errOrigin, repo: cfg.repo, locale, search, themePreference: harnessTheme() }));
491
351
  }
492
- },
493
- (err) => warn(`listen failed on ${cfg.listenHost}:${listenPort}: ${err.message}`),
494
- )
495
- return () => {
496
- sessions.dispose()
497
- localeReader.dispose()
498
- listener.close()
499
- }
500
- }, 'dsh-web-guard: listener')
501
-
502
- return { stop: () => listener.close() }
352
+ const body = await readBody(req).catch(() => '');
353
+ const form = new URLSearchParams(body);
354
+ const password = form.get('password') ?? '';
355
+ const next = form.get('next') ?? '/';
356
+ const safeNext = next.startsWith('/') && !next.startsWith('//') ? next : '/';
357
+ if (!verifyPassword(password, passwordHash)) {
358
+ const left = sessions.recordFailure(ip);
359
+ log(`login failed from ${ip} (${left} attempts left)`);
360
+ return html(res, 401, loginPage({
361
+ title: cfg.title,
362
+ error: left > 0 ? t(locale).errWrong : t(locale).errLocked,
363
+ next: safeNext,
364
+ repo: cfg.repo,
365
+ locale,
366
+ search,
367
+ themePreference: harnessTheme(),
368
+ }));
369
+ }
370
+ sessions.clearFailures(ip);
371
+ const token = sessions.issue(ip, String(req.headers['user-agent'] ?? ''));
372
+ setSessionCookie(res, token);
373
+ log(`login ok from ${ip}`);
374
+ redirect(res, safeNext);
375
+ }
376
+ // ── JSON API (native settings page) ───────────────────────────────────────
377
+ function apiError(res, status, error, extra = {}) {
378
+ const payload = { ok: false, error };
379
+ if (extra.field !== undefined)
380
+ payload.field = extra.field;
381
+ if (extra.detail !== undefined)
382
+ payload.detail = extra.detail;
383
+ json(res, status, payload);
384
+ }
385
+ function methodNotAllowed(res, allow) {
386
+ send(res, 405, {
387
+ 'content-type': 'application/json; charset=utf-8',
388
+ 'cache-control': 'no-store',
389
+ allow,
390
+ }, JSON.stringify({ ok: false, error: 'method_not_allowed' }));
391
+ }
392
+ /**
393
+ * Mutating API routes refuse anything a cross-site page could send: a JSON
394
+ * content type (which forces a CORS preflight) and an explicit same-host
395
+ * Origin (a non-browser client must restate it).
396
+ *
397
+ * `logout-all` carries no body, so a browser client that sends no
398
+ * `content-type` there is accepted (an explicit form content type is still
399
+ * rejected); the Origin check is the effective defence for that route.
400
+ */
401
+ function mutationForbidden(req, requireJson = true) {
402
+ const origin = req.headers.origin;
403
+ if (typeof origin !== 'string' || !originAllowed(req))
404
+ return 'forbidden';
405
+ const type = String(req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase();
406
+ if (requireJson ? type !== 'application/json' : type !== '' && type !== 'application/json')
407
+ return 'forbidden';
408
+ return null;
409
+ }
410
+ async function readJsonBody(req) {
411
+ let raw;
412
+ try {
413
+ raw = await readBody(req, MAX_BODY);
414
+ }
415
+ catch {
416
+ return undefined;
417
+ }
418
+ let parsed;
419
+ try {
420
+ parsed = JSON.parse(raw);
421
+ }
422
+ catch {
423
+ return undefined;
424
+ }
425
+ return isRecord(parsed) ? parsed : undefined;
426
+ }
427
+ function stateResponse(session) {
428
+ const currentId = sessions.idOf(session.token);
429
+ const rows = sessions.list().map((row) => ({ ...row, current: row.id === currentId }));
430
+ return { ok: true, settings: currentSettings(), sessions: rows, restartRequired: listenerRestartRequired() };
431
+ }
432
+ function apiSettings(res, body) {
433
+ const input = {};
434
+ const intFields = [
435
+ ['sessionDays', 0, 365],
436
+ ['maxAttempts', 1, 50],
437
+ ['lockMinutes', 1, 1440],
438
+ ['listenPort', 1, 65535],
439
+ ];
440
+ for (const [field, min, max] of intFields) {
441
+ const value = body[field];
442
+ if (value === undefined)
443
+ continue;
444
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < min || value > max) {
445
+ return apiError(res, 400, 'invalid_field', { field });
446
+ }
447
+ input[field] = value;
448
+ }
449
+ if (body.listenHost !== undefined) {
450
+ if (typeof body.listenHost !== 'string')
451
+ return apiError(res, 400, 'invalid_field', { field: 'listenHost' });
452
+ const listenHost = body.listenHost.trim();
453
+ if (listenHost.length === 0 || listenHost.length > 64)
454
+ return apiError(res, 400, 'invalid_field', { field: 'listenHost' });
455
+ input.listenHost = listenHost;
456
+ }
457
+ const result = updateSettings(input);
458
+ const payload = { ok: true, settings: result.settings, restartRequired: result.restartRequired };
459
+ json(res, 200, payload);
460
+ }
461
+ function apiPassword(res, body) {
462
+ const request = body;
463
+ for (const field of ['current', 'next', 'confirm']) {
464
+ if (typeof request[field] !== 'string')
465
+ return apiError(res, 400, 'invalid_field', { field });
466
+ }
467
+ const code = changePassword(request.current, request.next, request.confirm);
468
+ if (code)
469
+ return apiError(res, 400, code);
470
+ const payload = { ok: true };
471
+ json(res, 200, payload);
472
+ }
473
+ function apiKick(res, body) {
474
+ const id = body.id;
475
+ if (typeof id !== 'string' || id.length === 0)
476
+ return apiError(res, 400, 'invalid_field', { field: 'id' });
477
+ if (!kickSession(id))
478
+ return apiError(res, 404, 'kick_missing');
479
+ const payload = { ok: true };
480
+ json(res, 200, payload);
481
+ }
482
+ function apiLogoutAll(res) {
483
+ revokeAllSessions();
484
+ clearSessionCookie(res);
485
+ const payload = { ok: true };
486
+ json(res, 200, payload);
487
+ }
488
+ async function handleApi(req, res, path, session) {
489
+ if (path === GUARD_API.state) {
490
+ if (req.method !== 'GET')
491
+ return methodNotAllowed(res, 'GET');
492
+ return json(res, 200, stateResponse(session));
493
+ }
494
+ const known = path === GUARD_API.settings || path === GUARD_API.password || path === GUARD_API.kick || path === GUARD_API.logoutAll;
495
+ if (!known)
496
+ return apiError(res, 404, 'not_found');
497
+ if (req.method !== 'POST')
498
+ return methodNotAllowed(res, 'POST');
499
+ const forbidden = mutationForbidden(req, path !== GUARD_API.logoutAll);
500
+ if (forbidden)
501
+ return apiError(res, 403, forbidden);
502
+ if (path === GUARD_API.logoutAll)
503
+ return apiLogoutAll(res);
504
+ const body = await readJsonBody(req);
505
+ if (!body)
506
+ return apiError(res, 400, 'invalid_body');
507
+ if (path === GUARD_API.settings)
508
+ return apiSettings(res, body);
509
+ if (path === GUARD_API.password)
510
+ return apiPassword(res, body);
511
+ return apiKick(res, body);
512
+ }
513
+ // ── request router ────────────────────────────────────────────────────────
514
+ async function handler(req, res) {
515
+ const url = new URL(req.url ?? '/', 'http://guard.invalid');
516
+ const path = url.pathname;
517
+ const locale = localeFor(req);
518
+ const search = url.search;
519
+ if (path === '/__guard/health')
520
+ return json(res, 200, { ok: true, service: 'dsh-web-guard' });
521
+ // Authenticated probe: 200 when this browser still holds a live session,
522
+ // 401 when it does not. The injected client uses it to leave the
523
+ // "reconnecting" spinner for the login page instead of retrying forever.
524
+ if (path === '/__guard/session') {
525
+ return sessionOf(req) ? json(res, 200, { ok: true }) : json(res, 401, { ok: false });
526
+ }
527
+ if (path === '/login' && req.method === 'GET') {
528
+ if (sessionOf(req))
529
+ return redirect(res, '/');
530
+ return html(res, 200, loginPage({ title: cfg.title, next: url.searchParams.get('next') ?? '/', repo: cfg.repo, locale, search, themePreference: harnessTheme() }));
531
+ }
532
+ if (path === '/login' && req.method === 'POST')
533
+ return handleLogin(req, res, locale, search);
534
+ if (path === '/logout') {
535
+ const s = sessionOf(req);
536
+ if (s)
537
+ sessions.revoke(s.token);
538
+ clearSessionCookie(res);
539
+ return html(res, 200, logoutPage({ title: cfg.title, locale, search, themePreference: harnessTheme() }));
540
+ }
541
+ const session = sessionOf(req);
542
+ if (!session) {
543
+ // API routes never redirect: the settings page renders the 401 inline.
544
+ if (isApiPath(path))
545
+ return apiError(res, 401, 'unauthorized');
546
+ if (path.startsWith('/api'))
547
+ return json(res, 401, { error: 'unauthorized', login: '/login' });
548
+ // Management paths reveal nothing to anonymous visitors: no `next` echo.
549
+ if (path.startsWith('/__guard/'))
550
+ return redirect(res, '/login');
551
+ const next = encodeURIComponent(url.pathname + url.search);
552
+ return redirect(res, `/login?next=${next}`);
553
+ }
554
+ if (isApiPath(path))
555
+ return handleApi(req, res, path, session);
556
+ // No server-rendered management page: the native settings page talks to
557
+ // the JSON API, and anything else under the guard namespace is unknown.
558
+ if (path.startsWith('/__guard/'))
559
+ return apiError(res, 404, 'not_found');
560
+ let cookie;
561
+ try {
562
+ cookie = await harnessCookie();
563
+ }
564
+ catch (err) {
565
+ warn(`harness cookie unavailable: ${err instanceof Error ? err.message : String(err)}`);
566
+ return json(res, 503, { error: 'harness_unavailable', detail: err instanceof Error ? err.message : String(err) });
567
+ }
568
+ forward(req, res, { targetOrigin, cookie, agent: upstreamAgent, log });
569
+ }
570
+ function onUpgrade(req, socket, head) {
571
+ if (!sessionOf(req)) {
572
+ socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n');
573
+ return;
574
+ }
575
+ harnessCookie()
576
+ .then((cookie) => forwardUpgrade(req, socket, head, { targetOrigin, cookie, log }))
577
+ .catch((err) => {
578
+ warn(`upgrade blocked: ${err instanceof Error ? err.message : String(err)}`);
579
+ socket.end('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n');
580
+ });
581
+ }
582
+ // ── wiring ────────────────────────────────────────────────────────────────
583
+ // The polyfill and the session watchdog are injected unconditionally: the
584
+ // polyfill switch must not also disable the dead-session redirect.
585
+ ctx.effect(() => ctx.webServer.tapIndex((html) => injectUi(html, { polyfill: cfg.polyfill })), 'dsh-web-guard: index transform');
586
+ const listener = createListener({
587
+ host: String(cfg.listenHost),
588
+ port: listenPort,
589
+ tlsCert: cfg.tls?.certFile || null,
590
+ tlsKey: cfg.tls?.keyFile || null,
591
+ handler,
592
+ onUpgrade,
593
+ log,
594
+ });
595
+ ctx.effect(() => {
596
+ listener.listen().then(() => {
597
+ log(`listening on ${secureCookie ? 'https' : 'http'}://${cfg.listenHost}:${listenPort} -> ${targetOrigin}`);
598
+ const bind = ctx.webServer.config?.host;
599
+ if (typeof bind === 'string' && !isLoopback(bind)) {
600
+ warn(`harness is bound to ${bind}; clients can bypass this gate by connecting there directly — bind it to 127.0.0.1`);
601
+ }
602
+ }, (err) => warn(`listen failed on ${cfg.listenHost}:${listenPort}: ${err.message}`));
603
+ return () => {
604
+ sessions.dispose();
605
+ localeReader.dispose();
606
+ themeReader.dispose();
607
+ listener.close();
608
+ };
609
+ }, 'dsh-web-guard: listener');
610
+ return { stop: () => listener.close() };
503
611
  }
504
-
505
612
  function isLoopback(host) {
506
- return host === 'localhost' || host === '::1' || /^127\./.test(host)
613
+ return host === 'localhost' || host === '::1' || /^127\./.test(host);
507
614
  }
508
-
509
- function readBody(req) {
510
- return new Promise((resolve, reject) => {
511
- let size = 0
512
- const chunks = []
513
- req.on('data', (chunk) => {
514
- size += chunk.length
515
- if (size > MAX_FORM) {
516
- reject(new Error('form too large'))
517
- req.destroy()
518
- return
519
- }
520
- chunks.push(chunk)
521
- })
522
- req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
523
- req.on('error', reject)
524
- })
615
+ function readBody(req, limit = MAX_FORM) {
616
+ return new Promise((resolve, reject) => {
617
+ let size = 0;
618
+ const chunks = [];
619
+ req.on('data', (chunk) => {
620
+ size += chunk.length;
621
+ if (size > limit) {
622
+ reject(new Error('request body too large'));
623
+ req.destroy();
624
+ return;
625
+ }
626
+ chunks.push(chunk);
627
+ });
628
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
629
+ req.on('error', reject);
630
+ });
525
631
  }