@riceawa/dsh-lan-gateway 0.5.4 → 0.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -6
- package/lib/client.js +157 -125
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +44 -5
- package/lib/index.js +1379 -985
- package/package.json +1 -1
- package/src/auth.ts +7 -14
- package/src/client/lan-gateway-card.tsx +61 -127
- package/src/config-fields.ts +137 -0
- package/src/gateway.ts +203 -240
- package/src/index.ts +272 -118
- package/src/login.ts +1 -17
- package/src/request-policy.ts +315 -0
- package/src/state.ts +16 -5
- package/src/tls.ts +23 -0
- package/src/upstream-session.ts +24 -8
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every request decision the gateway makes, as pure functions over headers,
|
|
3
|
+
* paths and config — the same treatment `auth.ts` already gives
|
|
4
|
+
* `classifySource` / `signCookie` / `originMatchesHost`. `LanGateway` keeps the
|
|
5
|
+
* `http.Server`, the socket bookkeeping and the relay; who may pass, which path
|
|
6
|
+
* the gateway owns, and what the forwarded headers look like are decided here,
|
|
7
|
+
* where a test can reach them with a literal object instead of a live socket.
|
|
8
|
+
*
|
|
9
|
+
* Nothing here reads a clock, a socket or a config file.
|
|
10
|
+
*
|
|
11
|
+
* @module @riceawa/dsh-lan-gateway/request-policy
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { IncomingHttpHeaders, OutgoingHttpHeaders } from 'node:http'
|
|
15
|
+
import { originMatchesHost, type SourceClass } from './auth.ts'
|
|
16
|
+
import { isUpstreamCookiePair, isUpstreamSessionCookie } from './upstream-session.ts'
|
|
17
|
+
|
|
18
|
+
/** The request facts a policy decision reads. */
|
|
19
|
+
export interface RequestHead {
|
|
20
|
+
/**
|
|
21
|
+
* Optional *and* explicitly undefined-able, matching how Node declares
|
|
22
|
+
* `IncomingMessage.method`. Under `exactOptionalPropertyTypes` those are two
|
|
23
|
+
* different types, and the shorter `method?: string` would reject a plain
|
|
24
|
+
* `IncomingMessage` at every call site.
|
|
25
|
+
*/
|
|
26
|
+
method?: string | undefined
|
|
27
|
+
headers: IncomingHttpHeaders
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Methods a browser never attaches a CSRF-meaningful body to; safe without an Origin. */
|
|
31
|
+
export const READ_ONLY_METHODS: ReadonlySet<string> = new Set(['GET', 'HEAD', 'OPTIONS'])
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Headers a proxy must not forward in either direction (RFC 9110 §7.6.1), plus
|
|
35
|
+
* the non-standard proxy-connection.
|
|
36
|
+
*/
|
|
37
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
38
|
+
'connection',
|
|
39
|
+
'keep-alive',
|
|
40
|
+
'proxy-authenticate',
|
|
41
|
+
'proxy-authorization',
|
|
42
|
+
'proxy-connection',
|
|
43
|
+
'te',
|
|
44
|
+
'trailer',
|
|
45
|
+
'transfer-encoding',
|
|
46
|
+
'upgrade',
|
|
47
|
+
])
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Hop-by-hop headers a successful upgrade must still carry: 101 is exactly the
|
|
51
|
+
* exchange that negotiates Connection/Upgrade, so they survive there and
|
|
52
|
+
* nowhere else.
|
|
53
|
+
*/
|
|
54
|
+
const UPGRADE_HANDSHAKE_HEADERS = new Set(['connection', 'upgrade'])
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Headers by which a client asserts where a request came from. The gateway
|
|
58
|
+
* classifies on `socket.remoteAddress` and never reads these, so relaying a
|
|
59
|
+
* caller's own values only hands the next hop a forgeable claim.
|
|
60
|
+
*/
|
|
61
|
+
const FORWARDING_HEADERS = [
|
|
62
|
+
'forwarded',
|
|
63
|
+
'x-forwarded-for',
|
|
64
|
+
'x-forwarded-host',
|
|
65
|
+
'x-forwarded-port',
|
|
66
|
+
'x-forwarded-proto',
|
|
67
|
+
'x-real-ip',
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
/** Prefixes the gateway owns and must never relay to dsh. */
|
|
71
|
+
export function isOwnedPath(pathname: string): boolean {
|
|
72
|
+
return pathname === '/lan-gateway' || pathname.startsWith('/lan-gateway/')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The pathname a request is routed by: the one dsh's router resolves it to
|
|
77
|
+
* (WHATWG URL parsing, which strips the query and collapses dot segments),
|
|
78
|
+
* with trailing slashes then removed for the gateway's own surface tests.
|
|
79
|
+
*
|
|
80
|
+
* The decision paths below (owned prefix, login, logout) must use this rather
|
|
81
|
+
* than the raw request target. dsh normalizes before matching, so a raw-string
|
|
82
|
+
* test disagrees with it on `/foo/../lan-gateway/config` — that is not an owned
|
|
83
|
+
* path by string prefix, stays in the relay, and lands on the plugin's own
|
|
84
|
+
* config route once Host has been rewritten to loopback. Forwarding still
|
|
85
|
+
* relays the raw target: dsh applies the same normalization itself.
|
|
86
|
+
*
|
|
87
|
+
* WHATWG parsing does not drop a trailing slash, and neither does dsh's
|
|
88
|
+
* router, so `/__login/` is not the login page to either of them. The gateway
|
|
89
|
+
* recognizes its own surfaces there anyway: `/__logout/` must still sign out,
|
|
90
|
+
* and `/lan-gateway/config/` must be refused rather than relayed into dsh's
|
|
91
|
+
* single-page fallback. Blocking a trailing-slash spelling of an owned prefix
|
|
92
|
+
* errs toward refusing, which costs nothing — no upstream route lives under it.
|
|
93
|
+
*/
|
|
94
|
+
export function pathOf(url: string): string {
|
|
95
|
+
try {
|
|
96
|
+
return new URL(url, 'http://gateway.invalid').pathname.replace(/\/+$/, '') || '/'
|
|
97
|
+
} catch {
|
|
98
|
+
// Unparseable here means unparseable for dsh too; the raw target routes
|
|
99
|
+
// nowhere and is relayed as-is.
|
|
100
|
+
return url
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Whether `hostname` is loopback (127/8, localhost, ::1).
|
|
106
|
+
*
|
|
107
|
+
* This validates a URL *hostname* — the loopback fence on the gateway's own
|
|
108
|
+
* config route, where the input is the browser's Host header — so it accepts
|
|
109
|
+
* the spellings a URL parser produces, `[::1]` included. `classifySource` in
|
|
110
|
+
* `auth.ts` answers a different question about a different input (a socket
|
|
111
|
+
* address, unwrapped from its `::ffff:` mapping, and including LAN space); the
|
|
112
|
+
* two are related but not interchangeable, and neither should be rewritten in
|
|
113
|
+
* terms of the other without moving its input domain too.
|
|
114
|
+
*/
|
|
115
|
+
export function isLoopbackHost(hostname: string): boolean {
|
|
116
|
+
if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true
|
|
117
|
+
const parts = hostname.split('.')
|
|
118
|
+
return (
|
|
119
|
+
parts.length === 4
|
|
120
|
+
&& parts[0] === '127'
|
|
121
|
+
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Whether this source must present a gateway session (default: everyone). */
|
|
126
|
+
export function requiresLogin(source: SourceClass, lanPasswordless: boolean): boolean {
|
|
127
|
+
return !(lanPasswordless && source !== 'internet')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Parse the session cookie out of a Cookie header. */
|
|
131
|
+
export function sessionCookie(headers: IncomingHttpHeaders, cookieName: string): string | undefined {
|
|
132
|
+
const header = headers.cookie
|
|
133
|
+
if (typeof header !== 'string') return undefined
|
|
134
|
+
for (const part of header.split(';')) {
|
|
135
|
+
const trimmed = part.trim()
|
|
136
|
+
if (trimmed.startsWith(`${cookieName}=`)) {
|
|
137
|
+
return trimmed.slice(cookieName.length + 1)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return undefined
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The cross-site test shared by every gateway-owned entry point, applied before
|
|
145
|
+
* any Host/Origin rewriting: an explicit cross-site fetch, or an Origin that
|
|
146
|
+
* does not name the authority the browser actually used.
|
|
147
|
+
*
|
|
148
|
+
* Only claims a cross-site page cannot suppress are read, which is what makes
|
|
149
|
+
* this usable on the login POST too (see {@link loginOriginAllowed}).
|
|
150
|
+
*/
|
|
151
|
+
export function isCrossSiteRequest(headers: IncomingHttpHeaders): boolean {
|
|
152
|
+
if (headers['sec-fetch-site'] === 'cross-site') return true
|
|
153
|
+
const origin = headers.origin
|
|
154
|
+
if (origin !== undefined && !originMatchesHost(origin, headers.host)) return true
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The gateway's own cross-site gate, shared by HTTP and WebSocket upgrades and
|
|
160
|
+
* applied before any Host/Origin rewriting. Browsers attach Origin to
|
|
161
|
+
* state-changing requests and to every WebSocket handshake; reads without an
|
|
162
|
+
* Origin (navigations, non-browser clients holding a session) stay allowed.
|
|
163
|
+
*/
|
|
164
|
+
export function sameSiteAllowed(req: RequestHead, upgrade: boolean): boolean {
|
|
165
|
+
if (isCrossSiteRequest(req.headers)) return false
|
|
166
|
+
const origin = req.headers.origin
|
|
167
|
+
if (upgrade) return origin !== undefined
|
|
168
|
+
if (!READ_ONLY_METHODS.has(req.method ?? 'GET')) return origin !== undefined
|
|
169
|
+
return true
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The fence on the login POST. Issuing a session is as much a state change as
|
|
174
|
+
* retiring one — and a cross-site form post burns the victim's source address
|
|
175
|
+
* through the login rate limiter — so the login route runs the same cross-site
|
|
176
|
+
* test as everything else.
|
|
177
|
+
*
|
|
178
|
+
* It deliberately stops short of {@link sameSiteAllowed}'s "a state-changing
|
|
179
|
+
* request must carry an Origin" rule: a browser always sends an Origin on a
|
|
180
|
+
* form POST, but curl, the dsh CLI and other non-browser clients legitimately
|
|
181
|
+
* do not, and requiring one would lock them out of signing in. What remains is
|
|
182
|
+
* what a cross-site page cannot forge or strip: `sec-fetch-site`, and an Origin
|
|
183
|
+
* that disagrees with the Host the request names.
|
|
184
|
+
*/
|
|
185
|
+
export function loginOriginAllowed(headers: IncomingHttpHeaders): boolean {
|
|
186
|
+
return !isCrossSiteRequest(headers)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Drop every `dsh-auth-*` pair from a Cookie header, returning the remainder
|
|
191
|
+
* (possibly '').
|
|
192
|
+
*
|
|
193
|
+
* The relay's session is appended to the client's own cookie, and upstream
|
|
194
|
+
* reads the FIRST name match. A client that holds any `dsh-auth-<hash>` —
|
|
195
|
+
* typically one minted before dsh's signing secret was reset, so still present
|
|
196
|
+
* but no longer verifying — would therefore shadow the relay's session on every
|
|
197
|
+
* request. That draws a 401, the gateway reads the 401 as "upstream revoked our
|
|
198
|
+
* session" and discards it, the next request re-acquires, and the client's
|
|
199
|
+
* stale cookie shadows that one too: a loop that never converges. Stripping the
|
|
200
|
+
* namespace makes the relay's copy the only one.
|
|
201
|
+
*
|
|
202
|
+
* This filters the namespace; `isUpstreamSessionCookie` decides which cookie may
|
|
203
|
+
* be *accepted* from upstream. The two are deliberately different rules.
|
|
204
|
+
*/
|
|
205
|
+
export function withoutUpstreamSessionPairs(cookie: string): string {
|
|
206
|
+
return cookie
|
|
207
|
+
.split(';')
|
|
208
|
+
.map((pair) => pair.trim())
|
|
209
|
+
.filter((pair) => pair !== '' && !isUpstreamCookiePair(pair))
|
|
210
|
+
.join('; ')
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** How the outbound request headers are built. */
|
|
214
|
+
export interface UpstreamRequestOptions {
|
|
215
|
+
/** The loopback dsh port the Host/Origin rewrite names. */
|
|
216
|
+
dshPort: number
|
|
217
|
+
/** Keep the WebSocket handshake's Connection/Upgrade headers. */
|
|
218
|
+
keepUpgrade: boolean
|
|
219
|
+
/** The shared upstream session to ride, when the relay holds one. */
|
|
220
|
+
upstreamCookie?: string | undefined
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Build the outbound headers for one relayed request: rewrite Host/Origin to
|
|
225
|
+
* the loopback upstream, drop hop-by-hop and caller-supplied forwarding
|
|
226
|
+
* headers, clear the upstream cookie namespace the relay owns, and attach the
|
|
227
|
+
* relayed session.
|
|
228
|
+
*/
|
|
229
|
+
export function upstreamRequestHeaders(
|
|
230
|
+
headers: IncomingHttpHeaders,
|
|
231
|
+
options: UpstreamRequestOptions,
|
|
232
|
+
): OutgoingHttpHeaders {
|
|
233
|
+
const out: OutgoingHttpHeaders = { ...headers }
|
|
234
|
+
out.host = `127.0.0.1:${options.dshPort}`
|
|
235
|
+
if (typeof out.origin === 'string') {
|
|
236
|
+
out.origin = `http://127.0.0.1:${options.dshPort}`
|
|
237
|
+
}
|
|
238
|
+
delete out['proxy-connection']
|
|
239
|
+
if (!options.keepUpgrade) {
|
|
240
|
+
delete out.connection
|
|
241
|
+
delete out.upgrade
|
|
242
|
+
}
|
|
243
|
+
for (const name of FORWARDING_HEADERS) delete out[name]
|
|
244
|
+
if (typeof out.cookie === 'string') {
|
|
245
|
+
const kept = withoutUpstreamSessionPairs(out.cookie)
|
|
246
|
+
if (kept === '') delete out.cookie
|
|
247
|
+
else out.cookie = kept
|
|
248
|
+
}
|
|
249
|
+
const relayed = options.upstreamCookie
|
|
250
|
+
if (relayed !== undefined && relayed !== '') {
|
|
251
|
+
const existing = out.cookie
|
|
252
|
+
out.cookie = typeof existing === 'string' && existing !== ''
|
|
253
|
+
? `${existing}; ${relayed}`
|
|
254
|
+
: relayed
|
|
255
|
+
}
|
|
256
|
+
return out
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Filter one direction's worth of headers through the same cookie rule, so the
|
|
261
|
+
* HTTP and WebSocket branches cannot drift apart on it.
|
|
262
|
+
*/
|
|
263
|
+
function stripUpstreamCookies(entry: string): boolean {
|
|
264
|
+
return !isUpstreamSessionCookie(entry.trim())
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The headers to send back to the client: hop-by-hop headers dropped, and the
|
|
269
|
+
* upstream session cookie withheld. Upstream's one cookie-minting route is the
|
|
270
|
+
* launch-token exchange at `/`, so a client that already holds a gateway session
|
|
271
|
+
* could otherwise post the token through the gateway and walk away with a
|
|
272
|
+
* durable upstream credential the relay exists to keep on this side. Cookies
|
|
273
|
+
* from other routes (plugins) still pass through.
|
|
274
|
+
*/
|
|
275
|
+
export function downstreamResponseHeaders(upstream: IncomingHttpHeaders): OutgoingHttpHeaders {
|
|
276
|
+
const headers: OutgoingHttpHeaders = {}
|
|
277
|
+
for (const [key, value] of Object.entries(upstream)) {
|
|
278
|
+
if (value === undefined) continue
|
|
279
|
+
const lower = key.toLowerCase()
|
|
280
|
+
if (HOP_BY_HOP_HEADERS.has(lower)) continue
|
|
281
|
+
if (lower === 'set-cookie') {
|
|
282
|
+
const list = (Array.isArray(value) ? value : [value]).filter(stripUpstreamCookies)
|
|
283
|
+
if (list.length > 0) headers[key] = list
|
|
284
|
+
continue
|
|
285
|
+
}
|
|
286
|
+
headers[key] = value
|
|
287
|
+
}
|
|
288
|
+
return headers
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The headers of a 101 Switching Protocols response, replayed to the client on
|
|
293
|
+
* the socket the gateway just spliced.
|
|
294
|
+
*
|
|
295
|
+
* {@link downstreamResponseHeaders} cannot be reused verbatim here: a successful
|
|
296
|
+
* upgrade has to keep Connection/Upgrade, which are hop-by-hop on every other
|
|
297
|
+
* response. The cookie rule is not relaxed with them — the relay's session is
|
|
298
|
+
* withheld on this path too, so upstream cannot hand a client a durable
|
|
299
|
+
* credential by attaching it to the handshake.
|
|
300
|
+
*/
|
|
301
|
+
export function upgradeResponseHeaders(upstream: IncomingHttpHeaders): OutgoingHttpHeaders {
|
|
302
|
+
const headers: OutgoingHttpHeaders = {}
|
|
303
|
+
for (const [key, value] of Object.entries(upstream)) {
|
|
304
|
+
if (value === undefined) continue
|
|
305
|
+
const lower = key.toLowerCase()
|
|
306
|
+
if (HOP_BY_HOP_HEADERS.has(lower) && !UPGRADE_HANDSHAKE_HEADERS.has(lower)) continue
|
|
307
|
+
if (lower === 'set-cookie') {
|
|
308
|
+
const list = (Array.isArray(value) ? value : [value]).filter(stripUpstreamCookies)
|
|
309
|
+
if (list.length > 0) headers[key] = list
|
|
310
|
+
continue
|
|
311
|
+
}
|
|
312
|
+
headers[key] = value
|
|
313
|
+
}
|
|
314
|
+
return headers
|
|
315
|
+
}
|
package/src/state.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
11
11
|
import { join } from 'node:path'
|
|
12
|
-
import { randomBytes, scrypt,
|
|
12
|
+
import { randomBytes, scrypt, timingSafeEqual } from 'node:crypto'
|
|
13
13
|
import { homedir } from 'node:os'
|
|
14
14
|
|
|
15
15
|
/** The state directory: `~/.dsh/lan-gateway`. */
|
|
@@ -84,8 +84,13 @@ export async function verifyPassword(state: GatewayState, password: string): Pro
|
|
|
84
84
|
* Set (or clear) the password, re-salted on every write. Both operations bump
|
|
85
85
|
* the session epoch so every cookie issued under the previous epoch dies — a
|
|
86
86
|
* password change must invalidate sessions the old password authorized.
|
|
87
|
+
*
|
|
88
|
+
* Deriving the key is asynchronous for the same reason
|
|
89
|
+
* {@link verifyPassword} is: `scryptSync` occupies the event loop for tens of
|
|
90
|
+
* milliseconds, and that loop is shared with the dsh process the gateway
|
|
91
|
+
* forwards to. Every caller is already async.
|
|
87
92
|
*/
|
|
88
|
-
export function setPassword(state: GatewayState, password: string | undefined): GatewayState {
|
|
93
|
+
export async function setPassword(state: GatewayState, password: string | undefined): Promise<GatewayState> {
|
|
89
94
|
// The new epoch invalidates every cookie on its own account, so the list of
|
|
90
95
|
// individually revoked sessions has nothing left to say: drop it rather than
|
|
91
96
|
// carry entries that can never match again.
|
|
@@ -95,7 +100,7 @@ export function setPassword(state: GatewayState, password: string | undefined):
|
|
|
95
100
|
}
|
|
96
101
|
if (password === undefined) return base
|
|
97
102
|
const salt = randomBytes(16)
|
|
98
|
-
const hash =
|
|
103
|
+
const hash = await deriveKey(password, salt, 64)
|
|
99
104
|
return {
|
|
100
105
|
...base,
|
|
101
106
|
password: { hash: hash.toString('hex'), salt: salt.toString('hex') },
|
|
@@ -107,9 +112,15 @@ export function setPassword(state: GatewayState, password: string | undefined):
|
|
|
107
112
|
* @param expiresMs - the revoked cookie's own expiry. Past it the cookie is
|
|
108
113
|
* rejected on its own account, so the entry is no longer needed; dropping
|
|
109
114
|
* expired entries here is what keeps the list bounded.
|
|
115
|
+
* @param now - epoch millis to judge the existing entries against, injected so
|
|
116
|
+
* a test can age the list without fake timers.
|
|
110
117
|
*/
|
|
111
|
-
export function revokeSession(
|
|
112
|
-
|
|
118
|
+
export function revokeSession(
|
|
119
|
+
state: GatewayState,
|
|
120
|
+
sid: string,
|
|
121
|
+
expiresMs: number,
|
|
122
|
+
now: number = Date.now(),
|
|
123
|
+
): GatewayState {
|
|
113
124
|
const revoked: Record<string, number> = {}
|
|
114
125
|
for (const [id, exp] of Object.entries(state.revokedSessions ?? {})) {
|
|
115
126
|
if (exp > now) revoked[id] = exp
|
package/src/tls.ts
CHANGED
|
@@ -105,6 +105,29 @@ export function regenerateSelfSigned(opts: SelfSignedTlsOptions, home: string =
|
|
|
105
105
|
return material
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Read the persisted self-signed certificate for a status report, or undefined
|
|
110
|
+
* when none has been generated yet.
|
|
111
|
+
*
|
|
112
|
+
* Nothing is created or written here. The status path answers a question about
|
|
113
|
+
* a listener that is already running (or was), and minting a key pair — an RSA
|
|
114
|
+
* generation plus two file writes — to answer a read would both be slow and
|
|
115
|
+
* leave material on disk for a gateway that never started. Generation belongs
|
|
116
|
+
* to {@link loadOrRenewSelfSigned} and {@link regenerateSelfSigned}.
|
|
117
|
+
* @param home - dsh home override (tests).
|
|
118
|
+
* @returns the certificate material as persisted, or undefined.
|
|
119
|
+
*/
|
|
120
|
+
export function readSelfSignedStatus(home: string = homedir()): TlsMaterial | undefined {
|
|
121
|
+
const dir = tlsDir(home)
|
|
122
|
+
const certPath = join(dir, SELF_SIGNED_CERT_FILE)
|
|
123
|
+
const keyPath = join(dir, SELF_SIGNED_KEY_FILE)
|
|
124
|
+
if (!existsSync(certPath) || !existsSync(keyPath)) return undefined
|
|
125
|
+
const cert = readFileSync(certPath, 'utf8')
|
|
126
|
+
const key = readFileSync(keyPath, 'utf8')
|
|
127
|
+
new X509Certificate(cert) // sanity: must parse as a certificate
|
|
128
|
+
return { cert, key }
|
|
129
|
+
}
|
|
130
|
+
|
|
108
131
|
function generateSelfSignedMaterial(opts: SelfSignedTlsOptions): TlsMaterial {
|
|
109
132
|
const hosts = opts.hosts.map(h => h.trim()).filter(h => h !== '')
|
|
110
133
|
if (hosts.length === 0) {
|
package/src/upstream-session.ts
CHANGED
|
@@ -35,10 +35,14 @@ interface HeldCookie {
|
|
|
35
35
|
expiresAt: number
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* The minimal shared-session contract the gateway consumes.
|
|
40
|
+
*
|
|
41
|
+
* Two methods, and deliberately no reader: a caller that needs the value
|
|
42
|
+
* already holds the one `cookie()` returned, and a second way to read the
|
|
43
|
+
* cache only invites the caller to skip the acquisition it just awaited.
|
|
44
|
+
*/
|
|
39
45
|
export interface UpstreamSession {
|
|
40
|
-
/** The current `name=value` without triggering a re-acquisition. */
|
|
41
|
-
peek(): string | undefined
|
|
42
46
|
/** The current `name=value`, re-acquiring when missing or stale. Never throws. */
|
|
43
47
|
cookie(): Promise<string | undefined>
|
|
44
48
|
/** Forget a session upstream rejected, so the next request re-acquires. */
|
|
@@ -92,14 +96,30 @@ function cookieNameOf(setCookie: string): string {
|
|
|
92
96
|
return eq === -1 ? '' : setCookie.slice(0, eq).trim()
|
|
93
97
|
}
|
|
94
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Whether one `name=value` fragment of a request `Cookie` header names the
|
|
101
|
+
* upstream session namespace, and so must be dropped before the relay's own
|
|
102
|
+
* copy is appended. This is the *filter* rule: it matches the whole reserved
|
|
103
|
+
* namespace, name only, whether or not the pair is a well-formed session.
|
|
104
|
+
*/
|
|
105
|
+
export function isUpstreamCookiePair(pair: string): boolean {
|
|
106
|
+
return pair.trim().startsWith(UPSTREAM_COOKIE_PREFIX)
|
|
107
|
+
}
|
|
108
|
+
|
|
95
109
|
/**
|
|
96
110
|
* Whether a `Set-Cookie` string is the upstream browser-session cookie. The
|
|
97
111
|
* name upstream mints is `dsh-auth-<base64url(sha256(authority))>`: the prefix
|
|
98
112
|
* is followed by the authority hash, never by `=` itself, so the test is a
|
|
99
113
|
* prefix plus at least one character — matching on `dsh-auth-=` finds nothing
|
|
100
114
|
* and silently relays every request anonymously.
|
|
115
|
+
*
|
|
116
|
+
* This is the *accept* rule, and it is stricter than {@link isUpstreamCookiePair}
|
|
117
|
+
* on purpose: filtering drops a whole namespace the gateway owns, whereas
|
|
118
|
+
* accepting a session has to recognize the one cookie upstream actually mints.
|
|
119
|
+
* Both live here because this module owns the protocol fact; a consumer that
|
|
120
|
+
* re-derives it is how the two rules drifted apart before.
|
|
101
121
|
*/
|
|
102
|
-
function isUpstreamSessionCookie(setCookie: string): boolean {
|
|
122
|
+
export function isUpstreamSessionCookie(setCookie: string): boolean {
|
|
103
123
|
const name = cookieNameOf(setCookie)
|
|
104
124
|
return name.startsWith(UPSTREAM_COOKIE_PREFIX) && name.length > UPSTREAM_COOKIE_PREFIX.length
|
|
105
125
|
}
|
|
@@ -208,10 +228,6 @@ export class UpstreamSessionRelay implements UpstreamSession {
|
|
|
208
228
|
return Date.now() < held.expiresAt - 60_000
|
|
209
229
|
}
|
|
210
230
|
|
|
211
|
-
peek(): string | undefined {
|
|
212
|
-
return this.held?.header
|
|
213
|
-
}
|
|
214
|
-
|
|
215
231
|
invalidate(): void {
|
|
216
232
|
if (this.held !== undefined) this.log('invalidating held session (upstream rejected it)')
|
|
217
233
|
this.held = undefined
|