@riceawa/dsh-lan-gateway 0.3.0 → 0.5.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@riceawa/dsh-lan-gateway",
3
- "description": "LAN/internet reverse-proxy gateway for the DeepSeek Harness web GUI: binds 0.0.0.0, forwards to the loopback dsh web server with header rewrite to pass the /api trust fence. LAN sources are password-free; non-LAN sources get a login page + HMAC cookie. Optional TLS with auto-generated self-signed or user-supplied certificates, 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.3.0",
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.0",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: lan-gateway
3
- description: Manage remote (non-LAN) access to the DeepSeek Harness web GUI through the dsh-lan-gateway plugin. Use when the user asks to open the web GUI to the LAN/internet, set or change the remote-access password, rotate the session secret, or check whether remote access is currently enabled and what it is listening on.
4
- whenToUse: The user wants the web GUI reachable from their LAN or the wider internet, wants to enable/disable that, set the login password for remote visitors, rotate the session cookie secret, or learn the current gateway status (port, target, password state, trusted CIDRs).
3
+ description: Manage remote (non-LAN) access to the DeepSeek Harness web GUI through the dsh-lan-gateway plugin. Use when the user asks to open the web GUI to the LAN/internet, set or change the remote-access password, rotate the session secret, or check whether remote access is currently enabled, on what port, and whether the harness base supports the shared upstream session relay.
4
+ whenToUse: The user wants the web GUI reachable from their LAN or the wider internet, wants to enable/disable that, set the login password for remote visitors, rotate the session cookie secret, or learn the current gateway status (port, target, password state, ingress encryption, upstream-session relay, session epoch).
5
5
  user-invocable: true
6
6
  ---
7
7
 
@@ -10,47 +10,65 @@ user-invocable: true
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
12
  hard-refuses `0.0.0.0`), so this plugin runs its own reverse-proxy gateway on
13
- `0.0.0.0` that forwards to the loopback web server while rewriting Host/Origin so
14
- the `/api` trust fence still passes.
13
+ `0.0.0.0` that forwards to the loopback web server while rewriting Host/Origin.
15
14
 
16
- ## Access tiers
15
+ Since v0.5.0 the model is **default-deny** (post-QVD-2026-57410 hardening):
17
16
 
18
- - **Loopback** (`127.0.0.1`, `::1`) — no password.
19
- - **LAN** (RFC1918 `10/8`, `172.16/12`, `192.168/16`, link-local `169.254/16`, `fe80::/10`)
20
- — no password.
21
- - **Anything else** (the public internet, CGNAT, Tailscale) — must sign in at the
22
- login page and receive the session cookie.
23
-
24
- You can extend the trusted LAN ranges by editing the plugin's `lanCidrs` config.
17
+ - **Every source — loopback, LAN, internet — must sign in** at the gateway login
18
+ page and present the HMAC session cookie. Nothing is granted by source alone.
19
+ - **LAN passwordless is an explicit opt-in** (`lanPasswordless: true`). When on,
20
+ sources in `lanCidrs` (and loopback) skip the gateway login page. The listener
21
+ refuses to enable `lanPasswordless` unless the dsh base enforces browser-session
22
+ auth (dsh ≥ 0.1.2-rc.1), where the gateway relays one shared upstream session and
23
+ upstream authorization still gates every request.
24
+ - **Encrypted ingress is required to start.** The listener refuses to run over
25
+ plaintext unless TLS is on (`tlsEnabled`), a trusted terminator is declared
26
+ (`trustedTerminator`), or `allowInsecurePlaintext: true` is set explicitly.
27
+ - A password is always required to run the gateway.
25
28
 
26
29
  ## Drive it through the `lan_gateway` tool
27
30
 
28
- Do not edit state files by hand — use the `lan_gateway` tool. The gateway refuses
29
- to listen until a password is set (unless `authRequired` is explicitly false).
31
+ Do not edit state files by hand — use the `lan_gateway` tool.
30
32
 
31
33
  - `lan_gateway` with `command: "status"` — is it listening, on which port, toward
32
- which dsh port, password set?, trusted CIDRs, cookie lifetime.
33
- - `lan_gateway` with `command: "set-password"` and `password: "<new pass>"`
34
- (min 8 chars) — set the remote-access password. Pass an empty password to clear.
35
- - `lan_gateway` with `command: "enable"` — start listening on the gateway port.
34
+ which dsh port, password set?, session epoch, upstream-session relay state,
35
+ ingress encryption, last error.
36
+ - `lan_gateway` with `command: "enable"` — start listening. If it refuses (no
37
+ password, legacy `authRequired: false`, plaintext without opt-in, `lanPasswordless`
38
+ without a session-capable base), the message tells you what to change.
36
39
  - `lan_gateway` with `command: "disable"` — stop listening (dsh itself stays up).
40
+ - `lan_gateway` with `command: "set-password"` and `password: "<new pass>"`
41
+ (min 8 chars) — set the login password. Setting it revokes every existing session
42
+ (all sources, LAN included, must sign in again). Pass an empty password to clear —
43
+ clearing stops the listener (a password is required to run).
37
44
  - `lan_gateway` with `command: "rotate-secret"` — invalidate every issued login
38
- cookie (users must sign in again).
45
+ cookie and every live WebSocket (users must sign in again).
46
+ - `lan_gateway` with `command: "tls-regenerate"` — mint a fresh self-signed
47
+ certificate and hot-restart the listener (tlsMode must be `self-signed`).
39
48
 
40
49
  ## After enabling
41
50
 
42
51
  Tell the user the gateway URL they can share:
43
52
 
44
- - On the LAN: `http://<lan-ip>:<gatewayPort>/` (default port `3081`).
45
- - From outside: the machine's public address or a Tailscale IP on port
46
- `3081` — that path requires the password.
53
+ - On the LAN: `http://<lan-ip>:<gatewayPort>/` (default port `3081`) — requires the
54
+ login password unless `lanPasswordless` is enabled for their network.
55
+ - From outside: the machine's public address or a Tailscale IP on port `3081` —
56
+ that path always requires the password.
57
+
58
+ Remind the user which encrypted-ingress choice they are on: TLS / a trusted
59
+ terminator / explicit plaintext. If they enabled plaintext, restate the exposure
60
+ once (passwords and sessions travel in clear).
47
61
 
48
62
  ## Troubleshooting
49
63
 
50
- - Gateway won't start: set a password first (`lan_gateway set-password`), check
51
- the port is free, confirm `ctx.webServer.port` (default dsh web port `3080`)
52
- is where dsh is actually listening.
53
- - Remote visitors see a redirect loop or 403: check the login cookie
54
- (`dsh_gw_auth`) and that their source is classified as `internet`. A `403` on
55
- `/api` from a browser is the gateway's own origin/CSRF fence — a real page
56
- load carries the right Origin; a hand-crafted cross-site request does not.
64
+ - Gateway won't start: set a password first (`lan_gateway set-password`); check the
65
+ fail-closed conditions — legacy `authRequired: false` must be removed, plaintext
66
+ needs `allowInsecurePlaintext: true` or TLS/`trustedTerminator`, and
67
+ `lanPasswordless` needs dsh ≥ 0.1.2-rc.1. `lan_gateway status` shows the last error.
68
+ - Remote visitors see a redirect loop or 403: check the login cookie (`dsh_gw_auth`)
69
+ and that their source is classified as `internet`. A `403` on `/api` from a
70
+ browser is the gateway's own origin/CSRF fence — a real page load carries the
71
+ right Origin; a hand-crafted cross-site request (or a non-browser client posting
72
+ without an Origin) does not.
73
+ - Sessions don't survive a password change / `rotate-secret`: that is by design —
74
+ the revocation epoch advanced and all cookies (and live WebSockets) were revoked.
package/src/auth.ts CHANGED
@@ -114,16 +114,29 @@ function base64url(input: Buffer): string {
114
114
  * Issue a signed session cookie value.
115
115
  * @param secret - the HMAC signing secret (base64 string).
116
116
  * @param expiresMs - epoch millis at which the session expires.
117
+ * @param epoch - the session revocation epoch the cookie is minted under; a
118
+ * cookie whose epoch no longer matches the live state is rejected by
119
+ * {@link verifyCookie}. Defaults to 0 (epoch-less, legacy) for callers that
120
+ * do not participate in revocation.
117
121
  * @returns a `payload.signature` string suitable for the cookie value.
118
122
  */
119
- export function signCookie(secret: string, expiresMs: number): string {
120
- const payload = base64url(Buffer.from(JSON.stringify({ exp: expiresMs })))
123
+ export function signCookie(secret: string, expiresMs: number, epoch: number = 0): string {
124
+ const payload = base64url(Buffer.from(JSON.stringify({ exp: expiresMs, epoch })))
121
125
  const sig = createHmac('sha256', secret).update(payload).digest('base64url')
122
126
  return `${payload}.${sig}`
123
127
  }
124
128
 
125
- /** Whether a cookie value is a valid, unexpired session signed with `secret`. */
126
- export function verifyCookie(secret: string, value: string | undefined, now: number): boolean {
129
+ /**
130
+ * Whether a cookie value is a valid, unexpired session signed with `secret`
131
+ * and minted under `epoch`. Epoch-less cookies (legacy payloads) count as
132
+ * epoch 0, so an upgrade from a pre-0.5.0 state does not log everyone out.
133
+ */
134
+ export function verifyCookie(
135
+ secret: string,
136
+ value: string | undefined,
137
+ now: number,
138
+ epoch: number = 0,
139
+ ): boolean {
127
140
  if (value === undefined) return false
128
141
  const dot = value.indexOf('.')
129
142
  if (dot === -1) return false
@@ -139,8 +152,28 @@ export function verifyCookie(secret: string, value: string | undefined, now: num
139
152
  if (expected.length !== actual.length) return false
140
153
  if (!timingSafeEqual(expected, actual)) return false
141
154
  try {
142
- const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { exp?: unknown }
143
- return typeof decoded.exp === 'number' && decoded.exp > now
155
+ const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { exp?: unknown; epoch?: unknown }
156
+ if (typeof decoded.exp !== 'number' || decoded.exp <= now) return false
157
+ const cookieEpoch = typeof decoded.epoch === 'number' ? decoded.epoch : 0
158
+ return cookieEpoch === epoch
159
+ } catch {
160
+ return false
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Whether a browser Origin header names the same authority (hostname:port) as
166
+ * a request Host header. Both sides run through WHATWG URL parsing so case and
167
+ * an implicit scheme-default port never decide the match — the comparison the
168
+ * gateway uses to tell same-origin browser requests from cross-site ones.
169
+ * @param origin - the `Origin` header value, or undefined.
170
+ * @param host - the `Host` header value, or undefined.
171
+ * @returns true only when both parse and name the same host[:port].
172
+ */
173
+ export function originMatchesHost(origin: string | undefined, host: string | undefined): boolean {
174
+ if (origin === undefined || host === undefined) return false
175
+ try {
176
+ return new URL(origin).host === new URL(`http://${host}`).host
144
177
  } catch {
145
178
  return false
146
179
  }
@@ -79,11 +79,25 @@ export function apply(ctx: ClientContext): void {
79
79
  // The card rides the official Plugins → Configurable tab. Like ModLens, it
80
80
  // registers with no inject face and fetches its own loopback config route,
81
81
  // so it has no settings/locale/connection service dependencies.
82
+ //
83
+ // The `settings.plugin.item` slot is keyed BY the settings namespace the
84
+ // card edits (rc.8 contract): the configurable tab only dispatches entries
85
+ // whose `options.key` is both present and served by the Host's settings
86
+ // describe mirror. Registering with `id` alone throws
87
+ // `keyed slot "settings.plugin.item" requires options.key` and the card
88
+ // silently disappears from Settings → Plugins.
89
+ //
90
+ // `id`/`order` ride the legacy list-slot shape (older DSH versions
91
+ // dispatched this slot by id): harmless metadata on the keyed slot, and
92
+ // what keeps the card mounting if this plugin ever loads into an older
93
+ // deployment. Spread from a typed constant so the keyed registration type
94
+ // stays exact.
95
+ const legacyListOptions = { id: 'lan-gateway', order: 30 } as const
82
96
  ctx.slots.inject('settings.plugin.item', function* () {
83
97
  yield ctx.slots.register({
84
98
  name: 'settings.plugin.item',
85
- id: 'lan-gateway',
86
- order: 30,
99
+ key: 'lan-gateway',
100
+ ...legacyListOptions,
87
101
  }, LanGatewayCard)
88
102
  })
89
103
  }
@@ -15,14 +15,15 @@ import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
15
15
 
16
16
  /**
17
17
  * The official Settings → Plugins page declares the `settings.plugin.item`
18
- * list slot (kind list, root scope, empty owner share) in its own package.
19
- * The published package ships no `src/`, so the entry is re-declared here —
20
- * the runtime slot is real; this only restores the compile-time table.
18
+ * slot keyed by the settings namespace each card edits (newer DSH releases;
19
+ * older releases dispatched it as a list slot by `id`). The published package
20
+ * ships no `src/`, so the entry is re-declared here — the runtime slot is
21
+ * real; this only restores the compile-time table.
21
22
  */
22
23
  declare module '@deepseek-ai/dsh-client-ui-slots' {
23
24
  interface SlotMap {
24
25
  /** One plugin's card inside the plugin configuration section. */
25
- 'settings.plugin.item': { kind: 'list'; scope: 'root'; owner: { children?: never } }
26
+ 'settings.plugin.item': { kind: 'keyed'; scope: 'root'; owner: { children?: never } }
26
27
  }
27
28
  }
28
29
 
@@ -35,7 +36,7 @@ export interface LanGatewaySettings {
35
36
  gatewayPort?: number
36
37
  dshTargetPort?: number
37
38
  lanCidrs?: string[]
38
- authRequired?: boolean
39
+ lanPasswordless?: boolean
39
40
  cookieMaxAgeDays?: number
40
41
  tlsEnabled?: boolean
41
42
  tlsMode?: 'self-signed' | 'custom'
@@ -43,6 +44,8 @@ export interface LanGatewaySettings {
43
44
  tlsKeyPath?: string
44
45
  tlsSelfSignedHosts?: string
45
46
  tlsCertMaxAgeDays?: number
47
+ allowInsecurePlaintext?: boolean
48
+ trustedTerminator?: string
46
49
  }
47
50
 
48
51
  /** GET /lan-gateway/config response. */
@@ -105,8 +108,12 @@ const LABELS: Record<'zh' | 'en', Labels> = {
105
108
  'hint.dshTargetPort': '留空则自动跟随 dsh web 端口(默认 3080)',
106
109
  'field.lanCidrs': '免密 LAN 网段',
107
110
  'hint.lanCidrs': '逗号分隔的 CIDR,如 10.0.0.0/8, 192.168.0.0/16',
108
- 'field.authRequired': '非 LAN 访问需要密码',
109
- 'hint.authRequired': '公网来源必须登录后才能访问',
111
+ 'field.lanPasswordless': 'LAN 免登录',
112
+ 'hint.lanPasswordless': 'LAN/回环来源跳过网关登录页,但仍共用同一上游会话(需 dsh ≥ 0.1.2)',
113
+ 'field.allowInsecurePlaintext': '允许明文 HTTP',
114
+ 'hint.allowInsecurePlaintext': '危险:关闭 TLS 或受信终止代理时仍启动监听,密码与会话将以明文传输',
115
+ 'field.trustedTerminator': '受信 TLS 终止代理',
116
+ 'hint.trustedTerminator': '可选:声明前置代理标识,视为加密入口(如 nginx)。留空 = 未声明',
110
117
  'field.cookieMaxAgeDays': '会话有效期(天)',
111
118
  'hint.cookieMaxAgeDays': '登录 cookie 的存活天数(默认 7)',
112
119
  'field.tlsEnabled': '启用 TLS(HTTPS)',
@@ -147,8 +154,12 @@ const LABELS: Record<'zh' | 'en', Labels> = {
147
154
  'hint.dshTargetPort': 'Leave empty to follow the dsh web port (default 3080)',
148
155
  'field.lanCidrs': 'Password-free LAN CIDRs',
149
156
  'hint.lanCidrs': 'Comma separated CIDRs, e.g. 10.0.0.0/8, 192.168.0.0/16',
150
- 'field.authRequired': 'Password required for non-LAN',
151
- 'hint.authRequired': 'Internet sources must sign in before reaching the GUI',
157
+ 'field.lanPasswordless': 'LAN skip login',
158
+ 'hint.lanPasswordless': 'LAN/loopback sources skip the gateway login page but still ride one shared upstream session (needs dsh >= 0.1.2)',
159
+ 'field.allowInsecurePlaintext': 'Allow plaintext HTTP',
160
+ 'hint.allowInsecurePlaintext': 'Dangerous: start the listener even without TLS or a trusted terminator; passwords and sessions travel in clear',
161
+ 'field.trustedTerminator': 'Trusted TLS terminator',
162
+ 'hint.trustedTerminator': 'Optional identifier for a front proxy (e.g. nginx) treated as the encrypted ingress. Empty = none declared',
152
163
  'field.cookieMaxAgeDays': 'Session lifetime (days)',
153
164
  'hint.cookieMaxAgeDays': 'Login cookie lifetime (default 7)',
154
165
  'field.tlsEnabled': 'Enable TLS (HTTPS)',
@@ -189,7 +200,7 @@ const FIELDS: readonly FieldDef[] = [
189
200
  { field: 'gatewayPort', kind: 'number' },
190
201
  { field: 'dshTargetPort', kind: 'number', optional: true },
191
202
  { field: 'lanCidrs', kind: 'cidrs' },
192
- { field: 'authRequired', kind: 'boolean' },
203
+ { field: 'lanPasswordless', kind: 'boolean' },
193
204
  { field: 'cookieMaxAgeDays', kind: 'number' },
194
205
  { field: 'tlsEnabled', kind: 'boolean' },
195
206
  { field: 'tlsMode', kind: 'select', options: ['self-signed', 'custom'] },
@@ -197,6 +208,8 @@ const FIELDS: readonly FieldDef[] = [
197
208
  { field: 'tlsCertPath', kind: 'text', optional: true },
198
209
  { field: 'tlsKeyPath', kind: 'text', optional: true },
199
210
  { field: 'tlsCertMaxAgeDays', kind: 'number' },
211
+ { field: 'allowInsecurePlaintext', kind: 'boolean' },
212
+ { field: 'trustedTerminator', kind: 'text', optional: true },
200
213
  ]
201
214
 
202
215
  function formatValue(def: FieldDef, value: unknown): string {
@@ -423,13 +436,13 @@ export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
423
436
  aria-expanded={open}
424
437
  onClick={() => { setOpen(!open) }}
425
438
  >
426
- <span style={styles.headText}>
439
+ <span style={styles.headerTop}>
427
440
  <span style={styles.name}>{t.title}</span>
428
- <span style={styles.description}>{t.description}</span>
441
+ <span style={styles.status} title={statusLine}>{statusLine}</span>
442
+ {dirty ? <span style={styles.pending}>{t.unsaved}</span> : null}
443
+ <span style={open ? { ...styles.chevron, ...styles.chevronOpen } : styles.chevron}>{open ? '▾' : '▸'}</span>
429
444
  </span>
430
- <span style={styles.status}>{statusLine}</span>
431
- {dirty ? <span style={styles.pending}>{t.unsaved}</span> : null}
432
- <span style={open ? { ...styles.chevron, ...styles.chevronOpen } : styles.chevron}>{open ? '▾' : '▸'}</span>
445
+ <span style={styles.description}>{t.description}</span>
433
446
  </button>
434
447
  {open
435
448
  ? (
@@ -501,8 +514,9 @@ const styles: Record<string, React.CSSProperties> = {
501
514
  },
502
515
  header: {
503
516
  display: 'flex',
504
- alignItems: 'center',
505
- gap: '12px',
517
+ flexDirection: 'column',
518
+ alignItems: 'stretch',
519
+ gap: '6px',
506
520
  width: '100%',
507
521
  padding: '14px 16px',
508
522
  border: 0,
@@ -512,10 +526,31 @@ const styles: Record<string, React.CSSProperties> = {
512
526
  textAlign: 'left',
513
527
  cursor: 'pointer',
514
528
  },
515
- headText: { display: 'flex', flexDirection: 'column', gap: '4px', flex: 1, minWidth: 0 },
516
- name: { fontSize: '15px', fontWeight: 600, lineHeight: 1.4, color: L.labelPrimary },
517
- description: { fontSize: '13px', lineHeight: 1.5, color: L.labelTertiary },
518
- status: { fontSize: '11px', color: L.labelTertiary, whiteSpace: 'nowrap' },
529
+ headerTop: { display: 'flex', alignItems: 'center', gap: '12px', width: '100%' },
530
+ name: { flex: '1 1 auto', minWidth: 0, fontSize: '15px', fontWeight: 600, lineHeight: 1.4, color: L.labelPrimary },
531
+ // The status carries a verbose TLS cert summary; cap it and ellipsize so it
532
+ // can never swallow the row or squeeze the title (the old nowrap alone
533
+ // caused the description to be pushed into a thin wrapping column).
534
+ status: {
535
+ flex: '0 1 auto',
536
+ minWidth: 0,
537
+ maxWidth: '60%',
538
+ fontSize: '11px',
539
+ lineHeight: 1.4,
540
+ color: L.labelTertiary,
541
+ whiteSpace: 'nowrap',
542
+ overflow: 'hidden',
543
+ textOverflow: 'ellipsis',
544
+ },
545
+ description: {
546
+ display: 'block',
547
+ fontSize: '13px',
548
+ lineHeight: 1.5,
549
+ color: L.labelTertiary,
550
+ whiteSpace: 'nowrap',
551
+ overflow: 'hidden',
552
+ textOverflow: 'ellipsis',
553
+ },
519
554
  pending: {
520
555
  flex: 'none',
521
556
  borderRadius: '999px',