@riceawa/dsh-lan-gateway 0.5.3 → 0.5.5
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 +55 -42
- package/lib/client.js +157 -125
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +57 -7
- package/lib/index.js +1249 -554
- package/package.json +1 -1
- package/skills/lan-gateway.md +8 -4
- package/src/auth.ts +91 -26
- package/src/client/lan-gateway-card.tsx +61 -127
- package/src/config-fields.ts +137 -0
- package/src/gateway.ts +303 -130
- package/src/index.ts +313 -130
- package/src/login.ts +1 -17
- package/src/request-policy.ts +315 -0
- package/src/state.ts +90 -9
- package/src/tls.ts +56 -0
- package/src/upstream-session.ts +41 -10
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@riceawa/dsh-lan-gateway",
|
|
3
3
|
"description": "LAN/internet reverse-proxy gateway for the DeepSeek Harness web GUI: binds 0.0.0.0 and forwards to the loopback dsh web server. Default-deny: every source (loopback, LAN, internet) must sign in with an HMAC session cookie unless lanPasswordless is explicitly enabled; against dsh >= 0.1.2-rc.1 the gateway relays one shared upstream browser session, so the harness's own authorization still gates every request. Fail-closed start guard (password required, plaintext needs an explicit opt-in), session revocation by epoch (password changes and secret rotation kill cookies and live WebSockets), same-site/Origin fence on HTTP and WebSocket upgrades, optional TLS (auto self-signed or user-supplied certs), and a Settings → Plugins card for live adjustment of port, CIDRs, auth, and TLS. Includes an insecure-origin UUID shim client bundle: on gateway-served plain-HTTP origins browsers lack crypto.randomUUID, so the client half patches a getRandomValues-backed randomUUID onto the Crypto prototype, fixing workspace open over LAN without touching DSH source.",
|
|
4
|
-
"version": "0.5.
|
|
4
|
+
"version": "0.5.5",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/index.d.ts",
|
package/skills/lan-gateway.md
CHANGED
|
@@ -9,8 +9,9 @@ user-invocable: true
|
|
|
9
9
|
|
|
10
10
|
The `dsh-lan-gateway` plugin lets the DeepSeek Harness web GUI be reached from the
|
|
11
11
|
LAN and the wider internet. dsh itself binds only to loopback (the web CLI
|
|
12
|
-
hard-refuses `0.0.0.0`), so this plugin runs its own reverse-proxy gateway on
|
|
13
|
-
|
|
12
|
+
hard-refuses `0.0.0.0`), so this plugin runs its own reverse-proxy gateway on the
|
|
13
|
+
unspecified address — both families, so IPv6 clients reach it too — forwarding to
|
|
14
|
+
the loopback web server while rewriting Host/Origin.
|
|
14
15
|
|
|
15
16
|
Since v0.5.0 the model is **default-deny** (post-QVD-2026-57410 hardening):
|
|
16
17
|
|
|
@@ -31,8 +32,8 @@ Since v0.5.0 the model is **default-deny** (post-QVD-2026-57410 hardening):
|
|
|
31
32
|
Do not edit state files by hand — use the `lan_gateway` tool.
|
|
32
33
|
|
|
33
34
|
- `lan_gateway` with `command: "status"` — is it listening, on which port, toward
|
|
34
|
-
which dsh port, password set?, session epoch,
|
|
35
|
-
ingress encryption, last error.
|
|
35
|
+
which dsh port, password set?, session epoch, how many signed-out sessions are
|
|
36
|
+
still held, upstream-session relay state, ingress encryption, last error.
|
|
36
37
|
- `lan_gateway` with `command: "enable"` — start listening. If it refuses (no
|
|
37
38
|
password, legacy `authRequired: false`, plaintext without opt-in, `lanPasswordless`
|
|
38
39
|
without a session-capable base), the message tells you what to change.
|
|
@@ -72,3 +73,6 @@ once (passwords and sessions travel in clear).
|
|
|
72
73
|
without an Origin) does not.
|
|
73
74
|
- Sessions don't survive a password change / `rotate-secret`: that is by design —
|
|
74
75
|
the revocation epoch advanced and all cookies (and live WebSockets) were revoked.
|
|
76
|
+
Signing out is narrower: it revokes only the session that signed out, so that
|
|
77
|
+
session's cookie is dead even if a copy of it was kept elsewhere, while the
|
|
78
|
+
user's other devices stay signed in.
|
package/src/auth.ts
CHANGED
|
@@ -77,6 +77,13 @@ function normalizeAddress(raw: string): string {
|
|
|
77
77
|
|
|
78
78
|
/**
|
|
79
79
|
* Classify a source address string into one of the three trust tiers.
|
|
80
|
+
*
|
|
81
|
+
* The input is a *socket* address — `req.socket.remoteAddress`, unwrapped from
|
|
82
|
+
* its `::ffff:` mapping — which is a different domain from the URL hostname
|
|
83
|
+
* `isLoopbackHost` in `request-policy.ts` judges. The two agree on the common
|
|
84
|
+
* inputs but are not interchangeable: this one never sees `[::1]`, and that one
|
|
85
|
+
* never sees a mapped form. Both spans are documented where each lives.
|
|
86
|
+
*
|
|
80
87
|
* @param remoteAddress - the raw value of `req.socket.remoteAddress`.
|
|
81
88
|
* @param lanCidrs - CIDR strings treated as trusted LAN space (IPv4).
|
|
82
89
|
* @returns the classification. IPv4-mapped IPv6 addresses are unwrapped.
|
|
@@ -100,16 +107,40 @@ export function classifySource(
|
|
|
100
107
|
}
|
|
101
108
|
|
|
102
109
|
if (address === '::1') return 'loopback'
|
|
103
|
-
|
|
104
|
-
if (address.toLowerCase().startsWith('fe80:')) return 'lan'
|
|
110
|
+
if (inIpv6LinkLocal(address)) return 'lan'
|
|
105
111
|
return 'internet'
|
|
106
112
|
}
|
|
107
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Whether a textual IPv6 address falls inside fe80::/10. The first ten bits are
|
|
116
|
+
* `1111111010`, so the leading hextet spans fe80–febf; a `startsWith('fe80:')`
|
|
117
|
+
* test covers only fe80::/16 and misclassifies fe90::–febf:: as internet.
|
|
118
|
+
*/
|
|
119
|
+
function inIpv6LinkLocal(address: string): boolean {
|
|
120
|
+
const match = /^([0-9a-fA-F]{1,4}):/.exec(address)
|
|
121
|
+
if (match === null) return false
|
|
122
|
+
return (Number.parseInt(match[1]!, 16) & 0xffc0) === 0xfe80
|
|
123
|
+
}
|
|
124
|
+
|
|
108
125
|
/** Encode a byte buffer as URL-safe base64 without padding. */
|
|
109
126
|
function base64url(input: Buffer): string {
|
|
110
127
|
return input.toString('base64url')
|
|
111
128
|
}
|
|
112
129
|
|
|
130
|
+
/** The claims a verified session cookie carries. */
|
|
131
|
+
export interface SessionClaims {
|
|
132
|
+
/** Epoch millis at which the session expires. */
|
|
133
|
+
exp: number
|
|
134
|
+
/** The revocation epoch the cookie was minted under. */
|
|
135
|
+
epoch: number
|
|
136
|
+
/**
|
|
137
|
+
* Per-session id. Present on cookies minted from 0.5.4 on, which is what
|
|
138
|
+
* lets one session be retired on its own (sign-out) instead of retiring
|
|
139
|
+
* every session the password authorized. Absent on older cookies.
|
|
140
|
+
*/
|
|
141
|
+
sid?: string
|
|
142
|
+
}
|
|
143
|
+
|
|
113
144
|
/**
|
|
114
145
|
* Issue a signed session cookie value.
|
|
115
146
|
* @param secret - the HMAC signing secret (base64 string).
|
|
@@ -118,28 +149,29 @@ function base64url(input: Buffer): string {
|
|
|
118
149
|
* cookie whose epoch no longer matches the live state is rejected by
|
|
119
150
|
* {@link verifyCookie}. Defaults to 0 (epoch-less, legacy) for callers that
|
|
120
151
|
* do not participate in revocation.
|
|
152
|
+
* @param sid - optional per-session id (see {@link SessionClaims.sid}).
|
|
121
153
|
* @returns a `payload.signature` string suitable for the cookie value.
|
|
122
154
|
*/
|
|
123
|
-
export function signCookie(secret: string, expiresMs: number, epoch: number = 0): string {
|
|
124
|
-
const
|
|
155
|
+
export function signCookie(secret: string, expiresMs: number, epoch: number = 0, sid?: string): string {
|
|
156
|
+
const claims = sid === undefined ? { exp: expiresMs, epoch } : { exp: expiresMs, epoch, sid }
|
|
157
|
+
const payload = base64url(Buffer.from(JSON.stringify(claims)))
|
|
125
158
|
const sig = createHmac('sha256', secret).update(payload).digest('base64url')
|
|
126
159
|
return `${payload}.${sig}`
|
|
127
160
|
}
|
|
128
161
|
|
|
129
162
|
/**
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
* epoch 0, so an upgrade from a pre-0.5.0 state does not log everyone out.
|
|
163
|
+
* Verify a cookie's signature, expiry and epoch.
|
|
164
|
+
* @returns the claims it carries, or undefined when it is not a valid session.
|
|
133
165
|
*/
|
|
134
|
-
export function
|
|
166
|
+
export function verifySession(
|
|
135
167
|
secret: string,
|
|
136
168
|
value: string | undefined,
|
|
137
169
|
now: number,
|
|
138
170
|
epoch: number = 0,
|
|
139
|
-
):
|
|
140
|
-
if (value === undefined) return
|
|
171
|
+
): SessionClaims | undefined {
|
|
172
|
+
if (value === undefined) return undefined
|
|
141
173
|
const dot = value.indexOf('.')
|
|
142
|
-
if (dot === -1) return
|
|
174
|
+
if (dot === -1) return undefined
|
|
143
175
|
const payload = value.slice(0, dot)
|
|
144
176
|
const sig = value.slice(dot + 1)
|
|
145
177
|
const expected = createHmac('sha256', secret).update(payload).digest()
|
|
@@ -147,17 +179,22 @@ export function verifyCookie(
|
|
|
147
179
|
try {
|
|
148
180
|
actual = Buffer.from(sig, 'base64url')
|
|
149
181
|
} catch {
|
|
150
|
-
return
|
|
182
|
+
return undefined
|
|
151
183
|
}
|
|
152
|
-
if (expected.length !== actual.length) return
|
|
153
|
-
if (!timingSafeEqual(expected, actual)) return
|
|
184
|
+
if (expected.length !== actual.length) return undefined
|
|
185
|
+
if (!timingSafeEqual(expected, actual)) return undefined
|
|
154
186
|
try {
|
|
155
|
-
const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as
|
|
156
|
-
if (typeof decoded.exp !== 'number' || decoded.exp <= now) return
|
|
187
|
+
const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Partial<SessionClaims>
|
|
188
|
+
if (typeof decoded.exp !== 'number' || decoded.exp <= now) return undefined
|
|
157
189
|
const cookieEpoch = typeof decoded.epoch === 'number' ? decoded.epoch : 0
|
|
158
|
-
|
|
190
|
+
if (cookieEpoch !== epoch) return undefined
|
|
191
|
+
return {
|
|
192
|
+
exp: decoded.exp,
|
|
193
|
+
epoch: cookieEpoch,
|
|
194
|
+
...(typeof decoded.sid === 'string' ? { sid: decoded.sid } : {}),
|
|
195
|
+
}
|
|
159
196
|
} catch {
|
|
160
|
-
return
|
|
197
|
+
return undefined
|
|
161
198
|
}
|
|
162
199
|
}
|
|
163
200
|
|
|
@@ -181,7 +218,15 @@ export function originMatchesHost(origin: string | undefined, host: string | und
|
|
|
181
218
|
|
|
182
219
|
/** A token bucket limiter keyed by source address. */
|
|
183
220
|
export class RateLimiter {
|
|
221
|
+
/**
|
|
222
|
+
* Hard ceiling on tracked sources. Expiry alone only reclaims a bucket when
|
|
223
|
+
* `prune` runs, and a spray from many distinct addresses inside one window
|
|
224
|
+
* outruns it, so the map also sheds its soonest-expiring entries past this.
|
|
225
|
+
*/
|
|
226
|
+
private static readonly MAX_BUCKETS = 10_000
|
|
184
227
|
private readonly buckets = new Map<string, { tokens: number; resetAt: number }>()
|
|
228
|
+
/** Epoch millis at which the next opportunistic sweep is due. */
|
|
229
|
+
private nextPruneAt = 0
|
|
185
230
|
constructor(
|
|
186
231
|
private readonly maxTokens: number,
|
|
187
232
|
private readonly windowMs: number,
|
|
@@ -194,22 +239,42 @@ export class RateLimiter {
|
|
|
194
239
|
*/
|
|
195
240
|
allow(key: string): boolean {
|
|
196
241
|
const now = Date.now()
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
242
|
+
// Sweep on a rolling window. Without this, expired buckets are only
|
|
243
|
+
// replaced when their own key returns, so every address that ever posted
|
|
244
|
+
// to the login route keeps an entry for the life of the process.
|
|
245
|
+
if (now >= this.nextPruneAt) {
|
|
246
|
+
this.prune(now)
|
|
247
|
+
this.nextPruneAt = now + this.windowMs
|
|
201
248
|
}
|
|
202
|
-
|
|
203
|
-
|
|
249
|
+
const existing = this.buckets.get(key)
|
|
250
|
+
if (existing !== undefined && existing.resetAt > now) {
|
|
251
|
+
if (existing.tokens <= 0) return false
|
|
252
|
+
existing.tokens -= 1
|
|
204
253
|
return true
|
|
205
254
|
}
|
|
206
|
-
|
|
255
|
+
// A new bucket. A spray of distinct addresses inside one window outruns
|
|
256
|
+
// expiry, so shed the closest-to-expiring entries first — they are the
|
|
257
|
+
// ones about to lapse anyway, so the eviction costs the least fidelity.
|
|
258
|
+
if (existing === undefined && this.buckets.size >= RateLimiter.MAX_BUCKETS) {
|
|
259
|
+
this.evictSoonestToExpire()
|
|
260
|
+
}
|
|
261
|
+
this.buckets.set(key, { tokens: this.maxTokens - 1, resetAt: now + this.windowMs })
|
|
262
|
+
return true
|
|
207
263
|
}
|
|
208
264
|
|
|
209
|
-
/** Drop expired buckets to bound memory. */
|
|
265
|
+
/** Drop expired buckets to bound memory. Called from {@link allow}. */
|
|
210
266
|
prune(now: number = Date.now()): void {
|
|
211
267
|
for (const [key, bucket] of this.buckets) {
|
|
212
268
|
if (bucket.resetAt <= now) this.buckets.delete(key)
|
|
213
269
|
}
|
|
214
270
|
}
|
|
271
|
+
|
|
272
|
+
/** Trim back to 90% of the ceiling, oldest expiry first. */
|
|
273
|
+
private evictSoonestToExpire(): void {
|
|
274
|
+
const target = Math.floor(RateLimiter.MAX_BUCKETS * 0.9)
|
|
275
|
+
const byExpiry = [...this.buckets.entries()].sort((a, b) => a[1].resetAt - b[1].resetAt)
|
|
276
|
+
for (const [key] of byExpiry.slice(0, Math.max(0, this.buckets.size - target))) {
|
|
277
|
+
this.buckets.delete(key)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
215
280
|
}
|
|
@@ -12,6 +12,14 @@
|
|
|
12
12
|
|
|
13
13
|
import { useEffect, useState, type ChangeEvent, type ReactNode } from 'react'
|
|
14
14
|
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
|
15
|
+
import {
|
|
16
|
+
FIELDS,
|
|
17
|
+
TRISTATE_OPTIONS,
|
|
18
|
+
formatValue,
|
|
19
|
+
parseValue,
|
|
20
|
+
type FieldDef,
|
|
21
|
+
type LanGatewaySettings,
|
|
22
|
+
} from '../config-fields.ts'
|
|
15
23
|
|
|
16
24
|
/**
|
|
17
25
|
* The official Settings → Plugins page declares the `settings.plugin.item`
|
|
@@ -27,27 +35,20 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
|
|
27
35
|
}
|
|
28
36
|
}
|
|
29
37
|
|
|
30
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Props the renderer binds for this card (unused — the card is self-loading).
|
|
40
|
+
*/
|
|
31
41
|
export type LanGatewayCardProps = PropsRuntime<'settings.plugin.item'>
|
|
32
42
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
tlsMode?: 'self-signed' | 'custom'
|
|
43
|
-
tlsCertPath?: string
|
|
44
|
-
tlsKeyPath?: string
|
|
45
|
-
tlsSelfSignedHosts?: string
|
|
46
|
-
tlsCertMaxAgeDays?: number
|
|
47
|
-
allowInsecurePlaintext?: boolean
|
|
48
|
-
trustedTerminator?: string
|
|
49
|
-
secureCookies?: boolean
|
|
50
|
-
}
|
|
43
|
+
/**
|
|
44
|
+
* The card's field table and value codecs live in `config-fields.ts`, shared
|
|
45
|
+
* with the host: the host's config route decides which submitted keys are
|
|
46
|
+
* editable and which empty value means "clear", and a table duplicated here
|
|
47
|
+
* would let the two disagree about a field the card can render but the route
|
|
48
|
+
* would refuse. Re-exported so the existing tests keep their import path.
|
|
49
|
+
*/
|
|
50
|
+
export { FIELDS, TRISTATE_OPTIONS, formatValue, parseValue }
|
|
51
|
+
export type { FieldDef, LanGatewaySettings }
|
|
51
52
|
|
|
52
53
|
/** GET /lan-gateway/config response. */
|
|
53
54
|
interface RouteState {
|
|
@@ -70,7 +71,6 @@ interface Labels {
|
|
|
70
71
|
saving: string
|
|
71
72
|
discard: string
|
|
72
73
|
reset: string
|
|
73
|
-
overridden: string
|
|
74
74
|
readOnly: string
|
|
75
75
|
saveFailed: string
|
|
76
76
|
loadFailed: string
|
|
@@ -93,10 +93,9 @@ const LABELS: Record<'zh' | 'en', Labels> = {
|
|
|
93
93
|
saving: '保存中…',
|
|
94
94
|
discard: '放弃',
|
|
95
95
|
reset: '重置',
|
|
96
|
-
|
|
97
|
-
readOnly: '网关设置当前不可用(读不到配置路由)。',
|
|
96
|
+
readOnly: '网关设置只能在宿主机本机打开 dsh web 时修改:配置路由仅监听回环地址,经网关远程访问的浏览器会被拒绝。远程请改用 lan_gateway 工具。',
|
|
98
97
|
saveFailed: '保存未生效,请检查输入后重试。',
|
|
99
|
-
loadFailed: '
|
|
98
|
+
loadFailed: '无法读取网关配置',
|
|
100
99
|
emptyMeansClear: '留空 = 使用默认',
|
|
101
100
|
running: '运行中',
|
|
102
101
|
stopped: '已停止',
|
|
@@ -115,7 +114,7 @@ const LABELS: Record<'zh' | 'en', Labels> = {
|
|
|
115
114
|
'field.allowInsecurePlaintext': '允许明文 HTTP',
|
|
116
115
|
'hint.allowInsecurePlaintext': '危险:关闭 TLS 或受信终止代理时仍启动监听,密码与会话将以明文传输',
|
|
117
116
|
'field.trustedTerminator': '受信 TLS 终止代理',
|
|
118
|
-
'hint.trustedTerminator': '可选:声明前置代理标识,视为加密入口(如 nginx)。留空 =
|
|
117
|
+
'hint.trustedTerminator': '可选:声明前置代理标识,视为加密入口(如 nginx)。留空 = 未声明。注意:登录限流以 TCP 源地址为键,代理之后所有浏览器共用一个额度(5 次/分钟)',
|
|
119
118
|
'field.secureCookies': '会话 cookie 的 Secure 属性',
|
|
120
119
|
'hint.secureCookies': '自动 = TLS 或已声明受信终止代理时加 Secure。受信代理只做明文鉴权、浏览器走 http 访问时须设为 false,否则浏览器拒收 Secure cookie,登录会无限弹回登录页',
|
|
121
120
|
'opt.auto': '自动',
|
|
@@ -128,13 +127,13 @@ const LABELS: Record<'zh' | 'en', Labels> = {
|
|
|
128
127
|
'field.tlsMode': '证书来源',
|
|
129
128
|
'hint.tlsMode': 'self-signed = 自动生成自签名证书;custom = 使用自己的证书',
|
|
130
129
|
'field.tlsSelfSignedHosts': '自签名证书域名/IP',
|
|
131
|
-
'hint.tlsSelfSignedHosts': '逗号分隔,写入证书 SAN,如 localhost, 192.168.1.5',
|
|
130
|
+
'hint.tlsSelfSignedHosts': '逗号分隔,写入证书 SAN,如 localhost, 192.168.1.5。仅影响下次换发:已有证书沿用至到期,改动不会立刻生效',
|
|
132
131
|
'field.tlsCertPath': '证书文件路径(custom)',
|
|
133
132
|
'hint.tlsCertPath': 'PEM 格式证书(或证书链)的绝对路径',
|
|
134
133
|
'field.tlsKeyPath': '私钥文件路径(custom)',
|
|
135
134
|
'hint.tlsKeyPath': '与证书配套的 PEM 私钥绝对路径',
|
|
136
135
|
'field.tlsCertMaxAgeDays': '自签名证书有效期(天)',
|
|
137
|
-
'hint.tlsCertMaxAgeDays': '默认 825(约 27
|
|
136
|
+
'hint.tlsCertMaxAgeDays': '默认 825(约 27 个月)。仅影响下次换发:已有证书沿用至到期',
|
|
138
137
|
},
|
|
139
138
|
en: {
|
|
140
139
|
title: 'LAN Gateway',
|
|
@@ -144,10 +143,9 @@ const LABELS: Record<'zh' | 'en', Labels> = {
|
|
|
144
143
|
saving: 'Saving…',
|
|
145
144
|
discard: 'Discard',
|
|
146
145
|
reset: 'Reset',
|
|
147
|
-
|
|
148
|
-
readOnly: 'Gateway settings unavailable (config route unreachable).',
|
|
146
|
+
readOnly: 'Gateway settings can only be changed where dsh web runs locally: the config route listens on loopback only, so a browser reaching dsh through the gateway is refused. Use the lan_gateway tool remotely.',
|
|
149
147
|
saveFailed: 'The save did not land — check the inputs and retry.',
|
|
150
|
-
loadFailed: '
|
|
148
|
+
loadFailed: 'Cannot read the gateway configuration',
|
|
151
149
|
emptyMeansClear: 'Empty = default',
|
|
152
150
|
running: 'Running',
|
|
153
151
|
stopped: 'Stopped',
|
|
@@ -166,7 +164,7 @@ const LABELS: Record<'zh' | 'en', Labels> = {
|
|
|
166
164
|
'field.allowInsecurePlaintext': 'Allow plaintext HTTP',
|
|
167
165
|
'hint.allowInsecurePlaintext': 'Dangerous: start the listener even without TLS or a trusted terminator; passwords and sessions travel in clear',
|
|
168
166
|
'field.trustedTerminator': 'Trusted TLS terminator',
|
|
169
|
-
'hint.trustedTerminator': 'Optional identifier for a front proxy (e.g. nginx) treated as the encrypted ingress. Empty = none declared',
|
|
167
|
+
'hint.trustedTerminator': 'Optional identifier for a front proxy (e.g. nginx) treated as the encrypted ingress. Empty = none declared. Note: login rate limiting keys on the TCP source address, so behind a proxy every browser shares one budget (5/min)',
|
|
170
168
|
'field.secureCookies': 'Session cookie Secure attribute',
|
|
171
169
|
'hint.secureCookies': 'Auto = Secure when TLS or a trusted terminator is declared. Set false when the trusted proxy only authenticates over plaintext and browsers reach it over http — otherwise browsers drop the Secure cookie and every login bounces back to the login page',
|
|
172
170
|
'opt.auto': 'Auto',
|
|
@@ -179,13 +177,13 @@ const LABELS: Record<'zh' | 'en', Labels> = {
|
|
|
179
177
|
'field.tlsMode': 'Certificate source',
|
|
180
178
|
'hint.tlsMode': 'self-signed = auto-generated certificate; custom = your own files',
|
|
181
179
|
'field.tlsSelfSignedHosts': 'Self-signed hosts (SANs)',
|
|
182
|
-
'hint.tlsSelfSignedHosts': 'Comma separated DNS/IP names, e.g. localhost, 192.168.1.5',
|
|
180
|
+
'hint.tlsSelfSignedHosts': 'Comma separated DNS/IP names, e.g. localhost, 192.168.1.5. Applies to the next issuance only: an existing certificate is reused until it expires',
|
|
183
181
|
'field.tlsCertPath': 'Certificate path (custom)',
|
|
184
182
|
'hint.tlsCertPath': 'Absolute path to a PEM certificate (or chain)',
|
|
185
183
|
'field.tlsKeyPath': 'Private key path (custom)',
|
|
186
184
|
'hint.tlsKeyPath': 'Absolute path to the matching PEM private key',
|
|
187
185
|
'field.tlsCertMaxAgeDays': 'Self-signed validity (days)',
|
|
188
|
-
'hint.tlsCertMaxAgeDays': 'Default 825 (about 27 months)',
|
|
186
|
+
'hint.tlsCertMaxAgeDays': 'Default 825 (about 27 months). Applies to the next issuance only: an existing certificate is reused until it expires',
|
|
189
187
|
},
|
|
190
188
|
}
|
|
191
189
|
|
|
@@ -194,93 +192,6 @@ function labels(): Labels {
|
|
|
194
192
|
return lang.startsWith('zh') ? LABELS.zh : LABELS.en
|
|
195
193
|
}
|
|
196
194
|
|
|
197
|
-
/* ------------------------------------------------------------------ */
|
|
198
|
-
/* Field model */
|
|
199
|
-
/* ------------------------------------------------------------------ */
|
|
200
|
-
|
|
201
|
-
type FieldKind = 'boolean' | 'number' | 'text' | 'cidrs' | 'select' | 'tristate'
|
|
202
|
-
|
|
203
|
-
interface FieldDef {
|
|
204
|
-
field: keyof LanGatewaySettings
|
|
205
|
-
kind: FieldKind
|
|
206
|
-
optional?: boolean
|
|
207
|
-
options?: readonly string[]
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
/**
|
|
211
|
-
* The card's field table and its two value codecs are exported for tests: the
|
|
212
|
-
* tri-state codec is the load-bearing part of the settings round-trip (an
|
|
213
|
-
* unset value must stay distinguishable from an explicit false, or the
|
|
214
|
-
* plaintext-proxy escape hatch silently reverts).
|
|
215
|
-
*/
|
|
216
|
-
export { TRISTATE_OPTIONS, FIELDS, formatValue, parseValue }
|
|
217
|
-
export type { FieldDef, Write }
|
|
218
|
-
|
|
219
|
-
/** The three states of a tri-state field, in display order. */
|
|
220
|
-
const TRISTATE_OPTIONS = ['auto', 'true', 'false'] as const
|
|
221
|
-
|
|
222
|
-
const FIELDS: readonly FieldDef[] = [
|
|
223
|
-
{ field: 'enabled', kind: 'boolean' },
|
|
224
|
-
{ field: 'gatewayPort', kind: 'number' },
|
|
225
|
-
{ field: 'dshTargetPort', kind: 'number', optional: true },
|
|
226
|
-
{ field: 'lanCidrs', kind: 'cidrs' },
|
|
227
|
-
{ field: 'lanPasswordless', kind: 'boolean' },
|
|
228
|
-
{ field: 'cookieMaxAgeDays', kind: 'number' },
|
|
229
|
-
{ field: 'tlsEnabled', kind: 'boolean' },
|
|
230
|
-
{ field: 'tlsMode', kind: 'select', options: ['self-signed', 'custom'] },
|
|
231
|
-
{ field: 'tlsSelfSignedHosts', kind: 'text' },
|
|
232
|
-
{ field: 'tlsCertPath', kind: 'text', optional: true },
|
|
233
|
-
{ field: 'tlsKeyPath', kind: 'text', optional: true },
|
|
234
|
-
{ field: 'tlsCertMaxAgeDays', kind: 'number' },
|
|
235
|
-
{ field: 'allowInsecurePlaintext', kind: 'boolean' },
|
|
236
|
-
{ field: 'trustedTerminator', kind: 'text', optional: true },
|
|
237
|
-
{ field: 'secureCookies', kind: 'tristate' },
|
|
238
|
-
]
|
|
239
|
-
|
|
240
|
-
function formatValue(def: FieldDef, value: unknown): string {
|
|
241
|
-
switch (def.kind) {
|
|
242
|
-
case 'boolean': return value === true ? 'true' : 'false'
|
|
243
|
-
case 'number': return typeof value === 'number' ? String(value) : ''
|
|
244
|
-
case 'cidrs': return Array.isArray(value) ? value.join(', ') : ''
|
|
245
|
-
case 'select': return typeof value === 'string' ? value : (def.options?.[0] ?? '')
|
|
246
|
-
// Tri-state: an unset value is a distinct third state ("auto"), never "false".
|
|
247
|
-
case 'tristate': return value === true ? 'true' : value === false ? 'false' : 'auto'
|
|
248
|
-
case 'text': return typeof value === 'string' ? value : ''
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
type Write = { kind: 'set'; value: unknown } | { kind: 'clear' }
|
|
253
|
-
|
|
254
|
-
/** Parse draft text into a value for the POST body; undefined blocks saving. */
|
|
255
|
-
function parseValue(def: FieldDef, text: string): Write | undefined {
|
|
256
|
-
const trimmed = text.trim()
|
|
257
|
-
switch (def.kind) {
|
|
258
|
-
case 'boolean':
|
|
259
|
-
if (trimmed === 'true') return { kind: 'set', value: true }
|
|
260
|
-
if (trimmed === 'false') return { kind: 'set', value: false }
|
|
261
|
-
return undefined
|
|
262
|
-
case 'number':
|
|
263
|
-
if (trimmed === '') return def.optional ? { kind: 'clear' } : undefined
|
|
264
|
-
if (!/^\d+$/.test(trimmed)) return undefined
|
|
265
|
-
return { kind: 'set', value: Number(trimmed) }
|
|
266
|
-
case 'cidrs': {
|
|
267
|
-
const cidrs = trimmed.split(',').map(s => s.trim()).filter(s => s !== '')
|
|
268
|
-
return cidrs.length === 0 ? { kind: 'clear' } : { kind: 'set', value: cidrs }
|
|
269
|
-
}
|
|
270
|
-
case 'select':
|
|
271
|
-
return def.options?.includes(trimmed) ? { kind: 'set', value: trimmed } : undefined
|
|
272
|
-
case 'tristate':
|
|
273
|
-
// 'auto' clears the key so it re-inherits the composition layer (and the
|
|
274
|
-
// resolution rule), which is what an unset tri-state means.
|
|
275
|
-
if (trimmed === 'auto') return { kind: 'clear' }
|
|
276
|
-
if (trimmed === 'true') return { kind: 'set', value: true }
|
|
277
|
-
if (trimmed === 'false') return { kind: 'set', value: false }
|
|
278
|
-
return undefined
|
|
279
|
-
case 'text':
|
|
280
|
-
return trimmed === '' ? (def.optional ? { kind: 'clear' } : undefined) : { kind: 'set', value: trimmed }
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
|
-
|
|
284
195
|
/* ------------------------------------------------------------------ */
|
|
285
196
|
/* Card */
|
|
286
197
|
/* ------------------------------------------------------------------ */
|
|
@@ -314,7 +225,26 @@ export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
|
|
|
314
225
|
return () => { cancelled = true }
|
|
315
226
|
}, [])
|
|
316
227
|
|
|
317
|
-
|
|
228
|
+
// A remote browser reaches this card through the gateway, which answers 403
|
|
229
|
+
// for the plugin's own prefix by design, so the route is unreachable exactly
|
|
230
|
+
// where a user is most likely to go looking for the setting. Rendering
|
|
231
|
+
// nothing left them with a blank entry and no way to tell a missing card from
|
|
232
|
+
// a broken one; say what is wrong and where the card does work instead.
|
|
233
|
+
if (loadFailed) {
|
|
234
|
+
return (
|
|
235
|
+
<li style={styles.card}>
|
|
236
|
+
<div style={styles.header}>
|
|
237
|
+
<span style={styles.headerTop}>
|
|
238
|
+
<span style={styles.name}>{t.title}</span>
|
|
239
|
+
</span>
|
|
240
|
+
<span style={styles.description}>{t.loadFailed}</span>
|
|
241
|
+
</div>
|
|
242
|
+
<div style={styles.body}>
|
|
243
|
+
<p style={styles.hint}>{t.readOnly}</p>
|
|
244
|
+
</div>
|
|
245
|
+
</li>
|
|
246
|
+
)
|
|
247
|
+
}
|
|
318
248
|
if (route === null) return null
|
|
319
249
|
|
|
320
250
|
const { config } = route
|
|
@@ -352,18 +282,22 @@ export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
|
|
|
352
282
|
setSaving(true)
|
|
353
283
|
setFailed(null)
|
|
354
284
|
try {
|
|
355
|
-
//
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
285
|
+
// A patch of the edited fields only, never the whole config: the card
|
|
286
|
+
// cannot express every key the section may hold (a custom `cookieName`,
|
|
287
|
+
// say), and posting a synthesized full config made the route treat those
|
|
288
|
+
// keys as submitted — resetting each one to its schema default.
|
|
289
|
+
const patch: Record<string, unknown> = {}
|
|
290
|
+
for (const [field, text] of Object.entries(drafts)) {
|
|
291
|
+
const def = FIELDS.find(f => f.field === field)
|
|
292
|
+
if (def === undefined) continue
|
|
293
|
+
const write = parseValue(def, text ?? '')
|
|
360
294
|
if (write === undefined) continue
|
|
361
|
-
|
|
295
|
+
patch[field] = write.kind === 'clear' ? null : write.value
|
|
362
296
|
}
|
|
363
297
|
const response = await fetch('/lan-gateway/config', {
|
|
364
298
|
method: 'POST',
|
|
365
299
|
headers: { 'content-type': 'application/json' },
|
|
366
|
-
body: JSON.stringify(
|
|
300
|
+
body: JSON.stringify(patch),
|
|
367
301
|
})
|
|
368
302
|
const body = await response.json().catch(() => ({})) as Partial<RouteState> & { error?: string }
|
|
369
303
|
if (!response.ok) {
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `lan-gateway` configuration field table: which settings keys exist, how
|
|
3
|
+
* each one is rendered, and how a draft string becomes a config value or a
|
|
4
|
+
* clear.
|
|
5
|
+
*
|
|
6
|
+
* The host and the browser card share this module. The host derives the key set
|
|
7
|
+
* its config route accepts and the set an empty value clears from it; the card
|
|
8
|
+
* renders its controls from it. A field therefore cannot be editable on one
|
|
9
|
+
* side and unknown on the other, which is what let the card silently rewrite
|
|
10
|
+
* keys it never showed.
|
|
11
|
+
*
|
|
12
|
+
* Because both halves import it, this module must stay dependency-free and
|
|
13
|
+
* side-effect-free: no schemastery (the host schema remains the validating
|
|
14
|
+
* authority, not this table), no node built-ins, no DOM, no I/O.
|
|
15
|
+
*
|
|
16
|
+
* @module @riceawa/dsh-lan-gateway/config-fields
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The wire shape of the `lan-gateway` config section, as the Settings card sees
|
|
21
|
+
* it. Deliberately narrower than the host's `Config`: `cookieName` and the
|
|
22
|
+
* retired `authRequired` are not card-editable. The config route applies a
|
|
23
|
+
* patch, so a key absent here is left untouched rather than reset.
|
|
24
|
+
*/
|
|
25
|
+
export interface LanGatewaySettings {
|
|
26
|
+
enabled?: boolean
|
|
27
|
+
gatewayPort?: number
|
|
28
|
+
dshTargetPort?: number
|
|
29
|
+
lanCidrs?: string[]
|
|
30
|
+
lanPasswordless?: boolean
|
|
31
|
+
cookieMaxAgeDays?: number
|
|
32
|
+
tlsEnabled?: boolean
|
|
33
|
+
tlsMode?: 'self-signed' | 'custom'
|
|
34
|
+
tlsCertPath?: string
|
|
35
|
+
tlsKeyPath?: string
|
|
36
|
+
tlsSelfSignedHosts?: string
|
|
37
|
+
tlsCertMaxAgeDays?: number
|
|
38
|
+
allowInsecurePlaintext?: boolean
|
|
39
|
+
trustedTerminator?: string
|
|
40
|
+
secureCookies?: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A settings key this table knows how to edit. */
|
|
44
|
+
export type ConfigFieldKey = keyof LanGatewaySettings
|
|
45
|
+
|
|
46
|
+
/** How a field is rendered and parsed. */
|
|
47
|
+
export type FieldKind = 'boolean' | 'number' | 'text' | 'cidrs' | 'select' | 'tristate'
|
|
48
|
+
|
|
49
|
+
export interface FieldDef {
|
|
50
|
+
readonly field: ConfigFieldKey
|
|
51
|
+
readonly kind: FieldKind
|
|
52
|
+
/** An empty draft clears the key back to the composition layer. */
|
|
53
|
+
readonly optional?: boolean
|
|
54
|
+
/** The accepted values of a `select` field. */
|
|
55
|
+
readonly options?: readonly string[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The three states of a tri-state field, in display order. */
|
|
59
|
+
export const TRISTATE_OPTIONS = ['auto', 'true', 'false'] as const
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The editable settings, in display order. Adding a config key means adding it
|
|
63
|
+
* here (the host whitelist and the card's controls both follow), to the
|
|
64
|
+
* `Config` schema in `index.ts`, and to `listenerKey` when it changes listener
|
|
65
|
+
* behavior.
|
|
66
|
+
*/
|
|
67
|
+
export const FIELDS: readonly FieldDef[] = [
|
|
68
|
+
{ field: 'enabled', kind: 'boolean' },
|
|
69
|
+
{ field: 'gatewayPort', kind: 'number' },
|
|
70
|
+
{ field: 'dshTargetPort', kind: 'number', optional: true },
|
|
71
|
+
{ field: 'lanCidrs', kind: 'cidrs' },
|
|
72
|
+
{ field: 'lanPasswordless', kind: 'boolean' },
|
|
73
|
+
{ field: 'cookieMaxAgeDays', kind: 'number' },
|
|
74
|
+
{ field: 'tlsEnabled', kind: 'boolean' },
|
|
75
|
+
{ field: 'tlsMode', kind: 'select', options: ['self-signed', 'custom'] },
|
|
76
|
+
{ field: 'tlsSelfSignedHosts', kind: 'text' },
|
|
77
|
+
{ field: 'tlsCertPath', kind: 'text', optional: true },
|
|
78
|
+
{ field: 'tlsKeyPath', kind: 'text', optional: true },
|
|
79
|
+
{ field: 'tlsCertMaxAgeDays', kind: 'number' },
|
|
80
|
+
{ field: 'allowInsecurePlaintext', kind: 'boolean' },
|
|
81
|
+
{ field: 'trustedTerminator', kind: 'text', optional: true },
|
|
82
|
+
{ field: 'secureCookies', kind: 'tristate' },
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
/** Every settings key the config route accepts; anything else is ignored. */
|
|
86
|
+
export const CONFIG_FIELD_KEYS: ReadonlySet<string> = new Set(FIELDS.map(def => def.field as string))
|
|
87
|
+
|
|
88
|
+
/** Keys an empty submitted value clears back to the composition layer. */
|
|
89
|
+
export const OPTIONAL_CONFIG_KEYS: ReadonlySet<string> = new Set(
|
|
90
|
+
FIELDS.filter(def => def.optional === true).map(def => def.field as string),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
/** Render a stored value as draft text. */
|
|
94
|
+
export function formatValue(def: FieldDef, value: unknown): string {
|
|
95
|
+
switch (def.kind) {
|
|
96
|
+
case 'boolean': return value === true ? 'true' : 'false'
|
|
97
|
+
case 'number': return typeof value === 'number' ? String(value) : ''
|
|
98
|
+
case 'cidrs': return Array.isArray(value) ? value.join(', ') : ''
|
|
99
|
+
case 'select': return typeof value === 'string' ? value : (def.options?.[0] ?? '')
|
|
100
|
+
// Tri-state: an unset value is a distinct third state ("auto"), never "false".
|
|
101
|
+
case 'tristate': return value === true ? 'true' : value === false ? 'false' : 'auto'
|
|
102
|
+
case 'text': return typeof value === 'string' ? value : ''
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** One field's contribution to a config patch. */
|
|
107
|
+
export type Write = { kind: 'set'; value: unknown } | { kind: 'clear' }
|
|
108
|
+
|
|
109
|
+
/** Parse draft text into a value for the POST body; undefined blocks saving. */
|
|
110
|
+
export function parseValue(def: FieldDef, text: string): Write | undefined {
|
|
111
|
+
const trimmed = text.trim()
|
|
112
|
+
switch (def.kind) {
|
|
113
|
+
case 'boolean':
|
|
114
|
+
if (trimmed === 'true') return { kind: 'set', value: true }
|
|
115
|
+
if (trimmed === 'false') return { kind: 'set', value: false }
|
|
116
|
+
return undefined
|
|
117
|
+
case 'number':
|
|
118
|
+
if (trimmed === '') return def.optional ? { kind: 'clear' } : undefined
|
|
119
|
+
if (!/^\d+$/.test(trimmed)) return undefined
|
|
120
|
+
return { kind: 'set', value: Number(trimmed) }
|
|
121
|
+
case 'cidrs': {
|
|
122
|
+
const cidrs = trimmed.split(',').map(s => s.trim()).filter(s => s !== '')
|
|
123
|
+
return cidrs.length === 0 ? { kind: 'clear' } : { kind: 'set', value: cidrs }
|
|
124
|
+
}
|
|
125
|
+
case 'select':
|
|
126
|
+
return def.options?.includes(trimmed) ? { kind: 'set', value: trimmed } : undefined
|
|
127
|
+
case 'tristate':
|
|
128
|
+
// 'auto' clears the key so it re-inherits the composition layer (and the
|
|
129
|
+
// resolution rule), which is what an unset tri-state means.
|
|
130
|
+
if (trimmed === 'auto') return { kind: 'clear' }
|
|
131
|
+
if (trimmed === 'true') return { kind: 'set', value: true }
|
|
132
|
+
if (trimmed === 'false') return { kind: 'set', value: false }
|
|
133
|
+
return undefined
|
|
134
|
+
case 'text':
|
|
135
|
+
return trimmed === '' ? (def.optional ? { kind: 'clear' } : undefined) : { kind: 'set', value: trimmed }
|
|
136
|
+
}
|
|
137
|
+
}
|