@riceawa/dsh-lan-gateway 0.5.2 → 0.5.4
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 +134 -238
- package/lib/client.js +68 -32
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +34 -3
- package/lib/index.js +426 -83
- package/package.json +3 -2
- package/skills/lan-gateway.md +8 -4
- package/src/auth.ts +98 -26
- package/src/client/lan-gateway-card.tsx +46 -2
- package/src/gateway.ts +238 -28
- package/src/index.ts +82 -16
- package/src/state.ts +77 -7
- package/src/tls.ts +33 -0
- package/src/upstream-session.ts +60 -7
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.4",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/index.d.ts",
|
|
@@ -86,5 +86,6 @@
|
|
|
86
86
|
"bugs": {
|
|
87
87
|
"url": "https://github.com/rice-awa/dsh-lan-gateway/issues"
|
|
88
88
|
},
|
|
89
|
-
"homepage": "https://github.com/rice-awa/dsh-lan-gateway#readme"
|
|
89
|
+
"homepage": "https://github.com/rice-awa/dsh-lan-gateway#readme",
|
|
90
|
+
"packageManager": "pnpm@11.20.0"
|
|
90
91
|
}
|
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
|
@@ -100,16 +100,40 @@ export function classifySource(
|
|
|
100
100
|
}
|
|
101
101
|
|
|
102
102
|
if (address === '::1') return 'loopback'
|
|
103
|
-
|
|
104
|
-
if (address.toLowerCase().startsWith('fe80:')) return 'lan'
|
|
103
|
+
if (inIpv6LinkLocal(address)) return 'lan'
|
|
105
104
|
return 'internet'
|
|
106
105
|
}
|
|
107
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Whether a textual IPv6 address falls inside fe80::/10. The first ten bits are
|
|
109
|
+
* `1111111010`, so the leading hextet spans fe80–febf; a `startsWith('fe80:')`
|
|
110
|
+
* test covers only fe80::/16 and misclassifies fe90::–febf:: as internet.
|
|
111
|
+
*/
|
|
112
|
+
function inIpv6LinkLocal(address: string): boolean {
|
|
113
|
+
const match = /^([0-9a-fA-F]{1,4}):/.exec(address)
|
|
114
|
+
if (match === null) return false
|
|
115
|
+
return (Number.parseInt(match[1]!, 16) & 0xffc0) === 0xfe80
|
|
116
|
+
}
|
|
117
|
+
|
|
108
118
|
/** Encode a byte buffer as URL-safe base64 without padding. */
|
|
109
119
|
function base64url(input: Buffer): string {
|
|
110
120
|
return input.toString('base64url')
|
|
111
121
|
}
|
|
112
122
|
|
|
123
|
+
/** The claims a verified session cookie carries. */
|
|
124
|
+
export interface SessionClaims {
|
|
125
|
+
/** Epoch millis at which the session expires. */
|
|
126
|
+
exp: number
|
|
127
|
+
/** The revocation epoch the cookie was minted under. */
|
|
128
|
+
epoch: number
|
|
129
|
+
/**
|
|
130
|
+
* Per-session id. Present on cookies minted from 0.5.4 on, which is what
|
|
131
|
+
* lets one session be retired on its own (sign-out) instead of retiring
|
|
132
|
+
* every session the password authorized. Absent on older cookies.
|
|
133
|
+
*/
|
|
134
|
+
sid?: string
|
|
135
|
+
}
|
|
136
|
+
|
|
113
137
|
/**
|
|
114
138
|
* Issue a signed session cookie value.
|
|
115
139
|
* @param secret - the HMAC signing secret (base64 string).
|
|
@@ -118,28 +142,29 @@ function base64url(input: Buffer): string {
|
|
|
118
142
|
* cookie whose epoch no longer matches the live state is rejected by
|
|
119
143
|
* {@link verifyCookie}. Defaults to 0 (epoch-less, legacy) for callers that
|
|
120
144
|
* do not participate in revocation.
|
|
145
|
+
* @param sid - optional per-session id (see {@link SessionClaims.sid}).
|
|
121
146
|
* @returns a `payload.signature` string suitable for the cookie value.
|
|
122
147
|
*/
|
|
123
|
-
export function signCookie(secret: string, expiresMs: number, epoch: number = 0): string {
|
|
124
|
-
const
|
|
148
|
+
export function signCookie(secret: string, expiresMs: number, epoch: number = 0, sid?: string): string {
|
|
149
|
+
const claims = sid === undefined ? { exp: expiresMs, epoch } : { exp: expiresMs, epoch, sid }
|
|
150
|
+
const payload = base64url(Buffer.from(JSON.stringify(claims)))
|
|
125
151
|
const sig = createHmac('sha256', secret).update(payload).digest('base64url')
|
|
126
152
|
return `${payload}.${sig}`
|
|
127
153
|
}
|
|
128
154
|
|
|
129
155
|
/**
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
* epoch 0, so an upgrade from a pre-0.5.0 state does not log everyone out.
|
|
156
|
+
* Verify a cookie's signature, expiry and epoch.
|
|
157
|
+
* @returns the claims it carries, or undefined when it is not a valid session.
|
|
133
158
|
*/
|
|
134
|
-
export function
|
|
159
|
+
export function verifySession(
|
|
135
160
|
secret: string,
|
|
136
161
|
value: string | undefined,
|
|
137
162
|
now: number,
|
|
138
163
|
epoch: number = 0,
|
|
139
|
-
):
|
|
140
|
-
if (value === undefined) return
|
|
164
|
+
): SessionClaims | undefined {
|
|
165
|
+
if (value === undefined) return undefined
|
|
141
166
|
const dot = value.indexOf('.')
|
|
142
|
-
if (dot === -1) return
|
|
167
|
+
if (dot === -1) return undefined
|
|
143
168
|
const payload = value.slice(0, dot)
|
|
144
169
|
const sig = value.slice(dot + 1)
|
|
145
170
|
const expected = createHmac('sha256', secret).update(payload).digest()
|
|
@@ -147,20 +172,39 @@ export function verifyCookie(
|
|
|
147
172
|
try {
|
|
148
173
|
actual = Buffer.from(sig, 'base64url')
|
|
149
174
|
} catch {
|
|
150
|
-
return
|
|
175
|
+
return undefined
|
|
151
176
|
}
|
|
152
|
-
if (expected.length !== actual.length) return
|
|
153
|
-
if (!timingSafeEqual(expected, actual)) return
|
|
177
|
+
if (expected.length !== actual.length) return undefined
|
|
178
|
+
if (!timingSafeEqual(expected, actual)) return undefined
|
|
154
179
|
try {
|
|
155
|
-
const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as
|
|
156
|
-
if (typeof decoded.exp !== 'number' || decoded.exp <= now) return
|
|
180
|
+
const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Partial<SessionClaims>
|
|
181
|
+
if (typeof decoded.exp !== 'number' || decoded.exp <= now) return undefined
|
|
157
182
|
const cookieEpoch = typeof decoded.epoch === 'number' ? decoded.epoch : 0
|
|
158
|
-
|
|
183
|
+
if (cookieEpoch !== epoch) return undefined
|
|
184
|
+
return {
|
|
185
|
+
exp: decoded.exp,
|
|
186
|
+
epoch: cookieEpoch,
|
|
187
|
+
...(typeof decoded.sid === 'string' ? { sid: decoded.sid } : {}),
|
|
188
|
+
}
|
|
159
189
|
} catch {
|
|
160
|
-
return
|
|
190
|
+
return undefined
|
|
161
191
|
}
|
|
162
192
|
}
|
|
163
193
|
|
|
194
|
+
/**
|
|
195
|
+
* Whether a cookie value is a valid, unexpired session signed with `secret`
|
|
196
|
+
* and minted under `epoch`. Epoch-less cookies (legacy payloads) count as
|
|
197
|
+
* epoch 0, so an upgrade from a pre-0.5.0 state does not log everyone out.
|
|
198
|
+
*/
|
|
199
|
+
export function verifyCookie(
|
|
200
|
+
secret: string,
|
|
201
|
+
value: string | undefined,
|
|
202
|
+
now: number,
|
|
203
|
+
epoch: number = 0,
|
|
204
|
+
): boolean {
|
|
205
|
+
return verifySession(secret, value, now, epoch) !== undefined
|
|
206
|
+
}
|
|
207
|
+
|
|
164
208
|
/**
|
|
165
209
|
* Whether a browser Origin header names the same authority (hostname:port) as
|
|
166
210
|
* a request Host header. Both sides run through WHATWG URL parsing so case and
|
|
@@ -181,7 +225,15 @@ export function originMatchesHost(origin: string | undefined, host: string | und
|
|
|
181
225
|
|
|
182
226
|
/** A token bucket limiter keyed by source address. */
|
|
183
227
|
export class RateLimiter {
|
|
228
|
+
/**
|
|
229
|
+
* Hard ceiling on tracked sources. Expiry alone only reclaims a bucket when
|
|
230
|
+
* `prune` runs, and a spray from many distinct addresses inside one window
|
|
231
|
+
* outruns it, so the map also sheds its soonest-expiring entries past this.
|
|
232
|
+
*/
|
|
233
|
+
private static readonly MAX_BUCKETS = 10_000
|
|
184
234
|
private readonly buckets = new Map<string, { tokens: number; resetAt: number }>()
|
|
235
|
+
/** Epoch millis at which the next opportunistic sweep is due. */
|
|
236
|
+
private nextPruneAt = 0
|
|
185
237
|
constructor(
|
|
186
238
|
private readonly maxTokens: number,
|
|
187
239
|
private readonly windowMs: number,
|
|
@@ -194,22 +246,42 @@ export class RateLimiter {
|
|
|
194
246
|
*/
|
|
195
247
|
allow(key: string): boolean {
|
|
196
248
|
const now = Date.now()
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
249
|
+
// Sweep on a rolling window. Without this, expired buckets are only
|
|
250
|
+
// replaced when their own key returns, so every address that ever posted
|
|
251
|
+
// to the login route keeps an entry for the life of the process.
|
|
252
|
+
if (now >= this.nextPruneAt) {
|
|
253
|
+
this.prune(now)
|
|
254
|
+
this.nextPruneAt = now + this.windowMs
|
|
201
255
|
}
|
|
202
|
-
|
|
203
|
-
|
|
256
|
+
const existing = this.buckets.get(key)
|
|
257
|
+
if (existing !== undefined && existing.resetAt > now) {
|
|
258
|
+
if (existing.tokens <= 0) return false
|
|
259
|
+
existing.tokens -= 1
|
|
204
260
|
return true
|
|
205
261
|
}
|
|
206
|
-
|
|
262
|
+
// A new bucket. A spray of distinct addresses inside one window outruns
|
|
263
|
+
// expiry, so shed the closest-to-expiring entries first — they are the
|
|
264
|
+
// ones about to lapse anyway, so the eviction costs the least fidelity.
|
|
265
|
+
if (existing === undefined && this.buckets.size >= RateLimiter.MAX_BUCKETS) {
|
|
266
|
+
this.evictSoonestToExpire()
|
|
267
|
+
}
|
|
268
|
+
this.buckets.set(key, { tokens: this.maxTokens - 1, resetAt: now + this.windowMs })
|
|
269
|
+
return true
|
|
207
270
|
}
|
|
208
271
|
|
|
209
|
-
/** Drop expired buckets to bound memory. */
|
|
272
|
+
/** Drop expired buckets to bound memory. Called from {@link allow}. */
|
|
210
273
|
prune(now: number = Date.now()): void {
|
|
211
274
|
for (const [key, bucket] of this.buckets) {
|
|
212
275
|
if (bucket.resetAt <= now) this.buckets.delete(key)
|
|
213
276
|
}
|
|
214
277
|
}
|
|
278
|
+
|
|
279
|
+
/** Trim back to 90% of the ceiling, oldest expiry first. */
|
|
280
|
+
private evictSoonestToExpire(): void {
|
|
281
|
+
const target = Math.floor(RateLimiter.MAX_BUCKETS * 0.9)
|
|
282
|
+
const byExpiry = [...this.buckets.entries()].sort((a, b) => a[1].resetAt - b[1].resetAt)
|
|
283
|
+
for (const [key] of byExpiry.slice(0, Math.max(0, this.buckets.size - target))) {
|
|
284
|
+
this.buckets.delete(key)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
215
287
|
}
|
|
@@ -46,6 +46,7 @@ export interface LanGatewaySettings {
|
|
|
46
46
|
tlsCertMaxAgeDays?: number
|
|
47
47
|
allowInsecurePlaintext?: boolean
|
|
48
48
|
trustedTerminator?: string
|
|
49
|
+
secureCookies?: boolean
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
/** GET /lan-gateway/config response. */
|
|
@@ -80,6 +81,7 @@ interface Labels {
|
|
|
80
81
|
lastError: string
|
|
81
82
|
[key: `field.${string}`]: string
|
|
82
83
|
[key: `hint.${string}`]: string
|
|
84
|
+
[key: `opt.${string}`]: string
|
|
83
85
|
}
|
|
84
86
|
|
|
85
87
|
const LABELS: Record<'zh' | 'en', Labels> = {
|
|
@@ -114,6 +116,11 @@ const LABELS: Record<'zh' | 'en', Labels> = {
|
|
|
114
116
|
'hint.allowInsecurePlaintext': '危险:关闭 TLS 或受信终止代理时仍启动监听,密码与会话将以明文传输',
|
|
115
117
|
'field.trustedTerminator': '受信 TLS 终止代理',
|
|
116
118
|
'hint.trustedTerminator': '可选:声明前置代理标识,视为加密入口(如 nginx)。留空 = 未声明',
|
|
119
|
+
'field.secureCookies': '会话 cookie 的 Secure 属性',
|
|
120
|
+
'hint.secureCookies': '自动 = TLS 或已声明受信终止代理时加 Secure。受信代理只做明文鉴权、浏览器走 http 访问时须设为 false,否则浏览器拒收 Secure cookie,登录会无限弹回登录页',
|
|
121
|
+
'opt.auto': '自动',
|
|
122
|
+
'opt.true': '始终 Secure',
|
|
123
|
+
'opt.false': '不加 Secure(明文浏览器入口)',
|
|
117
124
|
'field.cookieMaxAgeDays': '会话有效期(天)',
|
|
118
125
|
'hint.cookieMaxAgeDays': '登录 cookie 的存活天数(默认 7)',
|
|
119
126
|
'field.tlsEnabled': '启用 TLS(HTTPS)',
|
|
@@ -160,6 +167,11 @@ const LABELS: Record<'zh' | 'en', Labels> = {
|
|
|
160
167
|
'hint.allowInsecurePlaintext': 'Dangerous: start the listener even without TLS or a trusted terminator; passwords and sessions travel in clear',
|
|
161
168
|
'field.trustedTerminator': 'Trusted TLS terminator',
|
|
162
169
|
'hint.trustedTerminator': 'Optional identifier for a front proxy (e.g. nginx) treated as the encrypted ingress. Empty = none declared',
|
|
170
|
+
'field.secureCookies': 'Session cookie Secure attribute',
|
|
171
|
+
'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
|
+
'opt.auto': 'Auto',
|
|
173
|
+
'opt.true': 'Always Secure',
|
|
174
|
+
'opt.false': 'No Secure (plaintext browser ingress)',
|
|
163
175
|
'field.cookieMaxAgeDays': 'Session lifetime (days)',
|
|
164
176
|
'hint.cookieMaxAgeDays': 'Login cookie lifetime (default 7)',
|
|
165
177
|
'field.tlsEnabled': 'Enable TLS (HTTPS)',
|
|
@@ -186,7 +198,7 @@ function labels(): Labels {
|
|
|
186
198
|
/* Field model */
|
|
187
199
|
/* ------------------------------------------------------------------ */
|
|
188
200
|
|
|
189
|
-
type FieldKind = 'boolean' | 'number' | 'text' | 'cidrs' | 'select'
|
|
201
|
+
type FieldKind = 'boolean' | 'number' | 'text' | 'cidrs' | 'select' | 'tristate'
|
|
190
202
|
|
|
191
203
|
interface FieldDef {
|
|
192
204
|
field: keyof LanGatewaySettings
|
|
@@ -195,6 +207,18 @@ interface FieldDef {
|
|
|
195
207
|
options?: readonly string[]
|
|
196
208
|
}
|
|
197
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
|
+
|
|
198
222
|
const FIELDS: readonly FieldDef[] = [
|
|
199
223
|
{ field: 'enabled', kind: 'boolean' },
|
|
200
224
|
{ field: 'gatewayPort', kind: 'number' },
|
|
@@ -210,6 +234,7 @@ const FIELDS: readonly FieldDef[] = [
|
|
|
210
234
|
{ field: 'tlsCertMaxAgeDays', kind: 'number' },
|
|
211
235
|
{ field: 'allowInsecurePlaintext', kind: 'boolean' },
|
|
212
236
|
{ field: 'trustedTerminator', kind: 'text', optional: true },
|
|
237
|
+
{ field: 'secureCookies', kind: 'tristate' },
|
|
213
238
|
]
|
|
214
239
|
|
|
215
240
|
function formatValue(def: FieldDef, value: unknown): string {
|
|
@@ -218,6 +243,8 @@ function formatValue(def: FieldDef, value: unknown): string {
|
|
|
218
243
|
case 'number': return typeof value === 'number' ? String(value) : ''
|
|
219
244
|
case 'cidrs': return Array.isArray(value) ? value.join(', ') : ''
|
|
220
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'
|
|
221
248
|
case 'text': return typeof value === 'string' ? value : ''
|
|
222
249
|
}
|
|
223
250
|
}
|
|
@@ -242,6 +269,13 @@ function parseValue(def: FieldDef, text: string): Write | undefined {
|
|
|
242
269
|
}
|
|
243
270
|
case 'select':
|
|
244
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
|
|
245
279
|
case 'text':
|
|
246
280
|
return trimmed === '' ? (def.optional ? { kind: 'clear' } : undefined) : { kind: 'set', value: trimmed }
|
|
247
281
|
}
|
|
@@ -384,6 +418,11 @@ export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
|
|
|
384
418
|
</div>
|
|
385
419
|
)
|
|
386
420
|
case 'select':
|
|
421
|
+
case 'tristate': {
|
|
422
|
+
// A tri-state renders as a three-way select because neither a checkbox
|
|
423
|
+
// (cannot express "unset") nor a text field (cannot express false)
|
|
424
|
+
// distinguishes auto from an explicit false.
|
|
425
|
+
const options = def.kind === 'tristate' ? TRISTATE_OPTIONS : (def.options ?? [])
|
|
387
426
|
return (
|
|
388
427
|
<div style={styles.field}>
|
|
389
428
|
<label style={styles.label} htmlFor={`lan-gw-${field}`}>{label}</label>
|
|
@@ -394,7 +433,11 @@ export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
|
|
|
394
433
|
disabled={saving}
|
|
395
434
|
onChange={(e: ChangeEvent<HTMLSelectElement>) => stage(field, e.target.value)}
|
|
396
435
|
>
|
|
397
|
-
{
|
|
436
|
+
{options.map(option => (
|
|
437
|
+
<option key={option} value={option}>
|
|
438
|
+
{def.kind === 'tristate' ? t[`opt.${option}`] : option}
|
|
439
|
+
</option>
|
|
440
|
+
))}
|
|
398
441
|
</select>
|
|
399
442
|
<span style={styles.hint}>{hint}</span>
|
|
400
443
|
<button
|
|
@@ -407,6 +450,7 @@ export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
|
|
|
407
450
|
</button>
|
|
408
451
|
</div>
|
|
409
452
|
)
|
|
453
|
+
}
|
|
410
454
|
default:
|
|
411
455
|
return (
|
|
412
456
|
<div style={styles.field}>
|