@riceawa/dsh-lan-gateway 0.3.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 ADDED
@@ -0,0 +1,86 @@
1
+ {
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",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ },
13
+ "./cordis.patch.yml": "./cordis.patch.yml",
14
+ "./package.json": "./package.json",
15
+ "./client": {
16
+ "default": "./lib/client.js"
17
+ }
18
+ },
19
+ "files": [
20
+ "lib",
21
+ "src",
22
+ "skills",
23
+ "cordis.patch.yml"
24
+ ],
25
+ "scripts": {
26
+ "build": "tsdown",
27
+ "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.client.json --noEmit",
28
+ "test": "vitest run",
29
+ "build:client": "tsdown",
30
+ "prepack": "tsdown"
31
+ },
32
+ "dsh": {
33
+ "bundle": {
34
+ "patch": "./cordis.patch.yml"
35
+ },
36
+ "client": {
37
+ "inject": [
38
+ "@deepseek-ai/dsh-client-runtime"
39
+ ],
40
+ "platform": "web",
41
+ "immediately": true
42
+ }
43
+ },
44
+ "peerDependencies": {
45
+ "@deepseek-ai/cordis": "^4.0.1",
46
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.6 || ^0.1.0",
47
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.6 || ^0.1.0",
48
+ "@deepseek-ai/schemastery": "^3.18.1"
49
+ },
50
+ "devDependencies": {
51
+ "@deepseek-ai/cordis": "4.0.1",
52
+ "@deepseek-ai/dsh-client-runtime": "0.1.0-rc.6",
53
+ "@deepseek-ai/dsh-client-ui-slots": "0.1.0-rc.6",
54
+ "@deepseek-ai/dsh-settings": "0.1.0-rc.6",
55
+ "@deepseek-ai/dsh-tools": "0.1.0-rc.6",
56
+ "@deepseek-ai/schemastery": "3.18.1",
57
+ "@types/node": "^22.0.0",
58
+ "@types/react": "~18.3.1",
59
+ "react": "^18.2.0",
60
+ "tsdown": "^0.22.2",
61
+ "tsx": "^4.23.12",
62
+ "typescript": "^5.9.3",
63
+ "unrun": "^0.3.1",
64
+ "vitest": "^4.1.1"
65
+ },
66
+ "license": "MIT",
67
+ "keywords": [
68
+ "dsh",
69
+ "deepseek-harness",
70
+ "plugin",
71
+ "lan",
72
+ "gateway",
73
+ "reverse-proxy",
74
+ "uuid",
75
+ "secure-context",
76
+ "shim"
77
+ ],
78
+ "repository": {
79
+ "type": "git",
80
+ "url": "git+https://github.com/rice-awa/dsh-lan-gateway.git"
81
+ },
82
+ "bugs": {
83
+ "url": "https://github.com/rice-awa/dsh-lan-gateway/issues"
84
+ },
85
+ "homepage": "https://github.com/rice-awa/dsh-lan-gateway#readme"
86
+ }
@@ -0,0 +1,56 @@
1
+ ---
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).
5
+ user-invocable: true
6
+ ---
7
+
8
+ # dsh-lan-gateway
9
+
10
+ The `dsh-lan-gateway` plugin lets the DeepSeek Harness web GUI be reached from the
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
+ `0.0.0.0` that forwards to the loopback web server while rewriting Host/Origin so
14
+ the `/api` trust fence still passes.
15
+
16
+ ## Access tiers
17
+
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.
25
+
26
+ ## Drive it through the `lan_gateway` tool
27
+
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).
30
+
31
+ - `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.
36
+ - `lan_gateway` with `command: "disable"` — stop listening (dsh itself stays up).
37
+ - `lan_gateway` with `command: "rotate-secret"` — invalidate every issued login
38
+ cookie (users must sign in again).
39
+
40
+ ## After enabling
41
+
42
+ Tell the user the gateway URL they can share:
43
+
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.
47
+
48
+ ## Troubleshooting
49
+
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.
package/src/auth.ts ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Authentication primitives for the LAN gateway: source-IP classification
3
+ * (loopback / lan / internet), HMAC-signed session cookies, and an in-memory
4
+ * per-source login rate limiter. Pure functions where possible so the tests
5
+ * can exercise them without a live server. No runtime dependencies beyond
6
+ * node:crypto.
7
+ *
8
+ * @module @riceawa/dsh-lan-gateway/auth
9
+ */
10
+
11
+ import {
12
+ createHmac,
13
+ timingSafeEqual,
14
+ } from 'node:crypto'
15
+
16
+ /** The three trust tiers a request source can fall into. */
17
+ export type SourceClass = 'loopback' | 'lan' | 'internet'
18
+
19
+ /** One CIDR range: an IPv4 address and its prefix length. */
20
+ export interface Cidr {
21
+ addr: number
22
+ prefix: number
23
+ }
24
+
25
+ const DEFAULT_LAN_CIDRS: readonly string[] = [
26
+ '10.0.0.0/8',
27
+ '172.16.0.0/12',
28
+ '192.168.0.0/16',
29
+ '169.254.0.0/16', // link-local
30
+ ]
31
+
32
+ /** Default LAN CIDRs: RFC1918 + link-local, IPv4. */
33
+ export const DEFAULT_LAN_CIDR_STRINGS: readonly string[] = [...DEFAULT_LAN_CIDRS]
34
+
35
+ /** Parse a dotted-quad IPv4 string to its 32-bit integer, or undefined. */
36
+ export function parseIpv4(text: string): number | undefined {
37
+ const parts = text.split('.')
38
+ if (parts.length !== 4) return undefined
39
+ let out = 0
40
+ for (const part of parts) {
41
+ if (!/^\d{1,3}$/.test(part)) return undefined
42
+ const byte = Number(part)
43
+ if (byte > 255) return undefined
44
+ out = (out << 8) | byte
45
+ }
46
+ return out >>> 0
47
+ }
48
+
49
+ /** Parse `a.b.c.d/len` into a {@link Cidr}, or undefined on malformed input. */
50
+ export function parseCidr(text: string): Cidr | undefined {
51
+ const slash = text.indexOf('/')
52
+ const addrText = slash === -1 ? text : text.slice(0, slash)
53
+ const prefixText = slash === -1 ? '32' : text.slice(slash + 1)
54
+ const addr = parseIpv4(addrText)
55
+ if (addr === undefined) return undefined
56
+ if (!/^\d{1,2}$/.test(prefixText)) return undefined
57
+ const prefix = Number(prefixText)
58
+ if (prefix < 0 || prefix > 32) return undefined
59
+ return { addr, prefix }
60
+ }
61
+
62
+ /** Whether a 32-bit IPv4 address falls inside one CIDR range. */
63
+ export function inCidr(ip: number, cidr: Cidr): boolean {
64
+ if (cidr.prefix === 0) return true
65
+ const mask = cidr.prefix === 32 ? 0xffffffff : (0xffffffff << (32 - cidr.prefix)) >>> 0
66
+ return (ip & mask) === (cidr.addr & mask)
67
+ }
68
+
69
+ /** Normalize a raw socket address to a bare IPv4/6 string we classify on. */
70
+ function normalizeAddress(raw: string): string {
71
+ const value = raw.trim()
72
+ // IPv4-mapped IPv6: ::ffff:a.b.c.d
73
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(value)
74
+ if (mapped !== null) return mapped[1]!
75
+ return value
76
+ }
77
+
78
+ /**
79
+ * Classify a source address string into one of the three trust tiers.
80
+ * @param remoteAddress - the raw value of `req.socket.remoteAddress`.
81
+ * @param lanCidrs - CIDR strings treated as trusted LAN space (IPv4).
82
+ * @returns the classification. IPv4-mapped IPv6 addresses are unwrapped.
83
+ */
84
+ export function classifySource(
85
+ remoteAddress: string | undefined,
86
+ lanCidrs: readonly string[] = DEFAULT_LAN_CIDR_STRINGS,
87
+ ): SourceClass {
88
+ const address = normalizeAddress(remoteAddress ?? '')
89
+ if (address === '') return 'internet'
90
+
91
+ // Loopback: IPv4 127/8, ::1, or mapped 127.x.
92
+ const ipv4 = parseIpv4(address)
93
+ if (ipv4 !== undefined) {
94
+ if (ipv4 >>> 24 === 127) return 'loopback'
95
+ for (const cidrText of lanCidrs) {
96
+ const cidr = parseCidr(cidrText)
97
+ if (cidr !== undefined && inCidr(ipv4, cidr)) return 'lan'
98
+ }
99
+ return 'internet'
100
+ }
101
+
102
+ if (address === '::1') return 'loopback'
103
+ // Link-local IPv6 fe80::/10.
104
+ if (address.toLowerCase().startsWith('fe80:')) return 'lan'
105
+ return 'internet'
106
+ }
107
+
108
+ /** Encode a byte buffer as URL-safe base64 without padding. */
109
+ function base64url(input: Buffer): string {
110
+ return input.toString('base64url')
111
+ }
112
+
113
+ /**
114
+ * Issue a signed session cookie value.
115
+ * @param secret - the HMAC signing secret (base64 string).
116
+ * @param expiresMs - epoch millis at which the session expires.
117
+ * @returns a `payload.signature` string suitable for the cookie value.
118
+ */
119
+ export function signCookie(secret: string, expiresMs: number): string {
120
+ const payload = base64url(Buffer.from(JSON.stringify({ exp: expiresMs })))
121
+ const sig = createHmac('sha256', secret).update(payload).digest('base64url')
122
+ return `${payload}.${sig}`
123
+ }
124
+
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 {
127
+ if (value === undefined) return false
128
+ const dot = value.indexOf('.')
129
+ if (dot === -1) return false
130
+ const payload = value.slice(0, dot)
131
+ const sig = value.slice(dot + 1)
132
+ const expected = createHmac('sha256', secret).update(payload).digest()
133
+ let actual: Buffer
134
+ try {
135
+ actual = Buffer.from(sig, 'base64url')
136
+ } catch {
137
+ return false
138
+ }
139
+ if (expected.length !== actual.length) return false
140
+ if (!timingSafeEqual(expected, actual)) return false
141
+ try {
142
+ const decoded = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { exp?: unknown }
143
+ return typeof decoded.exp === 'number' && decoded.exp > now
144
+ } catch {
145
+ return false
146
+ }
147
+ }
148
+
149
+ /** A token bucket limiter keyed by source address. */
150
+ export class RateLimiter {
151
+ private readonly buckets = new Map<string, { tokens: number; resetAt: number }>()
152
+ constructor(
153
+ private readonly maxTokens: number,
154
+ private readonly windowMs: number,
155
+ ) {}
156
+
157
+ /**
158
+ * Attempt to consume one token for `key`.
159
+ * @returns true when the attempt is allowed, false when the source is
160
+ * temporarily rate-limited.
161
+ */
162
+ allow(key: string): boolean {
163
+ const now = Date.now()
164
+ const bucket = this.buckets.get(key)
165
+ if (bucket === undefined || bucket.resetAt <= now) {
166
+ this.buckets.set(key, { tokens: this.maxTokens - 1, resetAt: now + this.windowMs })
167
+ return true
168
+ }
169
+ if (bucket.tokens > 0) {
170
+ bucket.tokens -= 1
171
+ return true
172
+ }
173
+ return false
174
+ }
175
+
176
+ /** Drop expired buckets to bound memory. */
177
+ prune(now: number = Date.now()): void {
178
+ for (const [key, bucket] of this.buckets) {
179
+ if (bucket.resetAt <= now) this.buckets.delete(key)
180
+ }
181
+ }
182
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * @riceawa/dsh-lan-gateway — browser half.
3
+ *
4
+ * Two jobs:
5
+ * 1. Insecure-origin UUID shim: the gateway can serve the GUI over plain HTTP
6
+ * on LAN addresses, where browsers lack `crypto.randomUUID()`. This bundle
7
+ * installs a getRandomValues-backed `randomUUID` on the Crypto prototype at
8
+ * module scope. With TLS enabled the origin is secure and the shim is a
9
+ * no-op.
10
+ * 2. Settings card: registers the LAN gateway card into the official
11
+ * Settings → Plugins page (`settings.plugin.item` slot), editing the
12
+ * `lan-gateway` settings namespace so port, CIDRs, auth, and TLS are
13
+ * adjustable from the GUI.
14
+ */
15
+
16
+ /** RFC 4122 v4 UUID from crypto.getRandomValues (available on insecure origins). */
17
+ function uuidFromRandomValues(): string {
18
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16))
19
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
20
+ view.setUint8(6, (view.getUint8(6) & 0x0f) | 0x40)
21
+ view.setUint8(8, (view.getUint8(8) & 0x3f) | 0x80)
22
+ const hex = Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
23
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
24
+ }
25
+
26
+ /**
27
+ * Install `randomUUID` on the browser Crypto prototype when the platform
28
+ * lacks it. Idempotent; re-checks every call.
29
+ * @returns `true` when the shim was installed by this call.
30
+ */
31
+ export function installRandomUuidShim(): boolean {
32
+ const cryptoObj = globalThis.crypto
33
+ if (cryptoObj === undefined) return false
34
+ if (typeof cryptoObj.randomUUID === 'function') return false
35
+ if (typeof cryptoObj.getRandomValues !== 'function') return false
36
+ try {
37
+ // Browsers expose crypto.randomUUID through the Crypto prototype; patching
38
+ // the prototype (not the instance) covers `crypto.randomUUID()` everywhere.
39
+ const proto = Object.getPrototypeOf(cryptoObj) as { randomUUID?: unknown } | null
40
+ if (proto !== null && typeof proto.randomUUID !== 'function') {
41
+ Object.defineProperty(proto, 'randomUUID', {
42
+ value: uuidFromRandomValues,
43
+ writable: true,
44
+ configurable: true,
45
+ })
46
+ return true
47
+ }
48
+ // Fallback: direct instance property (sandboxed/odd environments).
49
+ Object.defineProperty(cryptoObj, 'randomUUID', {
50
+ value: uuidFromRandomValues,
51
+ writable: true,
52
+ configurable: true,
53
+ })
54
+ return true
55
+ } catch {
56
+ return false
57
+ }
58
+ }
59
+
60
+ // Module scope: the shim is live as soon as this bundle is evaluated, before
61
+ // any RPC/session code runs. apply() re-runs it as a belt-and-braces re-check.
62
+ installRandomUuidShim()
63
+
64
+ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
65
+ import { LanGatewayCard } from './lan-gateway-card.tsx'
66
+
67
+ export const name = 'dsh-lan-gateway'
68
+
69
+ /** Only the slots service: the card itself is self-loading (ModLens-style). */
70
+ export const inject = ['slots']
71
+
72
+ /**
73
+ * Mount the settings card and the UUID shim.
74
+ * @param ctx - the browser plugin context.
75
+ */
76
+ export function apply(ctx: ClientContext): void {
77
+ installRandomUuidShim()
78
+
79
+ // The card rides the official Plugins → Configurable tab. Like ModLens, it
80
+ // registers with no inject face and fetches its own loopback config route,
81
+ // so it has no settings/locale/connection service dependencies.
82
+ ctx.slots.inject('settings.plugin.item', function* () {
83
+ yield ctx.slots.register({
84
+ name: 'settings.plugin.item',
85
+ id: 'lan-gateway',
86
+ order: 30,
87
+ }, LanGatewayCard)
88
+ })
89
+ }