@lvxianchao/dsh-web-guard 2.1.0 → 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/CHANGELOG.md +45 -0
- package/README.md +64 -23
- package/README.zh.md +63 -31
- package/lib/auth.js +244 -232
- package/lib/client.js +1071 -0
- package/lib/i18n.js +49 -247
- package/lib/index.js +571 -465
- package/lib/inject.js +20 -69
- package/lib/page.js +52 -62
- package/lib/polyfill.js +9 -8
- package/lib/preferences.js +144 -0
- package/lib/proxy.js +161 -156
- package/lib/shared/api.js +19 -0
- package/lib/shared/theme.js +109 -0
- package/package.json +36 -4
- package/lib/admin.js +0 -190
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
|
-
*
|
|
13
|
-
*
|
|
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,
|
|
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 {
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
25
|
-
|
|
26
|
-
export const name = 'dsh-web-guard'
|
|
27
|
-
export const inject = ['webServer', 'connection']
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
const
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 +
|
|
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
|
-
|
|
53
|
+
return join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'dsh-web-guard.json');
|
|
57
54
|
}
|
|
58
|
-
|
|
59
55
|
function statePath(profileDir) {
|
|
60
|
-
|
|
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
|
-
|
|
64
|
+
return join(profileDir, 'data', `dsh-web-guard-sessions-${port}.json`);
|
|
70
65
|
}
|
|
71
|
-
|
|
72
66
|
function readState(profileDir) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
128
|
-
|
|
124
|
+
res.writeHead(status, headers);
|
|
125
|
+
res.end(body);
|
|
129
126
|
}
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
const
|
|
133
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
res
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
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
|
-
|
|
613
|
+
return host === 'localhost' || host === '::1' || /^127\./.test(host);
|
|
507
614
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
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
|
}
|