@riceawa/dsh-lan-gateway 0.6.0 → 0.6.2
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 +22 -10
- package/lib/client.js +251 -7
- package/lib/client.js.map +1 -1
- package/lib/index.js +97 -43
- package/package.json +2 -2
- package/skills/lan-gateway.md +15 -1
- package/src/client/lan-gateway-card.tsx +269 -9
- package/src/config-fields.ts +13 -0
- package/src/index.ts +129 -54
|
@@ -20,6 +20,7 @@ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
|
20
20
|
import type {} from '@deepseek-ai/dsh-client-ui-plugin-manager/client'
|
|
21
21
|
import {
|
|
22
22
|
FIELDS,
|
|
23
|
+
MIN_PASSWORD_LENGTH,
|
|
23
24
|
TRISTATE_OPTIONS,
|
|
24
25
|
formatValue,
|
|
25
26
|
parseValue,
|
|
@@ -52,6 +53,48 @@ interface RouteState {
|
|
|
52
53
|
port: number
|
|
53
54
|
tls: string
|
|
54
55
|
lastError: string | null
|
|
56
|
+
/** Whether a login credential exists. Never the credential itself. */
|
|
57
|
+
passwordSet?: boolean
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Why a password draft cannot be submitted yet. */
|
|
61
|
+
export type PasswordProblem = 'tooShort' | 'mismatch'
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The card's password badge state.
|
|
65
|
+
*
|
|
66
|
+
* Three states, not two: a host that predates the password route does not
|
|
67
|
+
* report `passwordSet` at all, and `undefined` must read as "unknown" rather
|
|
68
|
+
* than "not set" — a client refreshed against a running old host would
|
|
69
|
+
* otherwise announce that a gateway which is demonstrably running (it cannot
|
|
70
|
+
* start without a credential) has no password.
|
|
71
|
+
*/
|
|
72
|
+
export type PasswordStatus = 'set' | 'unset' | 'unknown'
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Classify the host's `passwordSet` for the badge.
|
|
76
|
+
* @param passwordSet - the snapshot's flag, or `undefined` when absent.
|
|
77
|
+
* @returns `set` / `unset` / `unknown` (absent field).
|
|
78
|
+
*/
|
|
79
|
+
export function passwordStatus(passwordSet: boolean | undefined): PasswordStatus {
|
|
80
|
+
if (passwordSet === undefined) return 'unknown'
|
|
81
|
+
return passwordSet ? 'set' : 'unset'
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Judge a password draft the way the host's password route will, so a draft the
|
|
86
|
+
* card enables is never answered with a 400. The length bound is the shared
|
|
87
|
+
* {@link MIN_PASSWORD_LENGTH}; the confirmation is a UI concern and is checked
|
|
88
|
+
* here rather than server-side (the host is told one password, and storing only
|
|
89
|
+
* what was typed twice is the browser's job).
|
|
90
|
+
* @param password - the new password draft.
|
|
91
|
+
* @param confirm - the confirmation draft.
|
|
92
|
+
* @returns the reason it cannot be submitted, or `null` when it can.
|
|
93
|
+
*/
|
|
94
|
+
export function passwordProblem(password: string, confirm: string): PasswordProblem | null {
|
|
95
|
+
if (password.length < MIN_PASSWORD_LENGTH) return 'tooShort'
|
|
96
|
+
if (password !== confirm) return 'mismatch'
|
|
97
|
+
return null
|
|
55
98
|
}
|
|
56
99
|
|
|
57
100
|
/* ------------------------------------------------------------------ */
|
|
@@ -74,6 +117,21 @@ interface Labels {
|
|
|
74
117
|
stopped: string
|
|
75
118
|
tls: string
|
|
76
119
|
lastError: string
|
|
120
|
+
passwordSection: string
|
|
121
|
+
passwordHint: string
|
|
122
|
+
passwordNew: string
|
|
123
|
+
passwordConfirm: string
|
|
124
|
+
passwordSet: string
|
|
125
|
+
passwordUnset: string
|
|
126
|
+
passwordUnknown: string
|
|
127
|
+
passwordRequired: string
|
|
128
|
+
passwordHostStale: string
|
|
129
|
+
passwordChange: string
|
|
130
|
+
passwordChanged: string
|
|
131
|
+
passwordFailed: string
|
|
132
|
+
passwordStaleHost: string
|
|
133
|
+
passwordTooShort: string
|
|
134
|
+
passwordMismatch: string
|
|
77
135
|
[key: `field.${string}`]: string
|
|
78
136
|
[key: `hint.${string}`]: string
|
|
79
137
|
[key: `opt.${string}`]: string
|
|
@@ -96,6 +154,21 @@ const LABELS: Record<'zh' | 'en', Labels> = {
|
|
|
96
154
|
stopped: '已停止',
|
|
97
155
|
tls: 'TLS',
|
|
98
156
|
lastError: '上次错误',
|
|
157
|
+
passwordSection: '登录密码',
|
|
158
|
+
passwordHint: '当前密码不会显示(也读不出来)。输入两次新密码后直接覆盖原密码;改密会递增会话代次,所有已登录会话与已建立的 WebSocket 立即失效。仅在从本机 loopback 打开 dsh web 时可改。',
|
|
159
|
+
passwordNew: '新密码(至少 8 位)',
|
|
160
|
+
passwordConfirm: '再次输入新密码',
|
|
161
|
+
passwordSet: '已设置',
|
|
162
|
+
passwordUnset: '未设置',
|
|
163
|
+
passwordUnknown: '状态未知(宿主端较旧)',
|
|
164
|
+
passwordRequired: '未设置密码时网关拒绝启动。',
|
|
165
|
+
passwordHostStale: '宿主端没有报告密码状态,说明 dsh web 还在运行旧版本:重启后这里才会显示「已设置 / 未设置」,这个改密表单也才会生效。',
|
|
166
|
+
passwordChange: '修改密码',
|
|
167
|
+
passwordChanged: '密码已更新:旧密码立即失效,所有已登录会话已作废。',
|
|
168
|
+
passwordFailed: '修改密码失败,请重试。',
|
|
169
|
+
passwordStaleHost: '宿主端没有响应这个接口:可能还没重启 dsh web 加载新版本。重启后再试;重启前仍可用 lan_gateway 工具改密。',
|
|
170
|
+
passwordTooShort: '密码至少 8 位。',
|
|
171
|
+
passwordMismatch: '两次输入不一致。',
|
|
99
172
|
'field.enabled': '启用网关',
|
|
100
173
|
'hint.enabled': '启动时监听 0.0.0.0 网关端口',
|
|
101
174
|
'field.gatewayPort': '网关端口',
|
|
@@ -146,6 +219,21 @@ const LABELS: Record<'zh' | 'en', Labels> = {
|
|
|
146
219
|
stopped: 'Stopped',
|
|
147
220
|
tls: 'TLS',
|
|
148
221
|
lastError: 'Last error',
|
|
222
|
+
passwordSection: 'Login password',
|
|
223
|
+
passwordHint: 'The current password is never shown (it cannot be read back). Enter a new one twice to overwrite it; changing it advances the session epoch, so every signed-in session and live WebSocket is invalidated at once. Only changeable where dsh web runs on loopback.',
|
|
224
|
+
passwordNew: 'New password (min 8 chars)',
|
|
225
|
+
passwordConfirm: 'Repeat new password',
|
|
226
|
+
passwordSet: 'Set',
|
|
227
|
+
passwordUnset: 'Not set',
|
|
228
|
+
passwordUnknown: 'Unknown (older host)',
|
|
229
|
+
passwordRequired: 'The gateway refuses to start without a password.',
|
|
230
|
+
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.',
|
|
231
|
+
passwordChange: 'Change password',
|
|
232
|
+
passwordChanged: 'Password updated: the old one no longer works and every signed-in session was revoked.',
|
|
233
|
+
passwordFailed: 'The password change failed — retry.',
|
|
234
|
+
passwordStaleHost: 'The host did not answer this endpoint — it may still be running the previous build. Restart dsh web and retry; the lan_gateway tool can change the password meanwhile.',
|
|
235
|
+
passwordTooShort: 'At least 8 characters.',
|
|
236
|
+
passwordMismatch: 'The two entries do not match.',
|
|
149
237
|
'field.enabled': 'Enable gateway',
|
|
150
238
|
'hint.enabled': 'Listen on the gateway port at boot',
|
|
151
239
|
'field.gatewayPort': 'Gateway port',
|
|
@@ -218,6 +306,14 @@ export function LanGatewayCard(props: LanGatewayCardProps): ReactNode {
|
|
|
218
306
|
const [drafts, setDrafts] = useState<Partial<Record<string, string>>>({})
|
|
219
307
|
const [saving, setSaving] = useState(false)
|
|
220
308
|
const [failed, setFailed] = useState<string | null>(null)
|
|
309
|
+
// The password section is its own submit path: a password is not a settings
|
|
310
|
+
// key, so it must not ride the config patch (nor light up the card's "未保存"
|
|
311
|
+
// badge for every keystroke in a password box).
|
|
312
|
+
const [password, setPasswordDraft] = useState('')
|
|
313
|
+
const [passwordConfirm, setPasswordConfirmDraft] = useState('')
|
|
314
|
+
const [passwordBusy, setPasswordBusy] = useState(false)
|
|
315
|
+
const [passwordError, setPasswordError] = useState<string | null>(null)
|
|
316
|
+
const [passwordNotice, setPasswordNotice] = useState<string | null>(null)
|
|
221
317
|
|
|
222
318
|
useEffect(() => {
|
|
223
319
|
let cancelled = false
|
|
@@ -290,6 +386,68 @@ export function LanGatewayCard(props: LanGatewayCardProps): ReactNode {
|
|
|
290
386
|
})
|
|
291
387
|
|
|
292
388
|
const dirty = Object.keys(drafts).length > 0
|
|
389
|
+
const passwordDraftProblem = passwordProblem(password, passwordConfirm)
|
|
390
|
+
const passwordTyping = password !== '' || passwordConfirm !== ''
|
|
391
|
+
const passwordBadge = passwordStatus(route.passwordSet)
|
|
392
|
+
|
|
393
|
+
/** Adopt the host's post-write snapshot, keeping the last known values. */
|
|
394
|
+
const adopt = (body: Partial<RouteState>): void => {
|
|
395
|
+
if (body.config === undefined) return
|
|
396
|
+
setRoute({
|
|
397
|
+
config: body.config,
|
|
398
|
+
running: body.running ?? false,
|
|
399
|
+
port: body.port ?? 0,
|
|
400
|
+
tls: body.tls ?? '',
|
|
401
|
+
lastError: body.lastError ?? null,
|
|
402
|
+
// Absent is a state of its own (an older host does not report the field),
|
|
403
|
+
// so it must not be coerced into `false`: that is what made a running,
|
|
404
|
+
// password-protected gateway render as "not set".
|
|
405
|
+
...(body.passwordSet !== undefined ? { passwordSet: body.passwordSet } : {}),
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Overwrite the login password. Deliberately writes only to
|
|
411
|
+
* `/lan-gateway/password`: the credential is not part of the config patch, so
|
|
412
|
+
* a change here can never disturb an unsaved settings draft, and the old
|
|
413
|
+
* password is neither sent nor requested.
|
|
414
|
+
*/
|
|
415
|
+
const changePassword = async (): Promise<void> => {
|
|
416
|
+
if (passwordBusy || passwordProblem(password, passwordConfirm) !== null) return
|
|
417
|
+
setPasswordBusy(true)
|
|
418
|
+
setPasswordError(null)
|
|
419
|
+
setPasswordNotice(null)
|
|
420
|
+
try {
|
|
421
|
+
const response = await fetch('/lan-gateway/password', {
|
|
422
|
+
method: 'POST',
|
|
423
|
+
headers: { 'content-type': 'application/json' },
|
|
424
|
+
body: JSON.stringify({ password }),
|
|
425
|
+
})
|
|
426
|
+
// A host that predates this route answers the SPA fallback (HTML, 200),
|
|
427
|
+
// not a 404 — so "not our JSON" is the signal, and it must not read as a
|
|
428
|
+
// wrong password or a network failure.
|
|
429
|
+
const body = await response.json().catch(() => null) as (Partial<RouteState> & { error?: string }) | null
|
|
430
|
+
if (!response.ok) {
|
|
431
|
+
setPasswordError(body?.error ?? (response.status === 404 ? t.passwordStaleHost : `HTTP ${response.status}`))
|
|
432
|
+
return
|
|
433
|
+
}
|
|
434
|
+
if (body === null) {
|
|
435
|
+
setPasswordError(t.passwordStaleHost)
|
|
436
|
+
return
|
|
437
|
+
}
|
|
438
|
+
// Clear the boxes the moment the write lands: the new credential is now
|
|
439
|
+
// the stored one, and leaving it on screen is the one thing a password
|
|
440
|
+
// field should not do.
|
|
441
|
+
setPasswordDraft('')
|
|
442
|
+
setPasswordConfirmDraft('')
|
|
443
|
+
setPasswordNotice(t.passwordChanged)
|
|
444
|
+
adopt(body)
|
|
445
|
+
} catch {
|
|
446
|
+
setPasswordError(t.passwordFailed)
|
|
447
|
+
} finally {
|
|
448
|
+
setPasswordBusy(false)
|
|
449
|
+
}
|
|
450
|
+
}
|
|
293
451
|
|
|
294
452
|
const save = async (): Promise<void> => {
|
|
295
453
|
if (!dirty || saving || invalid()) return
|
|
@@ -318,15 +476,7 @@ export function LanGatewayCard(props: LanGatewayCardProps): ReactNode {
|
|
|
318
476
|
setFailed(body.error ?? `HTTP ${response.status}`)
|
|
319
477
|
return
|
|
320
478
|
}
|
|
321
|
-
|
|
322
|
-
setRoute({
|
|
323
|
-
config: body.config,
|
|
324
|
-
running: body.running ?? false,
|
|
325
|
-
port: body.port ?? 0,
|
|
326
|
-
tls: body.tls ?? '',
|
|
327
|
-
lastError: body.lastError ?? null,
|
|
328
|
-
})
|
|
329
|
-
}
|
|
479
|
+
adopt(body)
|
|
330
480
|
setDrafts({})
|
|
331
481
|
} catch {
|
|
332
482
|
setFailed(t.saveFailed)
|
|
@@ -440,6 +590,72 @@ export function LanGatewayCard(props: LanGatewayCardProps): ReactNode {
|
|
|
440
590
|
? (
|
|
441
591
|
<div style={styles.body}>
|
|
442
592
|
{route.lastError ? <p style={styles.error} role="status">{t.lastError}: {route.lastError}</p> : null}
|
|
593
|
+
<div style={styles.section}>
|
|
594
|
+
<div style={styles.sectionHead}>
|
|
595
|
+
<span style={styles.label}>{t.passwordSection}</span>
|
|
596
|
+
<span style={passwordBadge === 'unset' ? styles.badgeAlert : styles.badge}>
|
|
597
|
+
{passwordBadge === 'set'
|
|
598
|
+
? t.passwordSet
|
|
599
|
+
: passwordBadge === 'unset' ? t.passwordUnset : t.passwordUnknown}
|
|
600
|
+
</span>
|
|
601
|
+
</div>
|
|
602
|
+
<p style={styles.hint}>{t.passwordHint}</p>
|
|
603
|
+
{passwordBadge === 'unset' ? <p style={styles.error}>{t.passwordRequired}</p> : null}
|
|
604
|
+
{passwordBadge === 'unknown' ? <p style={styles.hint}>{t.passwordHostStale}</p> : null}
|
|
605
|
+
<div style={styles.passwordRow}>
|
|
606
|
+
<input
|
|
607
|
+
id="lan-gw-password"
|
|
608
|
+
type="password"
|
|
609
|
+
autoComplete="new-password"
|
|
610
|
+
aria-label={t.passwordNew}
|
|
611
|
+
style={passwordInput}
|
|
612
|
+
placeholder={t.passwordNew}
|
|
613
|
+
value={password}
|
|
614
|
+
disabled={passwordBusy}
|
|
615
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
616
|
+
setPasswordDraft(e.target.value)
|
|
617
|
+
setPasswordError(null)
|
|
618
|
+
setPasswordNotice(null)
|
|
619
|
+
}}
|
|
620
|
+
/>
|
|
621
|
+
<input
|
|
622
|
+
id="lan-gw-password-confirm"
|
|
623
|
+
type="password"
|
|
624
|
+
autoComplete="new-password"
|
|
625
|
+
aria-label={t.passwordConfirm}
|
|
626
|
+
style={passwordInput}
|
|
627
|
+
placeholder={t.passwordConfirm}
|
|
628
|
+
value={passwordConfirm}
|
|
629
|
+
disabled={passwordBusy}
|
|
630
|
+
onChange={(e: ChangeEvent<HTMLInputElement>) => {
|
|
631
|
+
setPasswordConfirmDraft(e.target.value)
|
|
632
|
+
setPasswordError(null)
|
|
633
|
+
setPasswordNotice(null)
|
|
634
|
+
}}
|
|
635
|
+
/>
|
|
636
|
+
</div>
|
|
637
|
+
<div style={styles.passwordFoot}>
|
|
638
|
+
{passwordError !== null
|
|
639
|
+
? <p style={styles.error} role="alert">{passwordError}</p>
|
|
640
|
+
: passwordNotice !== null
|
|
641
|
+
? <p style={styles.notice} role="status">{passwordNotice}</p>
|
|
642
|
+
: passwordTyping && passwordDraftProblem !== null
|
|
643
|
+
? (
|
|
644
|
+
<p style={styles.error} role="status">
|
|
645
|
+
{passwordDraftProblem === 'tooShort' ? t.passwordTooShort : t.passwordMismatch}
|
|
646
|
+
</p>
|
|
647
|
+
)
|
|
648
|
+
: null}
|
|
649
|
+
<button
|
|
650
|
+
type="button"
|
|
651
|
+
style={styles.save}
|
|
652
|
+
disabled={passwordBusy || passwordDraftProblem !== null}
|
|
653
|
+
onClick={() => { void changePassword() }}
|
|
654
|
+
>
|
|
655
|
+
{passwordBusy ? t.saving : t.passwordChange}
|
|
656
|
+
</button>
|
|
657
|
+
</div>
|
|
658
|
+
</div>
|
|
443
659
|
{FIELDS.map(def => <div key={def.field}>{renderControl(def)}</div>)}
|
|
444
660
|
<div style={styles.footer}>
|
|
445
661
|
{failed ? <p style={styles.error} role="status">{failed}</p> : null}
|
|
@@ -563,6 +779,47 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
563
779
|
display: 'flex',
|
|
564
780
|
flexDirection: 'column',
|
|
565
781
|
},
|
|
782
|
+
// The password block sits inside the card body, ahead of the config fields:
|
|
783
|
+
// it is the setting an operator comes here for, and it owns its own submit
|
|
784
|
+
// button (a password is not part of the settings patch), so it is separated
|
|
785
|
+
// by a rule rather than merged into the field list.
|
|
786
|
+
section: {
|
|
787
|
+
display: 'flex',
|
|
788
|
+
flexDirection: 'column',
|
|
789
|
+
gap: '8px',
|
|
790
|
+
padding: '14px 0',
|
|
791
|
+
borderBottom: `1px solid ${L.border}`,
|
|
792
|
+
},
|
|
793
|
+
sectionHead: { display: 'flex', alignItems: 'center', gap: '8px' },
|
|
794
|
+
badge: {
|
|
795
|
+
borderRadius: '999px',
|
|
796
|
+
padding: '1px 8px',
|
|
797
|
+
fontSize: '11px',
|
|
798
|
+
lineHeight: '17px',
|
|
799
|
+
fontWeight: 500,
|
|
800
|
+
whiteSpace: 'nowrap',
|
|
801
|
+
background: L.badgeBg,
|
|
802
|
+
color: L.labelSecondary,
|
|
803
|
+
},
|
|
804
|
+
badgeAlert: {
|
|
805
|
+
borderRadius: '999px',
|
|
806
|
+
padding: '1px 8px',
|
|
807
|
+
fontSize: '11px',
|
|
808
|
+
lineHeight: '17px',
|
|
809
|
+
fontWeight: 500,
|
|
810
|
+
whiteSpace: 'nowrap',
|
|
811
|
+
background: L.badgeBg,
|
|
812
|
+
color: L.error,
|
|
813
|
+
},
|
|
814
|
+
notice: { flex: 1, minWidth: 0, margin: 0, fontSize: '12px', lineHeight: 1.5, color: L.labelSecondary },
|
|
815
|
+
passwordRow: { display: 'flex', flexWrap: 'wrap', gap: '8px' },
|
|
816
|
+
passwordFoot: {
|
|
817
|
+
display: 'flex',
|
|
818
|
+
alignItems: 'center',
|
|
819
|
+
justifyContent: 'flex-end',
|
|
820
|
+
gap: '8px',
|
|
821
|
+
marginTop: '2px',
|
|
822
|
+
},
|
|
566
823
|
field: {
|
|
567
824
|
display: 'flex',
|
|
568
825
|
flexDirection: 'column',
|
|
@@ -628,3 +885,6 @@ const styles: Record<string, React.CSSProperties> = {
|
|
|
628
885
|
cursor: 'pointer',
|
|
629
886
|
},
|
|
630
887
|
}
|
|
888
|
+
|
|
889
|
+
/** The shared input style plus the growth two side-by-side password boxes need. */
|
|
890
|
+
const passwordInput: React.CSSProperties = { ...styles.input, flex: '1 1 160px' }
|
package/src/config-fields.ts
CHANGED
|
@@ -9,6 +9,11 @@
|
|
|
9
9
|
* side and unknown on the other, which is what let the card silently rewrite
|
|
10
10
|
* keys it never showed.
|
|
11
11
|
*
|
|
12
|
+
* It also carries `MIN_PASSWORD_LENGTH`: the login password is deliberately not
|
|
13
|
+
* a field here (it is a secret in `state.json`, not a settings key), but its
|
|
14
|
+
* length bound is a contract the card's form and the host's password route
|
|
15
|
+
* must not disagree about.
|
|
16
|
+
*
|
|
12
17
|
* Because both halves import it, this module must stay dependency-free and
|
|
13
18
|
* side-effect-free: no schemastery (the host schema remains the validating
|
|
14
19
|
* authority, not this table), no node built-ins, no DOM, no I/O.
|
|
@@ -43,6 +48,14 @@ export interface LanGatewaySettings {
|
|
|
43
48
|
/** A settings key this table knows how to edit. */
|
|
44
49
|
export type ConfigFieldKey = keyof LanGatewaySettings
|
|
45
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Minimum login-password length, in characters. The credential is not a config
|
|
53
|
+
* field (it lives in `state.json`, never in the schema), but the bound belongs
|
|
54
|
+
* here all the same: the card's password form and the host's password route
|
|
55
|
+
* both read it, so a draft the card accepts is never answered with a 400.
|
|
56
|
+
*/
|
|
57
|
+
export const MIN_PASSWORD_LENGTH = 8
|
|
58
|
+
|
|
46
59
|
/** How a field is rendered and parsed. */
|
|
47
60
|
export type FieldKind = 'boolean' | 'number' | 'text' | 'cidrs' | 'select' | 'tristate'
|
|
48
61
|
|
package/src/index.ts
CHANGED
|
@@ -31,9 +31,12 @@
|
|
|
31
31
|
* Every tunable is also exposed as the `lan-gateway` user-settings namespace
|
|
32
32
|
* (`ctx.settings`), so the official DSH Settings → Plugins page can adjust
|
|
33
33
|
* port, CIDRs, auth, and TLS live; the running listener restarts on change.
|
|
34
|
-
* The card reads/writes through the loopback-only `/lan-gateway/config` route
|
|
35
|
-
*
|
|
36
|
-
* gateway
|
|
34
|
+
* The card reads/writes through the loopback-only `/lan-gateway/config` route
|
|
35
|
+
* and changes the login password through the loopback-only
|
|
36
|
+
* `/lan-gateway/password` route (the credential is a secret in `state.json`,
|
|
37
|
+
* never a settings key, so it cannot ride a config patch); remote browsers get
|
|
38
|
+
* a 403 from the gateway for that whole prefix and manage the gateway through
|
|
39
|
+
* the `lan_gateway` tool instead.
|
|
37
40
|
*
|
|
38
41
|
* Disabled by default in the bundle patch (safe): the listener opens only
|
|
39
42
|
* after `lan_gateway enable` or `enabled: true`.
|
|
@@ -53,6 +56,7 @@ import type {} from '@deepseek-ai/cordis-plugin-loader'
|
|
|
53
56
|
import { DEFAULT_LAN_CIDR_STRINGS, originMatchesHost } from './auth.ts'
|
|
54
57
|
import {
|
|
55
58
|
CONFIG_FIELD_KEYS,
|
|
59
|
+
MIN_PASSWORD_LENGTH,
|
|
56
60
|
OPTIONAL_CONFIG_KEYS,
|
|
57
61
|
} from './config-fields.ts'
|
|
58
62
|
import { LanGateway } from './gateway.ts'
|
|
@@ -677,6 +681,50 @@ export function apply(ctx: Context, config: ConfigRefs): void {
|
|
|
677
681
|
manualOverride = enabled
|
|
678
682
|
}
|
|
679
683
|
|
|
684
|
+
/**
|
|
685
|
+
* Apply a login-password change and reconcile the listener. One
|
|
686
|
+
* implementation behind both surfaces the operator has — the `lan_gateway`
|
|
687
|
+
* tool's `set-password` command and the Settings card's
|
|
688
|
+
* `/lan-gateway/password` route — so the epoch bump, the listener stop on a
|
|
689
|
+
* clear, and the first-password reconcile cannot diverge between them.
|
|
690
|
+
*
|
|
691
|
+
* The credential itself is never read back out: `state` holds a scrypt hash
|
|
692
|
+
* and salt, and neither is returned to a caller.
|
|
693
|
+
*/
|
|
694
|
+
const applyPassword = async (password: string | undefined): Promise<ToolResult> => {
|
|
695
|
+
if (password !== undefined && password.length > 0 && password.length < MIN_PASSWORD_LENGTH) {
|
|
696
|
+
return { ok: false, message: `Password must be at least ${MIN_PASSWORD_LENGTH} characters.` }
|
|
697
|
+
}
|
|
698
|
+
const setting = password !== undefined && password.length > 0
|
|
699
|
+
const hadPassword = state.password !== undefined
|
|
700
|
+
state = await setPassword(state, setting ? password : undefined)
|
|
701
|
+
saveState(state)
|
|
702
|
+
gateway?.setState(state)
|
|
703
|
+
if (!setting) {
|
|
704
|
+
// Clearing the credential must not leave an open gateway serving
|
|
705
|
+
// sessions the old password authorized: stop the listener. A password
|
|
706
|
+
// is required to run, so a later enable fails closed.
|
|
707
|
+
await setRunIntent(false)
|
|
708
|
+
return enqueue('password cleared', async () => {
|
|
709
|
+
if (gateway !== undefined) await stopGateway()
|
|
710
|
+
lastError = 'Password cleared — the gateway listener was stopped (a password is required to run).'
|
|
711
|
+
}).then(() => ({
|
|
712
|
+
ok: true,
|
|
713
|
+
message: 'Password cleared. Session epoch advanced and the gateway listener was stopped — set a password before enabling it again.',
|
|
714
|
+
}))
|
|
715
|
+
}
|
|
716
|
+
// The first password turns a dormant "enabled but unpassworded" intent
|
|
717
|
+
// into a startable one, so reconcile: the listener was refused a moment
|
|
718
|
+
// ago for a reason that no longer holds. A later password change needs no
|
|
719
|
+
// reconcile (the listener is already running or already refused for some
|
|
720
|
+
// other reason), and reconciling anyway would be harmless but noisy.
|
|
721
|
+
if (!hadPassword) await syncGateway('password set')
|
|
722
|
+
return {
|
|
723
|
+
ok: true,
|
|
724
|
+
message: 'Password set. Session epoch advanced — every previously issued session is now invalid; all sources must sign in again.',
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
680
728
|
// The tunables live in this plugin's own profile entry, and dsh 0.1.7 reaches
|
|
681
729
|
// it by *entry id*: the `lan-gateway` settings namespace this plugin used to
|
|
682
730
|
// register (and the scope handle it wrote through) no longer exist. The entry
|
|
@@ -735,28 +783,35 @@ export function apply(ctx: Context, config: ConfigRefs): void {
|
|
|
735
783
|
void syncGateway('connection attach')
|
|
736
784
|
})
|
|
737
785
|
|
|
738
|
-
// The Settings → Plugins card reads and writes through
|
|
739
|
-
// JSON
|
|
786
|
+
// The Settings → Plugins card reads and writes through these loopback-only
|
|
787
|
+
// JSON routes (ModLens-style: the browser never touches the settings seam
|
|
740
788
|
// directly, so the card has no service dependencies to resolve). The gateway
|
|
741
|
-
// refuses to relay this prefix, so only the native loopback listener can
|
|
789
|
+
// refuses to relay this whole prefix, so only the native loopback listener can
|
|
742
790
|
// reach it — a genuine local user, or a local process that could already read
|
|
743
791
|
// ~/.dsh.
|
|
744
|
-
const
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
792
|
+
const sendJson = (res: ServerResponse, status: number, body: unknown): void => {
|
|
793
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
794
|
+
res.end(JSON.stringify(body))
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* What both card routes report. `passwordSet` is a boolean on purpose: the
|
|
798
|
+
* card must be able to say whether a credential exists without any part of
|
|
799
|
+
* that credential — not even its length — ever leaving this process.
|
|
800
|
+
*/
|
|
801
|
+
const snapshot = (): Record<string, unknown> => {
|
|
802
|
+
const cfg = effective()
|
|
803
|
+
return {
|
|
804
|
+
config: cfg,
|
|
805
|
+
running: gateway !== undefined,
|
|
806
|
+
port: cfg.gatewayPort,
|
|
807
|
+
tls: tlsStatusLine(cfg),
|
|
808
|
+
upstreamSessionAvailable,
|
|
809
|
+
passwordSet: state.password !== undefined,
|
|
810
|
+
lastError: lastError ?? null,
|
|
759
811
|
}
|
|
812
|
+
}
|
|
813
|
+
const configRouteHandler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
814
|
+
const send = (status: number, body: unknown): void => sendJson(res, status, body)
|
|
760
815
|
if (!isTrustedConfigRequest(req)) {
|
|
761
816
|
send(403, { error: 'request refused: this route answers same-origin loopback requests only' })
|
|
762
817
|
return
|
|
@@ -840,6 +895,58 @@ export function apply(ctx: Context, config: ConfigRefs): void {
|
|
|
840
895
|
'dsh-lan-gateway: config route',
|
|
841
896
|
)
|
|
842
897
|
|
|
898
|
+
// The card's password control, on its own loopback-only route. It cannot ride
|
|
899
|
+
// the config patch: the credential is a scrypt hash in `state.json`, not a
|
|
900
|
+
// settings key, and a key that reached `--dump-config` would be exactly the
|
|
901
|
+
// leak the state file exists to prevent. Same fence as the config route, so
|
|
902
|
+
// remote browsers (which the gateway answers 403 for this whole prefix) keep
|
|
903
|
+
// using the `lan_gateway` tool.
|
|
904
|
+
//
|
|
905
|
+
// This surface only *sets* a password. Clearing stops the listener by design,
|
|
906
|
+
// and a card button that silently takes remote access down is a footgun; the
|
|
907
|
+
// tool's `set-password` with an empty password remains the way to clear.
|
|
908
|
+
const passwordRouteHandler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
909
|
+
if (!isTrustedConfigRequest(req)) {
|
|
910
|
+
sendJson(res, 403, { error: 'request refused: this route answers same-origin loopback requests only' })
|
|
911
|
+
return
|
|
912
|
+
}
|
|
913
|
+
if (req.method !== 'POST') {
|
|
914
|
+
sendJson(res, 405, { error: 'method not allowed' })
|
|
915
|
+
return
|
|
916
|
+
}
|
|
917
|
+
const body = await readBody(req, 4 * 1024, res)
|
|
918
|
+
if (body === undefined) return // response already sent (413/400)
|
|
919
|
+
let submitted: unknown
|
|
920
|
+
try {
|
|
921
|
+
submitted = JSON.parse(body)
|
|
922
|
+
} catch {
|
|
923
|
+
sendJson(res, 400, { error: 'invalid JSON body' })
|
|
924
|
+
return
|
|
925
|
+
}
|
|
926
|
+
const password = typeof submitted === 'object' && submitted !== null && !Array.isArray(submitted)
|
|
927
|
+
? (submitted as Record<string, unknown>)['password']
|
|
928
|
+
: undefined
|
|
929
|
+
if (typeof password !== 'string' || password.length === 0) {
|
|
930
|
+
sendJson(res, 400, {
|
|
931
|
+
error: `password must be a non-empty string of at least ${MIN_PASSWORD_LENGTH} characters; `
|
|
932
|
+
+ 'this route only sets one — use the lan_gateway tool to clear it',
|
|
933
|
+
})
|
|
934
|
+
return
|
|
935
|
+
}
|
|
936
|
+
const result = await applyPassword(password)
|
|
937
|
+
if (!result.ok) {
|
|
938
|
+
sendJson(res, 400, { error: result.message })
|
|
939
|
+
return
|
|
940
|
+
}
|
|
941
|
+
// The snapshot flips `passwordSet` for the card's status badge. Nothing here
|
|
942
|
+
// carries the password, its hash, or its length back to the browser.
|
|
943
|
+
sendJson(res, 200, snapshot())
|
|
944
|
+
}
|
|
945
|
+
ctx.effect(
|
|
946
|
+
() => ctx.webServer.register({ kind: 'exact', path: '/lan-gateway/password', handler: passwordRouteHandler }),
|
|
947
|
+
'dsh-lan-gateway: password route',
|
|
948
|
+
)
|
|
949
|
+
|
|
843
950
|
const controller: GatewayController = {
|
|
844
951
|
status(): ToolResult {
|
|
845
952
|
const cfg = desiredConfig()
|
|
@@ -875,39 +982,7 @@ export function apply(ctx: Context, config: ConfigRefs): void {
|
|
|
875
982
|
await syncGateway('tool disable')
|
|
876
983
|
return { ok: true, message: 'Gateway disabled.' }
|
|
877
984
|
},
|
|
878
|
-
|
|
879
|
-
if (password !== undefined && password.length > 0 && password.length < 8) {
|
|
880
|
-
return { ok: false, message: 'Password must be at least 8 characters.' }
|
|
881
|
-
}
|
|
882
|
-
const setting = password !== undefined && password.length > 0
|
|
883
|
-
const hadPassword = state.password !== undefined
|
|
884
|
-
state = await setPassword(state, setting ? password : undefined)
|
|
885
|
-
saveState(state)
|
|
886
|
-
gateway?.setState(state)
|
|
887
|
-
if (!setting) {
|
|
888
|
-
// Clearing the credential must not leave an open gateway serving
|
|
889
|
-
// sessions the old password authorized: stop the listener. A password
|
|
890
|
-
// is required to run, so a later enable fails closed.
|
|
891
|
-
await setRunIntent(false)
|
|
892
|
-
return enqueue('password cleared', async () => {
|
|
893
|
-
if (gateway !== undefined) await stopGateway()
|
|
894
|
-
lastError = 'Password cleared — the gateway listener was stopped (a password is required to run).'
|
|
895
|
-
}).then(() => ({
|
|
896
|
-
ok: true,
|
|
897
|
-
message: 'Password cleared. Session epoch advanced and the gateway listener was stopped — set a password before enabling it again.',
|
|
898
|
-
}))
|
|
899
|
-
}
|
|
900
|
-
// The first password turns a dormant "enabled but unpassworded" intent
|
|
901
|
-
// into a startable one, so reconcile: the listener was refused a moment
|
|
902
|
-
// ago for a reason that no longer holds. A later password change needs no
|
|
903
|
-
// reconcile (the listener is already running or already refused for some
|
|
904
|
-
// other reason), and reconciling anyway would be harmless but noisy.
|
|
905
|
-
if (!hadPassword) await syncGateway('password set')
|
|
906
|
-
return {
|
|
907
|
-
ok: true,
|
|
908
|
-
message: 'Password set. Session epoch advanced — every previously issued session is now invalid; all sources must sign in again.',
|
|
909
|
-
}
|
|
910
|
-
},
|
|
985
|
+
setPassword: applyPassword,
|
|
911
986
|
rotateSecret(): ToolResult {
|
|
912
987
|
const next: GatewayState = {
|
|
913
988
|
cookieSecret: randomBytes(32).toString('base64'),
|