@riceawa/dsh-lan-gateway 0.6.1 → 0.7.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/README.md +15 -12
- package/lib/client.js +106 -148
- package/lib/client.js.map +1 -1
- package/lib/index.js +52 -2
- package/package.json +21 -23
- package/src/client/index.ts +37 -26
- package/src/client/lan-gateway-card.tsx +205 -195
- package/src/gateway.ts +31 -5
- package/src/request-policy.ts +36 -0
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The lan-gateway
|
|
3
|
-
* through its `plugins.
|
|
2
|
+
* The lan-gateway configuration card, rendered by the official DSH Plugins page
|
|
3
|
+
* on this bundle's own page through its `plugins.bundle.config` slot.
|
|
4
4
|
*
|
|
5
5
|
* ModLens-style: the card carries NO injected services. It reads and writes
|
|
6
6
|
* the loopback-only `/lan-gateway/config` host route (the browser never sees
|
|
7
7
|
* the settings seam or any secret), so the only platform service it needs is
|
|
8
8
|
* the `slots` service every plugin already has.
|
|
9
9
|
*
|
|
10
|
+
* The page — not the card — draws the plugin's title, icon, and crumb, and the
|
|
11
|
+
* card is the page body: `view: 'summary'` renders nothing (bundle
|
|
12
|
+
* configuration is `page`-only).
|
|
13
|
+
*
|
|
10
14
|
* @module @riceawa/dsh-lan-gateway/client/card
|
|
11
15
|
*/
|
|
12
16
|
|
|
13
17
|
import { useEffect, useState, type ChangeEvent, type ReactNode } from 'react'
|
|
14
18
|
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
15
|
-
// Type-only. The Plugins page owns the `plugins.
|
|
16
|
-
// doc says a registrant merges that contract with `import type` instead of
|
|
19
|
+
// Type-only. The Plugins page owns the `plugins.bundle.config` contract, and its
|
|
20
|
+
// own doc says a registrant merges that contract with `import type` instead of
|
|
17
21
|
// importing the package at runtime. Taking the contract from its owner is also
|
|
18
22
|
// what turns the next upstream rename of this slot into a compile error here,
|
|
19
23
|
// rather than a card that quietly stops rendering.
|
|
@@ -29,12 +33,12 @@ import {
|
|
|
29
33
|
} from '../config-fields.ts'
|
|
30
34
|
|
|
31
35
|
/**
|
|
32
|
-
* Props the renderer binds for this card. The Plugins page asks
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* it fetches its own route.
|
|
36
|
+
* Props the renderer binds for this card. The Plugins page asks a bundle's
|
|
37
|
+
* configuration entry only for the body of its own page (`page`); the shared
|
|
38
|
+
* contract still carries `summary`, which bundle configuration never renders.
|
|
39
|
+
* The card needs no injected face — it fetches its own route.
|
|
36
40
|
*/
|
|
37
|
-
export type LanGatewayCardProps = PropsRuntime<'plugins.
|
|
41
|
+
export type LanGatewayCardProps = PropsRuntime<'plugins.bundle.config'>
|
|
38
42
|
|
|
39
43
|
/**
|
|
40
44
|
* The card's field table and value codecs live in `config-fields.ts`, shared
|
|
@@ -60,6 +64,27 @@ interface RouteState {
|
|
|
60
64
|
/** Why a password draft cannot be submitted yet. */
|
|
61
65
|
export type PasswordProblem = 'tooShort' | 'mismatch'
|
|
62
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The card's password badge state.
|
|
69
|
+
*
|
|
70
|
+
* Three states, not two: a host that predates the password route does not
|
|
71
|
+
* report `passwordSet` at all, and `undefined` must read as "unknown" rather
|
|
72
|
+
* than "not set" — a client refreshed against a running old host would
|
|
73
|
+
* otherwise announce that a gateway which is demonstrably running (it cannot
|
|
74
|
+
* start without a credential) has no password.
|
|
75
|
+
*/
|
|
76
|
+
export type PasswordStatus = 'set' | 'unset' | 'unknown'
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Classify the host's `passwordSet` for the badge.
|
|
80
|
+
* @param passwordSet - the snapshot's flag, or `undefined` when absent.
|
|
81
|
+
* @returns `set` / `unset` / `unknown` (absent field).
|
|
82
|
+
*/
|
|
83
|
+
export function passwordStatus(passwordSet: boolean | undefined): PasswordStatus {
|
|
84
|
+
if (passwordSet === undefined) return 'unknown'
|
|
85
|
+
return passwordSet ? 'set' : 'unset'
|
|
86
|
+
}
|
|
87
|
+
|
|
63
88
|
/**
|
|
64
89
|
* Judge a password draft the way the host's password route will, so a draft the
|
|
65
90
|
* card enables is never answered with a 400. The length bound is the shared
|
|
@@ -76,6 +101,23 @@ export function passwordProblem(password: string, confirm: string): PasswordProb
|
|
|
76
101
|
return null
|
|
77
102
|
}
|
|
78
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Whether the config route's refusal came from the gateway rather than from dsh
|
|
106
|
+
* itself.
|
|
107
|
+
*
|
|
108
|
+
* The gateway owns the `/lan-gateway*` prefix and answers it with a bare 403
|
|
109
|
+
* `forbidden` whenever the client is not on the host; dsh, when it answers the
|
|
110
|
+
* route at all, does not produce that pair. Telling the two apart is what lets
|
|
111
|
+
* the card say "you are not on the host loopback" instead of the generic
|
|
112
|
+
* "cannot read the configuration".
|
|
113
|
+
* @param status - the HTTP status of the failed read.
|
|
114
|
+
* @param body - that response's body.
|
|
115
|
+
* @returns true when the gateway itself refused the read.
|
|
116
|
+
*/
|
|
117
|
+
export function refusedByGateway(status: number, body: string): boolean {
|
|
118
|
+
return status === 403 && body.trim() === 'forbidden'
|
|
119
|
+
}
|
|
120
|
+
|
|
79
121
|
/* ------------------------------------------------------------------ */
|
|
80
122
|
/* Bilingual copy (ModLens-style: two small sets, picked by browser) */
|
|
81
123
|
/* ------------------------------------------------------------------ */
|
|
@@ -89,6 +131,7 @@ interface Labels {
|
|
|
89
131
|
discard: string
|
|
90
132
|
reset: string
|
|
91
133
|
readOnly: string
|
|
134
|
+
readOnlyGateway: string
|
|
92
135
|
saveFailed: string
|
|
93
136
|
loadFailed: string
|
|
94
137
|
emptyMeansClear: string
|
|
@@ -102,7 +145,9 @@ interface Labels {
|
|
|
102
145
|
passwordConfirm: string
|
|
103
146
|
passwordSet: string
|
|
104
147
|
passwordUnset: string
|
|
148
|
+
passwordUnknown: string
|
|
105
149
|
passwordRequired: string
|
|
150
|
+
passwordHostStale: string
|
|
106
151
|
passwordChange: string
|
|
107
152
|
passwordChanged: string
|
|
108
153
|
passwordFailed: string
|
|
@@ -123,7 +168,8 @@ const LABELS: Record<'zh' | 'en', Labels> = {
|
|
|
123
168
|
saving: '保存中…',
|
|
124
169
|
discard: '放弃',
|
|
125
170
|
reset: '重置',
|
|
126
|
-
readOnly: '
|
|
171
|
+
readOnly: '读不到网关配置:这个接口只在宿主机本机应答——Host 必须是回环地址且同源。请在宿主机上打开 dsh web 时修改,远程请改用 lan_gateway 工具。',
|
|
172
|
+
readOnlyGateway: '网关拒绝了这次读取:/lan-gateway/* 管理面由网关独占,只放行「TCP 来源为回环 且 地址写的是 127.0.0.1 / localhost」的浏览器。你现在不是从宿主机回环地址访问的——请在宿主机上用 127.0.0.1 或 localhost 打开本页(局域网 IP、域名都不算),或远程改用 lan_gateway 工具。',
|
|
127
173
|
saveFailed: '保存未生效,请检查输入后重试。',
|
|
128
174
|
loadFailed: '无法读取网关配置',
|
|
129
175
|
emptyMeansClear: '留空 = 使用默认',
|
|
@@ -137,7 +183,9 @@ const LABELS: Record<'zh' | 'en', Labels> = {
|
|
|
137
183
|
passwordConfirm: '再次输入新密码',
|
|
138
184
|
passwordSet: '已设置',
|
|
139
185
|
passwordUnset: '未设置',
|
|
186
|
+
passwordUnknown: '状态未知(宿主端较旧)',
|
|
140
187
|
passwordRequired: '未设置密码时网关拒绝启动。',
|
|
188
|
+
passwordHostStale: '宿主端没有报告密码状态,说明 dsh web 还在运行旧版本:重启后这里才会显示「已设置 / 未设置」,这个改密表单也才会生效。',
|
|
141
189
|
passwordChange: '修改密码',
|
|
142
190
|
passwordChanged: '密码已更新:旧密码立即失效,所有已登录会话已作废。',
|
|
143
191
|
passwordFailed: '修改密码失败,请重试。',
|
|
@@ -186,7 +234,8 @@ const LABELS: Record<'zh' | 'en', Labels> = {
|
|
|
186
234
|
saving: 'Saving…',
|
|
187
235
|
discard: 'Discard',
|
|
188
236
|
reset: 'Reset',
|
|
189
|
-
readOnly: '
|
|
237
|
+
readOnly: 'Cannot read the configuration: the route answers on the host only — the Host must be a loopback address and same-origin. Change the settings where dsh web runs on the host, or use the lan_gateway tool remotely.',
|
|
238
|
+
readOnlyGateway: 'The gateway refused this read: it owns the /lan-gateway/* management plane and admits only browsers that are both loopback-sourced and using a 127.0.0.1 / localhost address. You are not on the host loopback — open this page on the host via 127.0.0.1 or localhost (a LAN IP or a hostname does not qualify), or use the lan_gateway tool remotely.',
|
|
190
239
|
saveFailed: 'The save did not land — check the inputs and retry.',
|
|
191
240
|
loadFailed: 'Cannot read the gateway configuration',
|
|
192
241
|
emptyMeansClear: 'Empty = default',
|
|
@@ -200,7 +249,9 @@ const LABELS: Record<'zh' | 'en', Labels> = {
|
|
|
200
249
|
passwordConfirm: 'Repeat new password',
|
|
201
250
|
passwordSet: 'Set',
|
|
202
251
|
passwordUnset: 'Not set',
|
|
252
|
+
passwordUnknown: 'Unknown (older host)',
|
|
203
253
|
passwordRequired: 'The gateway refuses to start without a password.',
|
|
254
|
+
passwordHostStale: 'The host did not report a password state, so dsh web is still running the previous build: restart it to get Set / Not set here and to make this form take effect.',
|
|
204
255
|
passwordChange: 'Change password',
|
|
205
256
|
passwordChanged: 'Password updated: the old one no longer works and every signed-in session was revoked.',
|
|
206
257
|
passwordFailed: 'The password change failed — retry.',
|
|
@@ -248,34 +299,24 @@ function labels(): Labels {
|
|
|
248
299
|
return lang.startsWith('zh') ? LABELS.zh : LABELS.en
|
|
249
300
|
}
|
|
250
301
|
|
|
251
|
-
/**
|
|
252
|
-
* The card's title in the browser's language, for the Plugins page's list
|
|
253
|
-
* entry. A thunk so the label follows the page's locale without re-registering.
|
|
254
|
-
* @returns the localized card title.
|
|
255
|
-
*/
|
|
256
|
-
export function cardTitle(): string {
|
|
257
|
-
return labels().title
|
|
258
|
-
}
|
|
259
|
-
|
|
260
302
|
/* ------------------------------------------------------------------ */
|
|
261
303
|
/* Card */
|
|
262
304
|
/* ------------------------------------------------------------------ */
|
|
263
305
|
|
|
264
306
|
/**
|
|
265
|
-
* Render the LAN gateway card. Self-loading: fetches the config
|
|
266
|
-
* mount, posts the edited config on save.
|
|
307
|
+
* Render the LAN gateway configuration card. Self-loading: fetches the config
|
|
308
|
+
* route on mount, posts the edited config on save.
|
|
267
309
|
*
|
|
268
|
-
* `
|
|
269
|
-
*
|
|
270
|
-
*
|
|
310
|
+
* `summary` renders nothing: dsh asks a bundle's own configuration for its page
|
|
311
|
+
* body only, and the card's former one-liner (a list entry on the official
|
|
312
|
+
* plugin card) went away with `plugins.item`.
|
|
271
313
|
* @param props - the view the Plugins page is asking for.
|
|
272
|
-
* @returns the
|
|
314
|
+
* @returns the page body, or nothing while the view is `summary`.
|
|
273
315
|
*/
|
|
274
316
|
export function LanGatewayCard(props: LanGatewayCardProps): ReactNode {
|
|
275
317
|
const t = labels()
|
|
276
|
-
const [open, setOpen] = useState(false)
|
|
277
318
|
const [route, setRoute] = useState<RouteState | null>(null)
|
|
278
|
-
const [
|
|
319
|
+
const [failure, setFailure] = useState<'gateway' | 'other' | null>(null)
|
|
279
320
|
const [drafts, setDrafts] = useState<Partial<Record<string, string>>>({})
|
|
280
321
|
const [saving, setSaving] = useState(false)
|
|
281
322
|
const [failed, setFailed] = useState<string | null>(null)
|
|
@@ -293,38 +334,38 @@ export function LanGatewayCard(props: LanGatewayCardProps): ReactNode {
|
|
|
293
334
|
fetch('/lan-gateway/config')
|
|
294
335
|
.then(async (response) => {
|
|
295
336
|
if (cancelled) return
|
|
296
|
-
if (!response.ok)
|
|
337
|
+
if (!response.ok) {
|
|
338
|
+
// The body is only read to tell the gateway's own bare refusal from
|
|
339
|
+
// any other answer; it is never rendered.
|
|
340
|
+
const body = await response.text().catch(() => '')
|
|
341
|
+
if (!cancelled) setFailure(refusedByGateway(response.status, body) ? 'gateway' : 'other')
|
|
342
|
+
return
|
|
343
|
+
}
|
|
297
344
|
setRoute(await response.json() as RouteState)
|
|
298
345
|
})
|
|
299
346
|
.catch(() => {
|
|
300
|
-
if (!cancelled)
|
|
347
|
+
if (!cancelled) setFailure('other')
|
|
301
348
|
})
|
|
302
349
|
return () => { cancelled = true }
|
|
303
350
|
}, [])
|
|
304
351
|
|
|
305
|
-
//
|
|
306
|
-
//
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
if (props.view === 'summary') return
|
|
310
|
-
|
|
311
|
-
// A
|
|
312
|
-
// for the plugin's own prefix by design, so the route is
|
|
313
|
-
// where a user is most likely to go looking for the
|
|
314
|
-
// nothing left them with a blank entry and no way to tell a
|
|
315
|
-
// a broken one; say what is wrong and
|
|
316
|
-
|
|
352
|
+
// dsh asks a bundle for its own configuration under `view: 'page'` only, and
|
|
353
|
+
// the page draws the title, icon, and crumb itself — so the card is the page
|
|
354
|
+
// body and has no one-liner. Returning null keeps the contribution honest for
|
|
355
|
+
// a host that ever asks for the other view.
|
|
356
|
+
if (props.view === 'summary') return null
|
|
357
|
+
|
|
358
|
+
// A browser that is not on the host reaches this card through the gateway,
|
|
359
|
+
// which answers 403 for the plugin's own prefix by design, so the route is
|
|
360
|
+
// unreachable exactly where a user is most likely to go looking for the
|
|
361
|
+
// setting. Rendering nothing left them with a blank entry and no way to tell a
|
|
362
|
+
// missing card from a broken one; say what is wrong — and whether the gateway
|
|
363
|
+
// or the host refused — and where the card does work instead.
|
|
364
|
+
if (failure !== null) {
|
|
317
365
|
return (
|
|
318
366
|
<div style={styles.card}>
|
|
319
|
-
<
|
|
320
|
-
|
|
321
|
-
<span style={styles.name}>{t.title}</span>
|
|
322
|
-
</span>
|
|
323
|
-
<span style={styles.description}>{t.loadFailed}</span>
|
|
324
|
-
</div>
|
|
325
|
-
<div style={styles.body}>
|
|
326
|
-
<p style={styles.hint}>{t.readOnly}</p>
|
|
327
|
-
</div>
|
|
367
|
+
<p style={styles.status}>{t.loadFailed}</p>
|
|
368
|
+
<p style={styles.hint}>{failure === 'gateway' ? t.readOnlyGateway : t.readOnly}</p>
|
|
328
369
|
</div>
|
|
329
370
|
)
|
|
330
371
|
}
|
|
@@ -361,6 +402,7 @@ export function LanGatewayCard(props: LanGatewayCardProps): ReactNode {
|
|
|
361
402
|
const dirty = Object.keys(drafts).length > 0
|
|
362
403
|
const passwordDraftProblem = passwordProblem(password, passwordConfirm)
|
|
363
404
|
const passwordTyping = password !== '' || passwordConfirm !== ''
|
|
405
|
+
const passwordBadge = passwordStatus(route.passwordSet)
|
|
364
406
|
|
|
365
407
|
/** Adopt the host's post-write snapshot, keeping the last known values. */
|
|
366
408
|
const adopt = (body: Partial<RouteState>): void => {
|
|
@@ -371,7 +413,10 @@ export function LanGatewayCard(props: LanGatewayCardProps): ReactNode {
|
|
|
371
413
|
port: body.port ?? 0,
|
|
372
414
|
tls: body.tls ?? '',
|
|
373
415
|
lastError: body.lastError ?? null,
|
|
374
|
-
|
|
416
|
+
// Absent is a state of its own (an older host does not report the field),
|
|
417
|
+
// so it must not be coerced into `false`: that is what made a running,
|
|
418
|
+
// password-protected gateway render as "not set".
|
|
419
|
+
...(body.passwordSet !== undefined ? { passwordSet: body.passwordSet } : {}),
|
|
375
420
|
})
|
|
376
421
|
}
|
|
377
422
|
|
|
@@ -540,111 +585,103 @@ export function LanGatewayCard(props: LanGatewayCardProps): ReactNode {
|
|
|
540
585
|
const statusLine = `${route.running ? t.running : t.stopped} · ${t.tls}: ${route.tls} · :${route.port}`
|
|
541
586
|
|
|
542
587
|
return (
|
|
543
|
-
<div style={
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
<
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
{route.lastError ? <p style={styles.error} role="status">{t.lastError}: {route.lastError}</p> : null}
|
|
562
|
-
<div style={styles.section}>
|
|
563
|
-
<div style={styles.sectionHead}>
|
|
564
|
-
<span style={styles.label}>{t.passwordSection}</span>
|
|
565
|
-
<span style={route.passwordSet === true ? styles.badge : styles.badgeAlert}>
|
|
566
|
-
{route.passwordSet === true ? t.passwordSet : t.passwordUnset}
|
|
567
|
-
</span>
|
|
568
|
-
</div>
|
|
569
|
-
<p style={styles.hint}>{t.passwordHint}</p>
|
|
570
|
-
{route.passwordSet === true ? null : <p style={styles.error}>{t.passwordRequired}</p>}
|
|
571
|
-
<div style={styles.passwordRow}>
|
|
572
|
-
<input
|
|
573
|
-
id="lan-gw-password"
|
|
574
|
-
type="password"
|
|
575
|
-
autoComplete="new-password"
|
|
576
|
-
aria-label={t.passwordNew}
|
|
577
|
-
style={passwordInput}
|
|
578
|
-
placeholder={t.passwordNew}
|
|
579
|
-
value={password}
|
|
580
|
-
disabled={passwordBusy}
|
|
581
|
-
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
582
|
-
setPasswordDraft(e.target.value)
|
|
583
|
-
setPasswordError(null)
|
|
584
|
-
setPasswordNotice(null)
|
|
585
|
-
}}
|
|
586
|
-
/>
|
|
587
|
-
<input
|
|
588
|
-
id="lan-gw-password-confirm"
|
|
589
|
-
type="password"
|
|
590
|
-
autoComplete="new-password"
|
|
591
|
-
aria-label={t.passwordConfirm}
|
|
592
|
-
style={passwordInput}
|
|
593
|
-
placeholder={t.passwordConfirm}
|
|
594
|
-
value={passwordConfirm}
|
|
595
|
-
disabled={passwordBusy}
|
|
596
|
-
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
597
|
-
setPasswordConfirmDraft(e.target.value)
|
|
598
|
-
setPasswordError(null)
|
|
599
|
-
setPasswordNotice(null)
|
|
600
|
-
}}
|
|
601
|
-
/>
|
|
602
|
-
</div>
|
|
603
|
-
<div style={styles.passwordFoot}>
|
|
604
|
-
{passwordError !== null
|
|
605
|
-
? <p style={styles.error} role="alert">{passwordError}</p>
|
|
606
|
-
: passwordNotice !== null
|
|
607
|
-
? <p style={styles.notice} role="status">{passwordNotice}</p>
|
|
608
|
-
: passwordTyping && passwordDraftProblem !== null
|
|
609
|
-
? (
|
|
610
|
-
<p style={styles.error} role="status">
|
|
611
|
-
{passwordDraftProblem === 'tooShort' ? t.passwordTooShort : t.passwordMismatch}
|
|
612
|
-
</p>
|
|
613
|
-
)
|
|
614
|
-
: null}
|
|
615
|
-
<button
|
|
616
|
-
type="button"
|
|
617
|
-
style={styles.save}
|
|
618
|
-
disabled={passwordBusy || passwordDraftProblem !== null}
|
|
619
|
-
onClick={() => { void changePassword() }}
|
|
620
|
-
>
|
|
621
|
-
{passwordBusy ? t.saving : t.passwordChange}
|
|
622
|
-
</button>
|
|
623
|
-
</div>
|
|
624
|
-
</div>
|
|
625
|
-
{FIELDS.map(def => <div key={def.field}>{renderControl(def)}</div>)}
|
|
626
|
-
<div style={styles.footer}>
|
|
627
|
-
{failed ? <p style={styles.error} role="status">{failed}</p> : null}
|
|
628
|
-
<button
|
|
629
|
-
type="button"
|
|
630
|
-
style={styles.discard}
|
|
631
|
-
disabled={!dirty || saving}
|
|
632
|
-
onClick={discard}
|
|
633
|
-
>
|
|
634
|
-
{t.discard}
|
|
635
|
-
</button>
|
|
636
|
-
<button
|
|
637
|
-
type="button"
|
|
638
|
-
style={styles.save}
|
|
639
|
-
disabled={!dirty || invalid() || saving}
|
|
640
|
-
onClick={() => { void save() }}
|
|
641
|
-
>
|
|
642
|
-
{saving ? t.saving : t.save}
|
|
643
|
-
</button>
|
|
644
|
-
</div>
|
|
588
|
+
<div style={styles.card}>
|
|
589
|
+
{/* The page above draws the plugin's title itself, so the card opens with
|
|
590
|
+
the live listener status and the unsaved marker — the two facts an
|
|
591
|
+
operator wants before touching a field. */}
|
|
592
|
+
<div style={styles.statusRow}>
|
|
593
|
+
<span style={styles.status} title={statusLine}>{statusLine}</span>
|
|
594
|
+
{dirty ? <span style={styles.pending}>{t.unsaved}</span> : null}
|
|
595
|
+
</div>
|
|
596
|
+
<div style={styles.body}>
|
|
597
|
+
{route.lastError ? <p style={styles.error} role="status">{t.lastError}: {route.lastError}</p> : null}
|
|
598
|
+
<div style={styles.section}>
|
|
599
|
+
<div style={styles.sectionHead}>
|
|
600
|
+
<span style={styles.label}>{t.passwordSection}</span>
|
|
601
|
+
<span style={passwordBadge === 'unset' ? styles.badgeAlert : styles.badge}>
|
|
602
|
+
{passwordBadge === 'set'
|
|
603
|
+
? t.passwordSet
|
|
604
|
+
: passwordBadge === 'unset' ? t.passwordUnset : t.passwordUnknown}
|
|
605
|
+
</span>
|
|
645
606
|
</div>
|
|
646
|
-
|
|
647
|
-
|
|
607
|
+
<p style={styles.hint}>{t.passwordHint}</p>
|
|
608
|
+
{passwordBadge === 'unset' ? <p style={styles.error}>{t.passwordRequired}</p> : null}
|
|
609
|
+
{passwordBadge === 'unknown' ? <p style={styles.hint}>{t.passwordHostStale}</p> : null}
|
|
610
|
+
<div style={styles.passwordRow}>
|
|
611
|
+
<input
|
|
612
|
+
id="lan-gw-password"
|
|
613
|
+
type="password"
|
|
614
|
+
autoComplete="new-password"
|
|
615
|
+
aria-label={t.passwordNew}
|
|
616
|
+
style={passwordInput}
|
|
617
|
+
placeholder={t.passwordNew}
|
|
618
|
+
value={password}
|
|
619
|
+
disabled={passwordBusy}
|
|
620
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
621
|
+
setPasswordDraft(e.target.value)
|
|
622
|
+
setPasswordError(null)
|
|
623
|
+
setPasswordNotice(null)
|
|
624
|
+
}}
|
|
625
|
+
/>
|
|
626
|
+
<input
|
|
627
|
+
id="lan-gw-password-confirm"
|
|
628
|
+
type="password"
|
|
629
|
+
autoComplete="new-password"
|
|
630
|
+
aria-label={t.passwordConfirm}
|
|
631
|
+
style={passwordInput}
|
|
632
|
+
placeholder={t.passwordConfirm}
|
|
633
|
+
value={passwordConfirm}
|
|
634
|
+
disabled={passwordBusy}
|
|
635
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
636
|
+
setPasswordConfirmDraft(e.target.value)
|
|
637
|
+
setPasswordError(null)
|
|
638
|
+
setPasswordNotice(null)
|
|
639
|
+
}}
|
|
640
|
+
/>
|
|
641
|
+
</div>
|
|
642
|
+
<div style={styles.passwordFoot}>
|
|
643
|
+
{passwordError !== null
|
|
644
|
+
? <p style={styles.error} role="alert">{passwordError}</p>
|
|
645
|
+
: passwordNotice !== null
|
|
646
|
+
? <p style={styles.notice} role="status">{passwordNotice}</p>
|
|
647
|
+
: passwordTyping && passwordDraftProblem !== null
|
|
648
|
+
? (
|
|
649
|
+
<p style={styles.error} role="status">
|
|
650
|
+
{passwordDraftProblem === 'tooShort' ? t.passwordTooShort : t.passwordMismatch}
|
|
651
|
+
</p>
|
|
652
|
+
)
|
|
653
|
+
: null}
|
|
654
|
+
<button
|
|
655
|
+
type="button"
|
|
656
|
+
style={styles.save}
|
|
657
|
+
disabled={passwordBusy || passwordDraftProblem !== null}
|
|
658
|
+
onClick={() => { void changePassword() }}
|
|
659
|
+
>
|
|
660
|
+
{passwordBusy ? t.saving : t.passwordChange}
|
|
661
|
+
</button>
|
|
662
|
+
</div>
|
|
663
|
+
</div>
|
|
664
|
+
{FIELDS.map(def => <div key={def.field}>{renderControl(def)}</div>)}
|
|
665
|
+
<div style={styles.footer}>
|
|
666
|
+
{failed ? <p style={styles.error} role="status">{failed}</p> : null}
|
|
667
|
+
<button
|
|
668
|
+
type="button"
|
|
669
|
+
style={styles.discard}
|
|
670
|
+
disabled={!dirty || saving}
|
|
671
|
+
onClick={discard}
|
|
672
|
+
>
|
|
673
|
+
{t.discard}
|
|
674
|
+
</button>
|
|
675
|
+
<button
|
|
676
|
+
type="button"
|
|
677
|
+
style={styles.save}
|
|
678
|
+
disabled={!dirty || invalid() || saving}
|
|
679
|
+
onClick={() => { void save() }}
|
|
680
|
+
>
|
|
681
|
+
{saving ? t.saving : t.save}
|
|
682
|
+
</button>
|
|
683
|
+
</div>
|
|
684
|
+
</div>
|
|
648
685
|
</div>
|
|
649
686
|
)
|
|
650
687
|
}
|
|
@@ -663,13 +700,10 @@ function tk(token: string, fallback: string): string {
|
|
|
663
700
|
const L = {
|
|
664
701
|
border: tk('--dsw-alias-border-l2', 'rgba(127,127,127,0.35)'),
|
|
665
702
|
bg: tk('--dsw-alias-bg-layer-3', 'transparent'),
|
|
666
|
-
bgOpen: tk('--dsw-alias-bg-layer-2', 'transparent'),
|
|
667
703
|
labelPrimary: tk('--dsw-alias-label-primary', 'inherit'),
|
|
668
704
|
labelSecondary: tk('--dsw-alias-label-secondary', 'inherit'),
|
|
669
705
|
labelTertiary: tk('--dsw-alias-label-tertiary', 'rgba(127,127,127,0.8)'),
|
|
670
|
-
labelDimmed: tk('--dsw-alias-label-dimmed', 'rgba(127,127,127,0.6)'),
|
|
671
706
|
error: tk('--dsw-alias-label-error', '#d1242f'),
|
|
672
|
-
brand: tk('--dsw-alias-brand-primary', '#4f6ef7'),
|
|
673
707
|
badgeBg: tk('--dsw-alias-bg-module-platform', 'rgba(127,127,127,0.14)'),
|
|
674
708
|
}
|
|
675
709
|
|
|
@@ -679,36 +713,25 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
679
713
|
border: `1px solid ${L.border}`,
|
|
680
714
|
borderRadius: '12px',
|
|
681
715
|
background: L.bg,
|
|
682
|
-
|
|
716
|
+
display: 'flex',
|
|
717
|
+
flexDirection: 'column',
|
|
683
718
|
overflow: 'hidden',
|
|
684
719
|
},
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
},
|
|
689
|
-
header: {
|
|
720
|
+
// The page draws the plugin's title above this body, so the card leads with
|
|
721
|
+
// the live listener status and the unsaved marker instead.
|
|
722
|
+
statusRow: {
|
|
690
723
|
display: 'flex',
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
padding: '14px 16px',
|
|
696
|
-
border: 0,
|
|
697
|
-
background: 'none',
|
|
698
|
-
font: 'inherit',
|
|
699
|
-
color: 'inherit',
|
|
700
|
-
textAlign: 'left',
|
|
701
|
-
cursor: 'pointer',
|
|
724
|
+
alignItems: 'center',
|
|
725
|
+
gap: '12px',
|
|
726
|
+
padding: '12px 16px',
|
|
727
|
+
borderBottom: `1px solid ${L.border}`,
|
|
702
728
|
},
|
|
703
|
-
headerTop: { display: 'flex', alignItems: 'center', gap: '12px', width: '100%' },
|
|
704
|
-
name: { flex: '1 1 auto', minWidth: 0, fontSize: '15px', fontWeight: 600, lineHeight: 1.4, color: L.labelPrimary },
|
|
705
729
|
// The status carries a verbose TLS cert summary; cap it and ellipsize so it
|
|
706
|
-
// can never swallow the row
|
|
707
|
-
// caused the description to be pushed into a thin wrapping column).
|
|
730
|
+
// can never swallow the row.
|
|
708
731
|
status: {
|
|
709
732
|
flex: '0 1 auto',
|
|
710
733
|
minWidth: 0,
|
|
711
|
-
maxWidth: '
|
|
734
|
+
maxWidth: '80%',
|
|
712
735
|
fontSize: '11px',
|
|
713
736
|
lineHeight: 1.4,
|
|
714
737
|
color: L.labelTertiary,
|
|
@@ -716,15 +739,6 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
716
739
|
overflow: 'hidden',
|
|
717
740
|
textOverflow: 'ellipsis',
|
|
718
741
|
},
|
|
719
|
-
description: {
|
|
720
|
-
display: 'block',
|
|
721
|
-
fontSize: '13px',
|
|
722
|
-
lineHeight: 1.5,
|
|
723
|
-
color: L.labelTertiary,
|
|
724
|
-
whiteSpace: 'nowrap',
|
|
725
|
-
overflow: 'hidden',
|
|
726
|
-
textOverflow: 'ellipsis',
|
|
727
|
-
},
|
|
728
742
|
pending: {
|
|
729
743
|
flex: 'none',
|
|
730
744
|
borderRadius: '999px',
|
|
@@ -736,12 +750,8 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
736
750
|
background: L.badgeBg,
|
|
737
751
|
color: L.labelSecondary,
|
|
738
752
|
},
|
|
739
|
-
chevron: { flex: 'none', color: L.labelTertiary, fontSize: '12px', transition: 'transform .16s' },
|
|
740
|
-
chevronOpen: { transform: 'rotate(180deg)' },
|
|
741
753
|
body: {
|
|
742
|
-
|
|
743
|
-
margin: '0 16px',
|
|
744
|
-
paddingBottom: '8px',
|
|
754
|
+
padding: '4px 16px 8px',
|
|
745
755
|
display: 'flex',
|
|
746
756
|
flexDirection: 'column',
|
|
747
757
|
},
|
package/src/gateway.ts
CHANGED
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
} from './login.ts'
|
|
54
54
|
import {
|
|
55
55
|
downstreamResponseHeaders,
|
|
56
|
+
isLoopbackAuthority,
|
|
56
57
|
isOwnedPath,
|
|
57
58
|
loginOriginAllowed,
|
|
58
59
|
pathOf,
|
|
@@ -265,6 +266,24 @@ export class LanGateway {
|
|
|
265
266
|
: classifySource(req.socket.remoteAddress, this.config.lanCidrs)
|
|
266
267
|
}
|
|
267
268
|
|
|
269
|
+
/**
|
|
270
|
+
* Whether a request for the gateway-owned management prefix comes from the
|
|
271
|
+
* host itself, and may therefore be relayed to the loopback-only config and
|
|
272
|
+
* password routes.
|
|
273
|
+
*
|
|
274
|
+
* Both tests are required and neither is sufficient alone. The socket source
|
|
275
|
+
* is what a remote client cannot forge; the authority the client named is what
|
|
276
|
+
* a trusted TLS terminator deployment cannot blur, because there every socket
|
|
277
|
+
* source is the terminator's own loopback address while the browser still
|
|
278
|
+
* names the public host it dialed. A local browser that used `127.0.0.1` (or
|
|
279
|
+
* `localhost`) is the same operator the native route already trusts, and it
|
|
280
|
+
* still has to clear the session gate and the same-site fence below before
|
|
281
|
+
* anything is forwarded.
|
|
282
|
+
*/
|
|
283
|
+
private localManagementRequest(req: http.IncomingMessage, source: SourceClass): boolean {
|
|
284
|
+
return source === 'loopback' && isLoopbackAuthority(req.headers.host)
|
|
285
|
+
}
|
|
286
|
+
|
|
268
287
|
/**
|
|
269
288
|
* The session a request carries, or undefined when it presents none, presents
|
|
270
289
|
* one that no longer verifies under the current epoch, or presents one whose
|
|
@@ -323,7 +342,10 @@ export class LanGateway {
|
|
|
323
342
|
return `${this.config.cookieName}=${value}; ${attributes}${this.config.secureCookies ? '; Secure' : ''}`
|
|
324
343
|
}
|
|
325
344
|
|
|
326
|
-
/**
|
|
345
|
+
/**
|
|
346
|
+
* Handle one HTTP request: login surface → owned-path gate → session gate →
|
|
347
|
+
* same-site gate → relay.
|
|
348
|
+
*/
|
|
327
349
|
private async handleHttp(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
328
350
|
const url = req.url ?? '/'
|
|
329
351
|
const pathname = pathOf(url)
|
|
@@ -338,10 +360,14 @@ export class LanGateway {
|
|
|
338
360
|
return
|
|
339
361
|
}
|
|
340
362
|
|
|
341
|
-
// The gateway's own management surface never reaches dsh
|
|
342
|
-
// remote request must not be able to
|
|
343
|
-
// having the gateway rewrite Host to
|
|
344
|
-
|
|
363
|
+
// The gateway's own management surface never reaches dsh from anywhere but
|
|
364
|
+
// the host itself: an unauthenticated remote request must not be able to
|
|
365
|
+
// touch the loopback-only config route by having the gateway rewrite Host to
|
|
366
|
+
// loopback for it. A host-local browser (`localManagementRequest`) is
|
|
367
|
+
// exempt, so the settings card on the gateway origin behaves like the native
|
|
368
|
+
// card instead of being refused exactly where the user goes looking for it;
|
|
369
|
+
// it still has to clear the session and same-site gates below.
|
|
370
|
+
if (isOwnedPath(pathname) && !this.localManagementRequest(req, source)) {
|
|
345
371
|
res.writeHead(403, this.securityHeaders())
|
|
346
372
|
res.end('forbidden')
|
|
347
373
|
return
|