@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/LICENSE +20 -0
- package/README.md +221 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +733 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +71 -0
- package/lib/index.js +1310 -0
- package/package.json +86 -0
- package/skills/lan-gateway.md +56 -0
- package/src/auth.ts +182 -0
- package/src/client/index.ts +89 -0
- package/src/client/lan-gateway-card.tsx +603 -0
- package/src/gateway.ts +343 -0
- package/src/index.ts +498 -0
- package/src/login.ts +133 -0
- package/src/state.ts +93 -0
- package/src/tls.ts +164 -0
- package/src/tool.ts +82 -0
- package/src/x509.ts +314 -0
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('&', '&')
|
|
79
|
+
.replaceAll('<', '<')
|
|
80
|
+
.replaceAll('>', '>')
|
|
81
|
+
.replaceAll('"', '"')
|
|
82
|
+
.replaceAll("'", ''')
|
|
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
|
+
}
|