@riceawa/dsh-lan-gateway 0.3.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/src/index.ts ADDED
@@ -0,0 +1,498 @@
1
+ /**
2
+ * @riceawa/dsh-lan-gateway — the LAN/internet gateway plugin for the
3
+ * DeepSeek Harness web GUI.
4
+ *
5
+ * dsh's web CLI hard-refuses `--host 0.0.0.0` (exposing remote code execution
6
+ * to the network), so this plugin leaves dsh bound to 127.0.0.1 and starts its
7
+ * own reverse-proxy gateway on 0.0.0.0 that forwards to the loopback dsh port,
8
+ * rewriting Host/Origin so the `/api` trust fence passes. LAN and loopback
9
+ * sources are proxied password-free; anything else must complete the login
10
+ * page and present the HMAC cookie.
11
+ *
12
+ * The gateway listener can speak TLS: either a persisted auto-generated
13
+ * self-signed certificate (`tlsMode: 'self-signed'`, hosts from
14
+ * `tlsSelfSignedHosts`) or a user-supplied PEM pair (`tlsMode: 'custom'`,
15
+ * `tlsCertPath` + `tlsKeyPath`).
16
+ *
17
+ * Every tunable is also exposed as the `lan-gateway` user-settings namespace
18
+ * (`ctx.settings`), so the official DSH Settings → Plugins page can adjust
19
+ * port, CIDRs, auth, and TLS live; the running listener restarts on change.
20
+ *
21
+ * Disabled by default in the bundle patch (safe): the listener opens only
22
+ * after `lan_gateway enable` or `enabled: true`.
23
+ *
24
+ * @module @riceawa/dsh-lan-gateway
25
+ */
26
+
27
+ import type { Context } from '@deepseek-ai/cordis'
28
+ import { randomBytes } from 'node:crypto'
29
+ import type { IncomingMessage, ServerResponse } from 'node:http'
30
+ import z from '@deepseek-ai/schemastery'
31
+ import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings'
32
+ import { DEFAULT_LAN_CIDR_STRINGS } from './auth.ts'
33
+ import { LanGateway } from './gateway.ts'
34
+ import { readBody } from './login.ts'
35
+ import {
36
+ loadState,
37
+ saveState,
38
+ setPassword,
39
+ type GatewayState,
40
+ } from './state.ts'
41
+ import {
42
+ describeCert,
43
+ loadCustomCert,
44
+ loadOrCreateSelfSigned,
45
+ parseSelfSignedHosts,
46
+ regenerateSelfSigned,
47
+ type TlsMaterial,
48
+ } from './tls.ts'
49
+ import { lanGatewayTool } from './tool.ts'
50
+
51
+ /** Stable Cordis plugin name. */
52
+ export const name = 'dsh-lan-gateway'
53
+
54
+ /** Requires the web server service (binds before this row's apply runs) and the tool registry. */
55
+ export const inject = ['webServer', 'tools']
56
+
57
+ /** Minimal surface of the dsh web server service this plugin reads. */
58
+ export interface WebServerSurface {
59
+ port: number
60
+ /** Register an exact/prefix HTTP route owned by this plugin. */
61
+ register(route: {
62
+ kind: 'exact' | 'prefix'
63
+ path: string
64
+ handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
65
+ }): () => void
66
+ }
67
+
68
+ declare module '@deepseek-ai/cordis' {
69
+ interface Context {
70
+ webServer: WebServerSurface
71
+ }
72
+ }
73
+
74
+ /** One command result returned to the model. */
75
+ export interface ToolResult {
76
+ ok: boolean
77
+ message: string
78
+ }
79
+
80
+ /** The runtime surface the management tool drives. Implemented by `apply`. */
81
+ export interface GatewayController {
82
+ status(): ToolResult
83
+ enable(): Promise<ToolResult>
84
+ disable(): Promise<ToolResult>
85
+ setPassword(password: string | undefined): ToolResult
86
+ rotateSecret(): ToolResult
87
+ regenerateTls(): Promise<ToolResult>
88
+ }
89
+
90
+ /** Deployment configuration (composition-level; secrets live in state.json). */
91
+ export interface Config {
92
+ /** Whether the gateway listener is started at boot. Default false (safe). */
93
+ enabled: boolean
94
+ /** Port to bind on 0.0.0.0. */
95
+ gatewayPort: number
96
+ /** Explicit dsh target port; defaults to the live `ctx.webServer.port`. */
97
+ dshTargetPort?: number
98
+ /** LAN CIDRs treated as password-free. */
99
+ lanCidrs: string[]
100
+ /** Whether non-LAN sources must authenticate. */
101
+ authRequired: boolean
102
+ /** Session cookie lifetime in days. */
103
+ cookieMaxAgeDays: number
104
+ /** Cookie name. */
105
+ cookieName: string
106
+ /** Whether the gateway listener speaks TLS. */
107
+ tlsEnabled: boolean
108
+ /** Certificate source: auto-generated self-signed, or user-supplied files. */
109
+ tlsMode: 'self-signed' | 'custom'
110
+ /** Custom mode: path to the PEM certificate (or chain). */
111
+ tlsCertPath?: string
112
+ /** Custom mode: path to the PEM private key. */
113
+ tlsKeyPath?: string
114
+ /** Self-signed mode: comma/space separated DNS names and IPs for the SANs. */
115
+ tlsSelfSignedHosts?: string
116
+ /** Self-signed certificate validity in days (default 825 ≈ 27 months). */
117
+ tlsCertMaxAgeDays: number
118
+ }
119
+
120
+ /** The `lan-gateway` user-settings namespace, mirroring the composition schema. */
121
+ const NS = settingsNamespace('lan-gateway')
122
+
123
+ /** Optional config keys: an empty submitted value clears them back to the composition layer. */
124
+ const OPTIONAL_CONFIG_KEYS = new Set(['dshTargetPort', 'tlsCertPath', 'tlsKeyPath'])
125
+
126
+ /** Schemastery configuration validated by the Loader. */
127
+ export const Config: z<Config> = z.object({
128
+ enabled: z.boolean().default(false),
129
+ gatewayPort: z.natural().min(1).max(65535).default(3081),
130
+ dshTargetPort: z.natural().min(1).max(65535),
131
+ lanCidrs: z.array(String).default([...DEFAULT_LAN_CIDR_STRINGS]),
132
+ authRequired: z.boolean().default(true),
133
+ cookieMaxAgeDays: z.natural().min(1).max(365).default(7),
134
+ cookieName: z.string().default('dsh_gw_auth'),
135
+ tlsEnabled: z.boolean().default(false),
136
+ tlsMode: z.union([z.const('self-signed'), z.const('custom')]).default('self-signed'),
137
+ tlsCertPath: z.string(),
138
+ tlsKeyPath: z.string(),
139
+ tlsSelfSignedHosts: z.string().default('localhost'),
140
+ tlsCertMaxAgeDays: z.natural().min(1).max(3650).default(825),
141
+ })
142
+
143
+ /** Resolve the TLS material for a config, or undefined when TLS is off. */
144
+ function resolveTls(cfg: Config): TlsMaterial | undefined {
145
+ if (!cfg.tlsEnabled) return undefined
146
+ if (cfg.tlsMode === 'custom') {
147
+ return loadCustomCert(cfg.tlsCertPath ?? '', cfg.tlsKeyPath ?? '')
148
+ }
149
+ const hosts = parseSelfSignedHosts(cfg.tlsSelfSignedHosts)
150
+ if (hosts.length === 0) {
151
+ throw new Error('tlsSelfSignedHosts must name at least one host (DNS name or IP)')
152
+ }
153
+ const { material } = loadOrCreateSelfSigned({ hosts, days: cfg.tlsCertMaxAgeDays })
154
+ return material
155
+ }
156
+
157
+ /** Config fields that require a listener restart when they change. */
158
+ function listenerKey(cfg: Config): string {
159
+ return JSON.stringify([
160
+ cfg.gatewayPort,
161
+ cfg.dshTargetPort,
162
+ cfg.lanCidrs,
163
+ cfg.authRequired,
164
+ cfg.cookieMaxAgeDays,
165
+ cfg.cookieName,
166
+ cfg.tlsEnabled,
167
+ cfg.tlsMode,
168
+ cfg.tlsCertPath,
169
+ cfg.tlsKeyPath,
170
+ cfg.tlsSelfSignedHosts,
171
+ cfg.tlsCertMaxAgeDays,
172
+ ])
173
+ }
174
+
175
+ /** One-line TLS description for status output. */
176
+ function tlsStatusLine(cfg: Config): string {
177
+ if (!cfg.tlsEnabled) return 'off'
178
+ if (cfg.tlsMode === 'custom') {
179
+ return `custom (${cfg.tlsCertPath ?? '?'}, ${cfg.tlsKeyPath ?? '?'})`
180
+ }
181
+ try {
182
+ const hosts = parseSelfSignedHosts(cfg.tlsSelfSignedHosts)
183
+ const { material } = loadOrCreateSelfSigned({ hosts, days: cfg.tlsCertMaxAgeDays })
184
+ const info = describeCert(material.cert)
185
+ return `self-signed [${info.subject}] exp ${info.validTo}`
186
+ } catch (error) {
187
+ return `self-signed (unavailable: ${error instanceof Error ? error.message : String(error)})`
188
+ }
189
+ }
190
+
191
+ /** Whether `hostname` is loopback (127/8, localhost, ::1). */
192
+ function isLoopbackHost(hostname: string): boolean {
193
+ if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true
194
+ const parts = hostname.split('.')
195
+ return (
196
+ parts.length === 4
197
+ && parts[0] === '127'
198
+ && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
199
+ )
200
+ }
201
+
202
+ /**
203
+ * Same-origin loopback fence for the config route (mirrors the fence the dsh
204
+ * host uses for its own /api, and what dsh-lan-gateway's sibling plugins do):
205
+ * the Host must be loopback (the gateway rewrites it), cross-site fetches are
206
+ * refused, and any Origin must match the Host the browser actually used.
207
+ */
208
+ function isTrustedRequest(req: IncomingMessage): boolean {
209
+ const host = req.headers?.host
210
+ if (typeof host !== 'string' || host === '') return false
211
+ let hostUrl: URL
212
+ try {
213
+ hostUrl = new URL(`http://${host}`)
214
+ } catch {
215
+ return false
216
+ }
217
+ if (!isLoopbackHost(hostUrl.hostname)) return false
218
+ if (req.headers?.['sec-fetch-site'] === 'cross-site') return false
219
+ const origin = req.headers?.origin
220
+ if (origin === undefined) return true
221
+ try {
222
+ return new URL(origin).host === hostUrl.host
223
+ } catch {
224
+ return false
225
+ }
226
+ }
227
+
228
+ export function apply(ctx: Context, config: Config): void {
229
+ let state = loadState()
230
+ let gateway: LanGateway | undefined
231
+ let startedWith: string | undefined
232
+ let lastError: string | undefined
233
+ let manualOverride: boolean | undefined
234
+ /** The authoritative config: settings section when attached, else composition. */
235
+ let configSource: () => Config = () => config
236
+ /** Serializes listener start/stop/restart so settings changes cannot race. */
237
+ let syncing: Promise<void> = Promise.resolve()
238
+
239
+ const effective = (): Config => configSource()
240
+
241
+ const startGateway = async (cfg: Config): Promise<void> => {
242
+ if (gateway !== undefined) return
243
+ if (cfg.authRequired && state.password === undefined) {
244
+ // A passwordless gateway exposed to non-LAN sources would be an open
245
+ // remote-code-execution door. Refuse to listen until a password is set.
246
+ throw new Error(
247
+ 'dsh-lan-gateway: no password set — run `lan_gateway set-password` (or set '
248
+ + 'authRequired=false in the plugin config) before enabling.',
249
+ )
250
+ }
251
+ const dshPort = cfg.dshTargetPort ?? ctx.webServer.port
252
+ const tls = resolveTls(cfg)
253
+ const next = new LanGateway({
254
+ gatewayPort: cfg.gatewayPort,
255
+ dshPort,
256
+ lanCidrs: cfg.lanCidrs,
257
+ authRequired: cfg.authRequired,
258
+ cookieMaxAgeDays: cfg.cookieMaxAgeDays,
259
+ cookieName: cfg.cookieName,
260
+ ...(tls !== undefined ? { tls } : {}),
261
+ }, state)
262
+ await next.listen()
263
+ gateway = next
264
+ startedWith = listenerKey(cfg)
265
+ ctx.logger.info(
266
+ `dsh-lan-gateway: listening on 0.0.0.0:${cfg.gatewayPort}${tls !== undefined ? ' (TLS)' : ''} -> 127.0.0.1:${dshPort}`,
267
+ )
268
+ }
269
+
270
+ const stopGateway = async (): Promise<void> => {
271
+ const current = gateway
272
+ gateway = undefined
273
+ startedWith = undefined
274
+ if (current !== undefined) {
275
+ await current.close()
276
+ ctx.logger.info('dsh-lan-gateway: stopped')
277
+ }
278
+ }
279
+
280
+ /** Reconcile the listener with the effective config (start/stop/restart). */
281
+ const syncGateway = (reason: string): Promise<void> => {
282
+ syncing = syncing.then(async () => {
283
+ lastError = undefined
284
+ const cfg = effective()
285
+ const shouldRun = manualOverride ?? cfg.enabled
286
+ try {
287
+ if (gateway === undefined) {
288
+ if (shouldRun) await startGateway(cfg)
289
+ } else if (!shouldRun) {
290
+ await stopGateway()
291
+ } else if (startedWith !== listenerKey(cfg)) {
292
+ await stopGateway()
293
+ await startGateway(cfg)
294
+ }
295
+ } catch (error) {
296
+ lastError = error instanceof Error ? error.message : String(error)
297
+ ctx.logger.warn(`dsh-lan-gateway: ${reason}: ${lastError}`)
298
+ }
299
+ })
300
+ return syncing
301
+ }
302
+
303
+ // The tunables also live in the `lan-gateway` settings section: while the
304
+ // settings service exists, the section (composition base + user overrides)
305
+ // is the authoritative config, and every committed change re-syncs the
306
+ // listener — so the Settings → Plugins page adjusts the gateway live.
307
+ // Registered directly (not via installSettingsSection) so the scope handle
308
+ // is available to the /lan-gateway/config route for writes.
309
+ let settingsScope: SettingsScope<Config> | undefined
310
+ ctx.inject(['settings'], (sctx) => {
311
+ const scope = sctx.settings.register(NS, Config, { base: config })
312
+ settingsScope = scope
313
+ configSource = () => scope.get()
314
+ sctx.effect(() => scope.watch(() => { void syncGateway('settings change') }))
315
+ sctx.effect(() => () => {
316
+ // The settings provider went away (disposal / provider reload): fall
317
+ // back to the composition entry so the plugin keeps working as composed.
318
+ configSource = () => config
319
+ settingsScope = undefined
320
+ })
321
+ void syncGateway('settings attach')
322
+ })
323
+
324
+ // The Settings → Plugins card reads and writes through this loopback-only
325
+ // JSON route (ModLens-style: the browser never touches the settings seam
326
+ // directly, so the card has no service dependencies to resolve).
327
+ const configRouteHandler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
328
+ const send = (status: number, body: unknown): void => {
329
+ res.writeHead(status, { 'content-type': 'application/json' })
330
+ res.end(JSON.stringify(body))
331
+ }
332
+ if (!isTrustedRequest(req)) {
333
+ send(403, { error: 'request refused: this route answers loopback-origin requests only' })
334
+ return
335
+ }
336
+ if (req.method === 'GET') {
337
+ const cfg = effective()
338
+ send(200, {
339
+ config: cfg,
340
+ running: gateway !== undefined,
341
+ port: cfg.gatewayPort,
342
+ tls: tlsStatusLine(cfg),
343
+ lastError: lastError ?? null,
344
+ })
345
+ return
346
+ }
347
+ if (req.method !== 'POST') {
348
+ send(405, { error: 'method not allowed' })
349
+ return
350
+ }
351
+ const body = await readBody(req, 64 * 1024, res)
352
+ if (body === undefined) return // response already sent (413/400)
353
+ let submitted: unknown
354
+ try {
355
+ submitted = JSON.parse(body)
356
+ } catch {
357
+ send(400, { error: 'invalid JSON body' })
358
+ return
359
+ }
360
+ if (typeof submitted !== 'object' || submitted === null || Array.isArray(submitted)) {
361
+ send(400, { error: 'body must be a config object' })
362
+ return
363
+ }
364
+ // The schema callable validates and fills defaults; it throws with a
365
+ // descriptive message on any invalid value.
366
+ let candidate: Config
367
+ try {
368
+ candidate = Config(submitted as Config)
369
+ } catch (error) {
370
+ send(400, { error: error instanceof Error ? error.message : String(error) })
371
+ return
372
+ }
373
+ if (settingsScope === undefined) {
374
+ send(409, { error: 'settings service unavailable — edit the profile patch (cordis.patch.yml) instead' })
375
+ return
376
+ }
377
+ // Build the next user section: drop null/undefined and empty optionals
378
+ // (an empty path field re-inherits the composition layer).
379
+ const section: Record<string, unknown> = {}
380
+ for (const [key, value] of Object.entries(candidate)) {
381
+ if (value === null || value === undefined) continue
382
+ if (typeof value === 'string' && value === '' && OPTIONAL_CONFIG_KEYS.has(key)) continue
383
+ section[key] = value
384
+ }
385
+ try {
386
+ await settingsScope.replace(section)
387
+ // Let the listener restart settle before reporting, so `running` is
388
+ // accurate instead of a mid-restart snapshot.
389
+ await syncGateway('config route save')
390
+ const cfg = effective()
391
+ send(200, {
392
+ config: cfg,
393
+ running: gateway !== undefined,
394
+ port: cfg.gatewayPort,
395
+ tls: tlsStatusLine(cfg),
396
+ lastError: lastError ?? null,
397
+ })
398
+ } catch (error) {
399
+ send(409, { error: error instanceof Error ? error.message : String(error) })
400
+ }
401
+ }
402
+ ctx.effect(
403
+ () => ctx.webServer.register({ kind: 'exact', path: '/lan-gateway/config', handler: configRouteHandler }),
404
+ 'dsh-lan-gateway: config route',
405
+ )
406
+
407
+ const controller: GatewayController = {
408
+ status(): ToolResult {
409
+ const cfg = effective()
410
+ const dshPort = cfg.dshTargetPort ?? ctx.webServer.port
411
+ return {
412
+ ok: true,
413
+ message:
414
+ `LAN gateway: ${gateway !== undefined ? `LISTENING on 0.0.0.0:${cfg.gatewayPort}` : 'stopped'}`
415
+ + `\n- dsh target: 127.0.0.1:${dshPort}`
416
+ + `\n- password: ${state.password !== undefined ? 'set' : 'NOT SET'}`
417
+ + `\n- auth required for non-LAN: ${cfg.authRequired}`
418
+ + `\n- trusted LAN CIDRs: ${cfg.lanCidrs.join(', ') || '(none)'}`
419
+ + `\n- session cookie: ${cfg.cookieName}, ${cfg.cookieMaxAgeDays}d`
420
+ + `\n- TLS: ${tlsStatusLine(cfg)}`
421
+ + (manualOverride !== undefined
422
+ ? `\n- manual override: ${manualOverride ? 'enabled' : 'disabled'}`
423
+ : '')
424
+ + (lastError !== undefined ? `\n- last error: ${lastError}` : ''),
425
+ }
426
+ },
427
+ async enable(): Promise<ToolResult> {
428
+ manualOverride = true
429
+ await syncGateway('tool enable')
430
+ return gateway !== undefined
431
+ ? { ok: true, message: `Gateway enabled: listening on 0.0.0.0:${effective().gatewayPort}` }
432
+ : { ok: false, message: `Failed to enable gateway: ${lastError ?? 'unknown error'}` }
433
+ },
434
+ async disable(): Promise<ToolResult> {
435
+ manualOverride = false
436
+ await syncGateway('tool disable')
437
+ return { ok: true, message: 'Gateway disabled.' }
438
+ },
439
+ setPassword(password: string | undefined): ToolResult {
440
+ if (password !== undefined && password.length > 0 && password.length < 8) {
441
+ return { ok: false, message: 'Password must be at least 8 characters.' }
442
+ }
443
+ const setting = password !== undefined && password.length > 0
444
+ state = setPassword(state, setting ? password : undefined)
445
+ saveState(state)
446
+ gateway?.setState(state)
447
+ return {
448
+ ok: true,
449
+ message: setting
450
+ ? 'Password set. Non-LAN access now requires it.'
451
+ : 'Password cleared. Non-LAN access is now password-free (only safe if authRequired is false or no non-LAN sources exist).',
452
+ }
453
+ },
454
+ rotateSecret(): ToolResult {
455
+ const next: GatewayState = { cookieSecret: randomBytes(32).toString('base64') }
456
+ if (state.password !== undefined) {
457
+ next.password = state.password
458
+ }
459
+ state = next
460
+ saveState(state)
461
+ gateway?.setState(state)
462
+ return { ok: true, message: 'Session secret rotated. All existing login cookies are now invalid.' }
463
+ },
464
+ async regenerateTls(): Promise<ToolResult> {
465
+ const cfg = effective()
466
+ if (!cfg.tlsEnabled || cfg.tlsMode !== 'self-signed') {
467
+ return { ok: false, message: 'TLS is off or in custom mode — nothing to regenerate. Enable tlsEnabled with tlsMode=self-signed first.' }
468
+ }
469
+ const hosts = parseSelfSignedHosts(cfg.tlsSelfSignedHosts)
470
+ if (hosts.length === 0) {
471
+ return { ok: false, message: 'tlsSelfSignedHosts must name at least one host (DNS name or IP).' }
472
+ }
473
+ try {
474
+ regenerateSelfSigned({ hosts, days: cfg.tlsCertMaxAgeDays })
475
+ if (gateway !== undefined) {
476
+ await stopGateway()
477
+ await startGateway(effective())
478
+ lastError = undefined
479
+ }
480
+ return { ok: true, message: 'Self-signed certificate regenerated (new key). Listener restarted with the new certificate.' }
481
+ } catch (error) {
482
+ return {
483
+ ok: false,
484
+ message: `Failed to regenerate TLS certificate: ${error instanceof Error ? error.message : String(error)}`,
485
+ }
486
+ }
487
+ },
488
+ }
489
+
490
+ // Register the management tool once.
491
+ ctx.tools.register(lanGatewayTool(controller))
492
+
493
+ // Own the gateway lifecycle with the cordis tree.
494
+ ctx.effect(async () => {
495
+ await syncGateway('boot')
496
+ return stopGateway
497
+ }, 'dsh-lan-gateway: listener lifecycle')
498
+ }
package/src/login.ts ADDED
@@ -0,0 +1,133 @@
1
+ /**
2
+ * The gateway's own login surface: a self-contained HTML form served at
3
+ * `/__login` (never proxied) and the form-post handler that validates the
4
+ * password and issues the session cookie.
5
+ *
6
+ * @module @riceawa/dsh-lan-gateway/login
7
+ */
8
+
9
+ import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http'
10
+
11
+ /** Path the gateway owns and never forwards. */
12
+ export const LOGIN_PATH = '/__login' as const
13
+
14
+ /** The cookie name used for the signed session. */
15
+ export const COOKIE_NAME = 'dsh_gw_auth' as const
16
+
17
+ export interface LoginPageOptions {
18
+ error?: string
19
+ /** Optional login attempt counter to show when rate-limited. */
20
+ limited?: boolean
21
+ }
22
+
23
+ /** Render the self-contained login page. */
24
+ export function renderLoginPage(opts: LoginPageOptions = {}): string {
25
+ const errorHtml = opts.limited
26
+ ? '<p class="error">Too many attempts — wait a minute and try again.</p>'
27
+ : opts.error
28
+ ? `<p class="error">${escapeHtml(opts.error)}</p>`
29
+ : ''
30
+ return `<!doctype html>
31
+ <html lang="en">
32
+ <head>
33
+ <meta charset="utf-8">
34
+ <meta name="viewport" content="width=device-width, initial-scale=1">
35
+ <title>DeepSeek Harness — Remote Access</title>
36
+ <style>
37
+ :root { color-scheme: dark; }
38
+ * { box-sizing: border-box; }
39
+ body {
40
+ margin: 0; min-height: 100vh; display: grid; place-items: center;
41
+ background: #0f1115; color: #e6e9ef; font: 14px/1.5 system-ui, -apple-system, sans-serif;
42
+ }
43
+ .card {
44
+ width: min(360px, 90vw); padding: 32px 28px; border: 1px solid #262b36; border-radius: 12px;
45
+ background: #161a21; box-shadow: 0 8px 30px rgba(0,0,0,.4);
46
+ }
47
+ h1 { font-size: 17px; margin: 0 0 4px; }
48
+ p.sub { color: #8b93a3; margin: 0 0 20px; font-size: 13px; }
49
+ label { display: block; font-size: 12px; color: #aab2c1; margin-bottom: 6px; }
50
+ input[type=password] {
51
+ width: 100%; padding: 10px 12px; border: 1px solid #2d3442; border-radius: 8px;
52
+ background: #0f1115; color: #e6e9ef; font-size: 14px;
53
+ }
54
+ button {
55
+ width: 100%; margin-top: 16px; padding: 10px; border: 0; border-radius: 8px;
56
+ background: #4f6ef7; color: #fff; font-size: 14px; font-weight: 600; cursor: pointer;
57
+ }
58
+ button:hover { background: #5c7afa; }
59
+ p.error { color: #ff7b72; font-size: 13px; margin: 12px 0 0; }
60
+ </style>
61
+ </head>
62
+ <body>
63
+ <form class="card" method="post" action="${LOGIN_PATH}">
64
+ <h1>DeepSeek Harness</h1>
65
+ <p class="sub">This instance requires a password from your network location.</p>
66
+ <label for="password">Password</label>
67
+ <input type="password" id="password" name="password" autofocus autocomplete="current-password" required>
68
+ ${errorHtml}
69
+ <button type="submit">Sign in</button>
70
+ </form>
71
+ </body>
72
+ </html>`
73
+ }
74
+
75
+ /** Minimal HTML-escape for the error string interpolated into the page. */
76
+ function escapeHtml(text: string): string {
77
+ return text
78
+ .replaceAll('&', '&amp;')
79
+ .replaceAll('<', '&lt;')
80
+ .replaceAll('>', '&gt;')
81
+ .replaceAll('"', '&quot;')
82
+ .replaceAll("'", '&#39;')
83
+ }
84
+
85
+ /** Serve the GET login page. */
86
+ export function serveLoginGet(res: ServerResponse, extraHeaders: OutgoingHttpHeaders = {}): void {
87
+ res.writeHead(200, {
88
+ 'content-type': 'text/html; charset=utf-8',
89
+ 'cache-control': 'no-store',
90
+ ...extraHeaders,
91
+ })
92
+ res.end(renderLoginPage())
93
+ }
94
+
95
+ /** Parse an application/x-www-form-urlencoded body into its fields. */
96
+ export function parseFormBody(body: string): Record<string, string> {
97
+ const out: Record<string, string> = {}
98
+ for (const pair of body.split('&')) {
99
+ if (pair === '') continue
100
+ const eq = pair.indexOf('=')
101
+ const key = eq === -1 ? pair : pair.slice(0, eq)
102
+ const value = eq === -1 ? '' : pair.slice(eq + 1)
103
+ out[decodeURIComponent(key.replaceAll('+', ' '))] = decodeURIComponent(value.replaceAll('+', ' '))
104
+ }
105
+ return out
106
+ }
107
+
108
+ /** Read a request body up to a byte ceiling, rejecting anything larger. */
109
+ export function readBody(req: IncomingMessage, maxBytes: number, res: ServerResponse): Promise<string | undefined> {
110
+ return new Promise((resolve) => {
111
+ let size = 0
112
+ const chunks: Buffer[] = []
113
+ req.on('data', (chunk: Buffer) => {
114
+ size += chunk.length
115
+ if (size > maxBytes) {
116
+ res.writeHead(413)
117
+ res.end()
118
+ resolve(undefined)
119
+ req.destroy()
120
+ return
121
+ }
122
+ chunks.push(chunk)
123
+ })
124
+ req.on('end', () => {
125
+ resolve(Buffer.concat(chunks).toString('utf8'))
126
+ })
127
+ req.on('error', () => {
128
+ res.writeHead(400)
129
+ res.end()
130
+ resolve(undefined)
131
+ })
132
+ })
133
+ }