@riceawa/dsh-lan-gateway 0.5.3 → 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 +47 -40
- package/lib/index.d.ts +13 -2
- package/lib/index.js +375 -74
- package/package.json +1 -1
- package/skills/lan-gateway.md +8 -4
- package/src/auth.ts +98 -26
- package/src/gateway.ts +238 -28
- package/src/index.ts +42 -13
- package/src/state.ts +77 -7
- package/src/tls.ts +33 -0
- package/src/upstream-session.ts +17 -2
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",
|
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
|
}
|
package/src/gateway.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The reverse-proxy gateway: a `node:http(s)` server bound to
|
|
3
|
-
*
|
|
2
|
+
* The reverse-proxy gateway: a `node:http(s)` server bound to the unspecified
|
|
3
|
+
* address (dual-stack, so IPv6 clients reach it too) that forwards every
|
|
4
|
+
* request to the loopback dsh web server.
|
|
4
5
|
*
|
|
5
6
|
* Security model (post-QVD / session-base):
|
|
6
7
|
* - Source is classified from `socket.remoteAddress` only (never
|
|
@@ -23,22 +24,25 @@
|
|
|
23
24
|
* authority-bound session cookie). The gateway therefore relays one shared
|
|
24
25
|
* upstream session acquired through the launch-token exchange and replays it
|
|
25
26
|
* on every forwarded request. See `upstream-session.ts`.
|
|
26
|
-
* - Sessions
|
|
27
|
-
*
|
|
28
|
-
*
|
|
27
|
+
* - Sessions are revocable two ways. Each carries a random id, so signing out
|
|
28
|
+
* retires exactly that session and the WebSockets it opened; and each
|
|
29
|
+
* carries a revocation epoch, so a password change or secret rotation kills
|
|
30
|
+
* every session at once — cookie, socket, and all.
|
|
29
31
|
*
|
|
30
32
|
* @module @riceawa/dsh-lan-gateway/gateway
|
|
31
33
|
*/
|
|
32
34
|
|
|
33
35
|
import http from 'node:http'
|
|
34
36
|
import https from 'node:https'
|
|
37
|
+
import { randomBytes } from 'node:crypto'
|
|
35
38
|
import type { Duplex } from 'node:stream'
|
|
36
39
|
import {
|
|
37
40
|
classifySource,
|
|
38
41
|
originMatchesHost,
|
|
39
42
|
RateLimiter,
|
|
40
43
|
signCookie,
|
|
41
|
-
|
|
44
|
+
verifySession,
|
|
45
|
+
type SessionClaims,
|
|
42
46
|
type SourceClass,
|
|
43
47
|
} from './auth.ts'
|
|
44
48
|
import {
|
|
@@ -49,12 +53,17 @@ import {
|
|
|
49
53
|
serveLoginGet,
|
|
50
54
|
type LoginPageOptions,
|
|
51
55
|
} from './login.ts'
|
|
52
|
-
import {
|
|
56
|
+
import {
|
|
57
|
+
isSessionRevoked,
|
|
58
|
+
revokeSession,
|
|
59
|
+
verifyPassword,
|
|
60
|
+
type GatewayState,
|
|
61
|
+
} from './state.ts'
|
|
53
62
|
import type { UpstreamSession } from './upstream-session.ts'
|
|
54
63
|
|
|
55
64
|
/** Configuration the gateway needs at listen time. */
|
|
56
65
|
export interface GatewayConfig {
|
|
57
|
-
/** Port to bind on
|
|
66
|
+
/** Port to bind on the unspecified address (dual-stack; see {@link LanGateway.listen}). */
|
|
58
67
|
gatewayPort: number
|
|
59
68
|
/** The loopback dsh web server port to forward to. */
|
|
60
69
|
dshPort: number
|
|
@@ -74,6 +83,12 @@ export interface GatewayConfig {
|
|
|
74
83
|
classifySource?: (req: http.IncomingMessage) => SourceClass
|
|
75
84
|
/** Optional shared upstream session relayed onto every forwarded request. */
|
|
76
85
|
upstreamSession?: UpstreamSession
|
|
86
|
+
/**
|
|
87
|
+
* Called after the gateway revokes a session itself (sign-out), so the plugin
|
|
88
|
+
* can persist a state the gateway changed on its own. The gateway has already
|
|
89
|
+
* installed it locally by then.
|
|
90
|
+
*/
|
|
91
|
+
onStateChange?: (state: GatewayState) => void
|
|
77
92
|
}
|
|
78
93
|
|
|
79
94
|
const DEFAULT_BODY_LIMIT_BYTES = 64 * 1024
|
|
@@ -83,15 +98,102 @@ const LOGIN_ATTEMPTS_WINDOW_MS = 60_000
|
|
|
83
98
|
/** Methods a browser never attaches a CSRF-meaningful body to; safe without an Origin. */
|
|
84
99
|
const READ_ONLY_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
|
|
85
100
|
|
|
101
|
+
/** The upstream browser-session cookie name prefix; the relay owns this namespace. */
|
|
102
|
+
const UPSTREAM_COOKIE_PREFIX = 'dsh-auth-'
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Headers a proxy must not forward in either direction (RFC 9110 §7.6.1), plus
|
|
106
|
+
* the non-standard proxy-connection.
|
|
107
|
+
*/
|
|
108
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
109
|
+
'connection',
|
|
110
|
+
'keep-alive',
|
|
111
|
+
'proxy-authenticate',
|
|
112
|
+
'proxy-authorization',
|
|
113
|
+
'proxy-connection',
|
|
114
|
+
'te',
|
|
115
|
+
'trailer',
|
|
116
|
+
'transfer-encoding',
|
|
117
|
+
'upgrade',
|
|
118
|
+
])
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Headers by which a client asserts where a request came from. The gateway
|
|
122
|
+
* classifies on `socket.remoteAddress` and never reads these, so relaying a
|
|
123
|
+
* caller's own values only hands the next hop a forgeable claim.
|
|
124
|
+
*/
|
|
125
|
+
const FORWARDING_HEADERS = [
|
|
126
|
+
'forwarded',
|
|
127
|
+
'x-forwarded-for',
|
|
128
|
+
'x-forwarded-host',
|
|
129
|
+
'x-forwarded-port',
|
|
130
|
+
'x-forwarded-proto',
|
|
131
|
+
'x-real-ip',
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
/** Whether a Cookie fragment names the upstream session cookie. */
|
|
135
|
+
function isUpstreamSessionPair(pair: string): boolean {
|
|
136
|
+
return pair.startsWith(UPSTREAM_COOKIE_PREFIX)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Drop every `dsh-auth-*` pair from a Cookie header, returning the remainder
|
|
141
|
+
* (possibly '').
|
|
142
|
+
*
|
|
143
|
+
* `attachUpstreamSession` appends the relay's session to the client's own
|
|
144
|
+
* cookie, and upstream reads the FIRST name match. A client that holds any
|
|
145
|
+
* `dsh-auth-<hash>` — typically one minted before dsh's signing secret was
|
|
146
|
+
* reset, so still present but no longer verifying — would therefore shadow the
|
|
147
|
+
* relay's session on every request. That draws a 401, the gateway reads the
|
|
148
|
+
* 401 as "upstream revoked our session" and discards it, the next request
|
|
149
|
+
* re-acquires, and the client's stale cookie shadows that one too: a loop that
|
|
150
|
+
* never converges. Stripping the namespace makes the relay's copy the only one.
|
|
151
|
+
*/
|
|
152
|
+
function withoutUpstreamSessionPairs(cookie: string): string {
|
|
153
|
+
return cookie
|
|
154
|
+
.split(';')
|
|
155
|
+
.map((pair) => pair.trim())
|
|
156
|
+
.filter((pair) => pair !== '' && !isUpstreamSessionPair(pair))
|
|
157
|
+
.join('; ')
|
|
158
|
+
}
|
|
159
|
+
|
|
86
160
|
/** Prefixes the gateway owns and must never relay to dsh. */
|
|
87
161
|
function isOwnedPath(pathname: string): boolean {
|
|
88
162
|
return pathname === '/lan-gateway' || pathname.startsWith('/lan-gateway/')
|
|
89
163
|
}
|
|
90
164
|
|
|
91
|
-
/**
|
|
165
|
+
/**
|
|
166
|
+
* The pathname a request is routed by: the one dsh's router resolves it to
|
|
167
|
+
* (WHATWG URL parsing, which strips the query and collapses dot segments),
|
|
168
|
+
* with trailing slashes then removed for the gateway's own surface tests.
|
|
169
|
+
*
|
|
170
|
+
* The decision paths below (owned prefix, login, logout) must use this rather
|
|
171
|
+
* than the raw request target. dsh normalizes before matching, so a raw-string
|
|
172
|
+
* test disagrees with it on `/foo/../lan-gateway/config` — that is not an owned
|
|
173
|
+
* path by string prefix, stays in the relay, and lands on the plugin's own
|
|
174
|
+
* config route once Host has been rewritten to loopback. Forwarding still
|
|
175
|
+
* relays the raw target: dsh applies the same normalization itself.
|
|
176
|
+
*
|
|
177
|
+
* WHATWG parsing does not drop a trailing slash, and neither does dsh's
|
|
178
|
+
* router, so `/__login/` is not the login page to either of them. The gateway
|
|
179
|
+
* recognizes its own surfaces there anyway: `/__logout/` must still sign out,
|
|
180
|
+
* and `/lan-gateway/config/` must be refused rather than relayed into dsh's
|
|
181
|
+
* single-page fallback. Blocking a trailing-slash spelling of an owned prefix
|
|
182
|
+
* errs toward refusing, which costs nothing — no upstream route lives under it.
|
|
183
|
+
*/
|
|
92
184
|
function pathOf(url: string): string {
|
|
93
|
-
|
|
94
|
-
|
|
185
|
+
try {
|
|
186
|
+
return new URL(url, 'http://gateway.invalid').pathname.replace(/\/+$/, '') || '/'
|
|
187
|
+
} catch {
|
|
188
|
+
// Unparseable here means unparseable for dsh too; the raw target routes
|
|
189
|
+
// nowhere and is relayed as-is.
|
|
190
|
+
return url
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** A fresh per-session id: 128 random bits, URL-safe. */
|
|
195
|
+
function newSessionId(): string {
|
|
196
|
+
return randomBytes(16).toString('base64url')
|
|
95
197
|
}
|
|
96
198
|
|
|
97
199
|
/**
|
|
@@ -104,8 +206,14 @@ export class LanGateway {
|
|
|
104
206
|
private readonly loginLimiter = new RateLimiter(LOGIN_ATTEMPTS_LIMIT, LOGIN_ATTEMPTS_WINDOW_MS)
|
|
105
207
|
private state: GatewayState
|
|
106
208
|
private disposed = false
|
|
107
|
-
/**
|
|
108
|
-
|
|
209
|
+
/**
|
|
210
|
+
* Established WebSockets (upgraded client sockets), each keyed by the session
|
|
211
|
+
* that opened it. A socket outlives the request that authenticated it, so it
|
|
212
|
+
* has to be closable by session: on an epoch bump every socket dies, and on
|
|
213
|
+
* sign-out only that session's. The value is undefined for a cookie minted
|
|
214
|
+
* before per-session ids existed, which only a wholesale revocation reaches.
|
|
215
|
+
*/
|
|
216
|
+
private readonly activeDuplexes = new Map<Duplex, string | undefined>()
|
|
109
217
|
|
|
110
218
|
constructor(
|
|
111
219
|
private readonly config: GatewayConfig,
|
|
@@ -131,7 +239,14 @@ export class LanGateway {
|
|
|
131
239
|
this.state = state
|
|
132
240
|
}
|
|
133
241
|
|
|
134
|
-
/**
|
|
242
|
+
/**
|
|
243
|
+
* Start listening on the configured port. The listener is dual-stack: with
|
|
244
|
+
* no host given, node binds the unspecified IPv6 address `::` — which also
|
|
245
|
+
* accepts IPv4 clients, arriving as `::ffff:a.b.c.d` for the classifier to
|
|
246
|
+
* unwrap — when the host has IPv6, and falls back to `0.0.0.0` when it does
|
|
247
|
+
* not. Binding IPv4 only used to leave every IPv6 client (including `::1`)
|
|
248
|
+
* unable to reach a gateway that classifies them.
|
|
249
|
+
*/
|
|
135
250
|
async listen(): Promise<void> {
|
|
136
251
|
return new Promise((resolve, reject) => {
|
|
137
252
|
const onError = (err: Error): void => {
|
|
@@ -144,10 +259,18 @@ export class LanGateway {
|
|
|
144
259
|
}
|
|
145
260
|
this.server.once('error', onError)
|
|
146
261
|
this.server.once('listening', onListening)
|
|
147
|
-
this.server.listen(this.config.gatewayPort
|
|
262
|
+
this.server.listen(this.config.gatewayPort)
|
|
148
263
|
})
|
|
149
264
|
}
|
|
150
265
|
|
|
266
|
+
/** The address actually bound, for logs and status (never a claim about it). */
|
|
267
|
+
boundAddress(): string {
|
|
268
|
+
const address = this.server.address()
|
|
269
|
+
if (address === null || typeof address === 'string') return `port ${this.config.gatewayPort}`
|
|
270
|
+
const host = address.family === 'IPv6' ? `[${address.address}]` : address.address
|
|
271
|
+
return `${host}:${address.port}`
|
|
272
|
+
}
|
|
273
|
+
|
|
151
274
|
/** Close the server, drop upgraded sockets, and stop accepting connections. */
|
|
152
275
|
async close(): Promise<void> {
|
|
153
276
|
if (this.disposed) return
|
|
@@ -160,14 +283,23 @@ export class LanGateway {
|
|
|
160
283
|
}
|
|
161
284
|
|
|
162
285
|
private destroyActiveDuplexes(): void {
|
|
163
|
-
for (const socket of this.activeDuplexes) {
|
|
286
|
+
for (const socket of this.activeDuplexes.keys()) {
|
|
164
287
|
socket.destroy()
|
|
165
288
|
}
|
|
166
289
|
this.activeDuplexes.clear()
|
|
167
290
|
}
|
|
168
291
|
|
|
169
|
-
|
|
170
|
-
|
|
292
|
+
/** Close the sockets one session opened, so signing out ends its live streams too. */
|
|
293
|
+
private destroyDuplexesFor(sid: string): void {
|
|
294
|
+
for (const [socket, owner] of this.activeDuplexes) {
|
|
295
|
+
if (owner !== sid) continue
|
|
296
|
+
this.activeDuplexes.delete(socket)
|
|
297
|
+
socket.destroy()
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private trackDuplex(socket: Duplex, sid: string | undefined): void {
|
|
302
|
+
this.activeDuplexes.set(socket, sid)
|
|
171
303
|
socket.on('close', () => {
|
|
172
304
|
this.activeDuplexes.delete(socket)
|
|
173
305
|
})
|
|
@@ -192,15 +324,27 @@ export class LanGateway {
|
|
|
192
324
|
return undefined
|
|
193
325
|
}
|
|
194
326
|
|
|
195
|
-
/**
|
|
196
|
-
|
|
327
|
+
/**
|
|
328
|
+
* The session a request carries, or undefined when it presents none, presents
|
|
329
|
+
* one that no longer verifies under the current epoch, or presents one whose
|
|
330
|
+
* id has been signed out.
|
|
331
|
+
*/
|
|
332
|
+
private session(req: http.IncomingMessage): SessionClaims | undefined {
|
|
197
333
|
const cookie = this.sessionCookie(req)
|
|
198
|
-
|
|
334
|
+
if (cookie === undefined) return undefined
|
|
335
|
+
const claims = verifySession(
|
|
199
336
|
this.state.cookieSecret,
|
|
200
337
|
cookie,
|
|
201
338
|
Date.now(),
|
|
202
339
|
this.state.sessionEpoch,
|
|
203
340
|
)
|
|
341
|
+
if (claims === undefined) return undefined
|
|
342
|
+
return isSessionRevoked(this.state, claims.sid) ? undefined : claims
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Whether a request carries a session valid under the current epoch. */
|
|
346
|
+
private authorized(req: http.IncomingMessage): boolean {
|
|
347
|
+
return this.session(req) !== undefined
|
|
204
348
|
}
|
|
205
349
|
|
|
206
350
|
/** Whether this source must present a gateway session (default: everyone). */
|
|
@@ -312,7 +456,7 @@ export class LanGateway {
|
|
|
312
456
|
return
|
|
313
457
|
}
|
|
314
458
|
|
|
315
|
-
void readBody(req, DEFAULT_BODY_LIMIT_BYTES, res).then((body) => {
|
|
459
|
+
void readBody(req, DEFAULT_BODY_LIMIT_BYTES, res).then(async (body) => {
|
|
316
460
|
if (body === undefined) return // response already sent (413/400)
|
|
317
461
|
let password: string | undefined
|
|
318
462
|
try {
|
|
@@ -321,23 +465,47 @@ export class LanGateway {
|
|
|
321
465
|
} catch {
|
|
322
466
|
password = undefined
|
|
323
467
|
}
|
|
324
|
-
|
|
468
|
+
const accepted = password !== undefined && await verifyPassword(this.state, password)
|
|
469
|
+
if (!accepted) {
|
|
325
470
|
this.serveLoginError(res, 'Incorrect password.')
|
|
326
471
|
return
|
|
327
472
|
}
|
|
328
473
|
const maxAgeSeconds = this.config.cookieMaxAgeDays * 86_400
|
|
329
474
|
const expiresMs = Date.now() + maxAgeSeconds * 1000
|
|
330
|
-
|
|
475
|
+
// Every session gets its own id so signing out can retire this one alone.
|
|
476
|
+
const cookie = signCookie(
|
|
477
|
+
this.state.cookieSecret,
|
|
478
|
+
expiresMs,
|
|
479
|
+
this.state.sessionEpoch,
|
|
480
|
+
newSessionId(),
|
|
481
|
+
)
|
|
331
482
|
res.writeHead(302, {
|
|
332
483
|
location: '/',
|
|
333
484
|
...this.securityHeaders(),
|
|
334
485
|
'set-cookie': [this.sessionSetCookie(cookie, maxAgeSeconds)],
|
|
335
486
|
})
|
|
336
487
|
res.end()
|
|
488
|
+
}).catch(() => {
|
|
489
|
+
// The body reader reports its own failures through the response; this
|
|
490
|
+
// catches the async verification path so it cannot become an unhandled
|
|
491
|
+
// rejection.
|
|
492
|
+
if (!res.headersSent) {
|
|
493
|
+
res.writeHead(500, this.securityHeaders())
|
|
494
|
+
res.end('login failed')
|
|
495
|
+
}
|
|
337
496
|
})
|
|
338
497
|
}
|
|
339
498
|
|
|
340
|
-
/**
|
|
499
|
+
/**
|
|
500
|
+
* POST /__logout: revoke this session and clear the cookie.
|
|
501
|
+
*
|
|
502
|
+
* The session is stateless, so clearing the cookie only stops the browser
|
|
503
|
+
* that ran the sign-out; a copy of the same value held anywhere else would
|
|
504
|
+
* keep working until it expired. Revoking the id in the cookie retires that
|
|
505
|
+
* one session for good, and leaves the account's other sessions — other
|
|
506
|
+
* devices, other browsers — alone. Bumping the session epoch here would be
|
|
507
|
+
* the blunter instrument: it signs out every session there is.
|
|
508
|
+
*/
|
|
341
509
|
private handleLogout(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
342
510
|
if (req.method !== 'POST') {
|
|
343
511
|
res.writeHead(405, { allow: 'POST' })
|
|
@@ -350,6 +518,12 @@ export class LanGateway {
|
|
|
350
518
|
res.end('forbidden')
|
|
351
519
|
return
|
|
352
520
|
}
|
|
521
|
+
const claims = this.session(req)
|
|
522
|
+
if (claims?.sid !== undefined) {
|
|
523
|
+
this.state = revokeSession(this.state, claims.sid, claims.exp)
|
|
524
|
+
this.config.onStateChange?.(this.state)
|
|
525
|
+
this.destroyDuplexesFor(claims.sid)
|
|
526
|
+
}
|
|
353
527
|
res.writeHead(302, {
|
|
354
528
|
location: '/',
|
|
355
529
|
...this.securityHeaders(),
|
|
@@ -371,6 +545,14 @@ export class LanGateway {
|
|
|
371
545
|
delete headers.connection
|
|
372
546
|
delete headers.upgrade
|
|
373
547
|
}
|
|
548
|
+
// A caller's own forwarding claims are not ours to relay.
|
|
549
|
+
for (const name of FORWARDING_HEADERS) delete headers[name]
|
|
550
|
+
// The relay is the only authority on the upstream session cookie.
|
|
551
|
+
if (typeof headers.cookie === 'string') {
|
|
552
|
+
const kept = withoutUpstreamSessionPairs(headers.cookie)
|
|
553
|
+
if (kept === '') delete headers.cookie
|
|
554
|
+
else headers.cookie = kept
|
|
555
|
+
}
|
|
374
556
|
return headers
|
|
375
557
|
}
|
|
376
558
|
|
|
@@ -387,6 +569,31 @@ export class LanGateway {
|
|
|
387
569
|
return true
|
|
388
570
|
}
|
|
389
571
|
|
|
572
|
+
/**
|
|
573
|
+
* The headers to send back to the client: hop-by-hop headers dropped, and
|
|
574
|
+
* the upstream session cookie withheld. Upstream's one cookie-minting route
|
|
575
|
+
* is the launch-token exchange at `/`, so a client that already holds a
|
|
576
|
+
* gateway session could otherwise post the token through the gateway and
|
|
577
|
+
* walk away with a durable upstream credential the relay exists to keep on
|
|
578
|
+
* this side. Cookies from other routes (plugins) still pass through.
|
|
579
|
+
*/
|
|
580
|
+
private downstreamHeaders(upstream: http.IncomingHttpHeaders): http.OutgoingHttpHeaders {
|
|
581
|
+
const headers: http.OutgoingHttpHeaders = {}
|
|
582
|
+
for (const [key, value] of Object.entries(upstream)) {
|
|
583
|
+
if (value === undefined) continue
|
|
584
|
+
const lower = key.toLowerCase()
|
|
585
|
+
if (HOP_BY_HOP_HEADERS.has(lower)) continue
|
|
586
|
+
if (lower === 'set-cookie') {
|
|
587
|
+
const list = (Array.isArray(value) ? value : [value])
|
|
588
|
+
.filter((entry) => !isUpstreamSessionPair(entry.trim()))
|
|
589
|
+
if (list.length > 0) headers[key] = list
|
|
590
|
+
continue
|
|
591
|
+
}
|
|
592
|
+
headers[key] = value
|
|
593
|
+
}
|
|
594
|
+
return headers
|
|
595
|
+
}
|
|
596
|
+
|
|
390
597
|
/** Forward an HTTP request to dsh, replaying the shared upstream session. */
|
|
391
598
|
private async relayHttp(req: http.IncomingMessage, res: http.ServerResponse, url: string): Promise<void> {
|
|
392
599
|
const session = this.config.upstreamSession
|
|
@@ -407,7 +614,7 @@ export class LanGateway {
|
|
|
407
614
|
if (attached && session !== undefined && proxyRes.statusCode === 401) {
|
|
408
615
|
session.invalidate()
|
|
409
616
|
}
|
|
410
|
-
res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers)
|
|
617
|
+
res.writeHead(proxyRes.statusCode ?? 502, this.downstreamHeaders(proxyRes.headers))
|
|
411
618
|
proxyRes.pipe(res)
|
|
412
619
|
})
|
|
413
620
|
proxyReq.on('error', () => {
|
|
@@ -436,7 +643,10 @@ export class LanGateway {
|
|
|
436
643
|
return
|
|
437
644
|
}
|
|
438
645
|
|
|
439
|
-
|
|
646
|
+
// The session is read once: the socket this upgrade ends up holding stays
|
|
647
|
+
// attributable to it, so signing that session out can close the socket.
|
|
648
|
+
const claims = this.session(req)
|
|
649
|
+
if (this.requiresLogin(source) && claims === undefined) {
|
|
440
650
|
refuse(401)
|
|
441
651
|
return
|
|
442
652
|
}
|
|
@@ -462,7 +672,7 @@ export class LanGateway {
|
|
|
462
672
|
headers,
|
|
463
673
|
})
|
|
464
674
|
proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
|
|
465
|
-
this.trackDuplex(socket)
|
|
675
|
+
this.trackDuplex(socket, claims?.sid)
|
|
466
676
|
// node's http client has already consumed the 101 response headers, so
|
|
467
677
|
// reconstruct them on the client socket before splicing.
|
|
468
678
|
const statusLine = `HTTP/1.1 ${proxyRes.statusCode ?? 101} ${proxyRes.statusMessage ?? 'Switching Protocols'}\r\n`
|