@riceawa/dsh-lan-gateway 0.5.3 → 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/src/login.ts CHANGED
@@ -14,12 +14,9 @@ export const LOGIN_PATH = '/__login' as const
14
14
  /** Path the gateway owns and never forwards: signs the session out. */
15
15
  export const LOGOUT_PATH = '/__logout' as const
16
16
 
17
- /** The cookie name used for the signed session. */
18
- export const COOKIE_NAME = 'dsh_gw_auth' as const
19
-
20
17
  export interface LoginPageOptions {
21
18
  error?: string
22
- /** Optional login attempt counter to show when rate-limited. */
19
+ /** The attempt was refused by the rate limiter, not by a wrong password. */
23
20
  limited?: boolean
24
21
  }
25
22
 
@@ -95,19 +92,6 @@ export function serveLoginGet(res: ServerResponse, extraHeaders: OutgoingHttpHea
95
92
  res.end(renderLoginPage())
96
93
  }
97
94
 
98
- /** Parse an application/x-www-form-urlencoded body into its fields. */
99
- export function parseFormBody(body: string): Record<string, string> {
100
- const out: Record<string, string> = {}
101
- for (const pair of body.split('&')) {
102
- if (pair === '') continue
103
- const eq = pair.indexOf('=')
104
- const key = eq === -1 ? pair : pair.slice(0, eq)
105
- const value = eq === -1 ? '' : pair.slice(eq + 1)
106
- out[decodeURIComponent(key.replaceAll('+', ' '))] = decodeURIComponent(value.replaceAll('+', ' '))
107
- }
108
- return out
109
- }
110
-
111
95
  /** Read a request body up to a byte ceiling, rejecting anything larger. */
112
96
  export function readBody(req: IncomingMessage, maxBytes: number, res: ServerResponse): Promise<string | undefined> {
113
97
  return new Promise((resolve) => {
@@ -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, scryptSync, timingSafeEqual } from 'node:crypto'
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`. */
@@ -37,17 +37,43 @@ export interface GatewayState {
37
37
  * immediately. Old state files without the field load as epoch 0.
38
38
  */
39
39
  sessionEpoch: number
40
+ /**
41
+ * Revoked session ids, each mapped to the epoch millis at which that
42
+ * session's own cookie expires. Signing out revokes the single id the
43
+ * browser presented, so the account's other sessions keep working, and it
44
+ * has to be persisted: the cookie it names stays unforgeable until it
45
+ * expires, and a restart must not resurrect it. Entries lapse once the
46
+ * cookie they name could no longer be presented anyway, which bounds the
47
+ * list by the sessions that are still live somewhere. Absent on state
48
+ * written before 0.5.4.
49
+ */
50
+ revokedSessions?: Record<string, number>
40
51
  }
41
52
 
42
53
  const STATE_FILENAME = 'state.json'
43
54
 
44
- /** Whether a password is present and passes scrypt verification. */
45
- export function verifyPassword(state: GatewayState, password: string): boolean {
55
+ /** Promise wrapper around the threaded `scrypt`, which runs off the main loop. */
56
+ function deriveKey(password: string, salt: Buffer, keylen: number): Promise<Buffer> {
57
+ return new Promise((resolve, reject) => {
58
+ scrypt(password, salt, keylen, (error, derived) => {
59
+ if (error !== null) reject(error)
60
+ else resolve(derived)
61
+ })
62
+ })
63
+ }
64
+
65
+ /**
66
+ * Whether a password is present and passes scrypt verification. Asynchronous
67
+ * on purpose: `scryptSync` occupies the event loop for tens of milliseconds
68
+ * per attempt, and that loop is shared with the dsh process the gateway is
69
+ * forwarding to.
70
+ */
71
+ export async function verifyPassword(state: GatewayState, password: string): Promise<boolean> {
46
72
  if (state.password === undefined) return false
47
73
  const { hash, salt } = state.password
48
74
  try {
49
75
  const expected = Buffer.from(hash, 'hex')
50
- const actual = scryptSync(password, Buffer.from(salt, 'hex'), expected.length)
76
+ const actual = await deriveKey(password, Buffer.from(salt, 'hex'), expected.length)
51
77
  return expected.length === actual.length && timingSafeEqual(expected, actual)
52
78
  } catch {
53
79
  return false
@@ -58,24 +84,76 @@ export function verifyPassword(state: GatewayState, password: string): boolean {
58
84
  * Set (or clear) the password, re-salted on every write. Both operations bump
59
85
  * the session epoch so every cookie issued under the previous epoch dies — a
60
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.
61
92
  */
62
- export function setPassword(state: GatewayState, password: string | undefined): GatewayState {
63
- const base = { ...state, sessionEpoch: state.sessionEpoch + 1 }
64
- if (password === undefined) {
65
- return { cookieSecret: base.cookieSecret, sessionEpoch: base.sessionEpoch }
93
+ export async function setPassword(state: GatewayState, password: string | undefined): Promise<GatewayState> {
94
+ // The new epoch invalidates every cookie on its own account, so the list of
95
+ // individually revoked sessions has nothing left to say: drop it rather than
96
+ // carry entries that can never match again.
97
+ const base: GatewayState = {
98
+ cookieSecret: state.cookieSecret,
99
+ sessionEpoch: state.sessionEpoch + 1,
66
100
  }
101
+ if (password === undefined) return base
67
102
  const salt = randomBytes(16)
68
- const hash = scryptSync(password, salt, 64)
103
+ const hash = await deriveKey(password, salt, 64)
69
104
  return {
70
105
  ...base,
71
106
  password: { hash: hash.toString('hex'), salt: salt.toString('hex') },
72
107
  }
73
108
  }
74
109
 
110
+ /**
111
+ * Record a session id as revoked.
112
+ * @param expiresMs - the revoked cookie's own expiry. Past it the cookie is
113
+ * rejected on its own account, so the entry is no longer needed; dropping
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.
117
+ */
118
+ export function revokeSession(
119
+ state: GatewayState,
120
+ sid: string,
121
+ expiresMs: number,
122
+ now: number = Date.now(),
123
+ ): GatewayState {
124
+ const revoked: Record<string, number> = {}
125
+ for (const [id, exp] of Object.entries(state.revokedSessions ?? {})) {
126
+ if (exp > now) revoked[id] = exp
127
+ }
128
+ revoked[sid] = expiresMs
129
+ return { ...state, revokedSessions: revoked }
130
+ }
131
+
132
+ /** Whether `sid` names a session that has been signed out. */
133
+ export function isSessionRevoked(state: GatewayState, sid: string | undefined): boolean {
134
+ if (sid === undefined) return false
135
+ return Object.hasOwn(state.revokedSessions ?? {}, sid)
136
+ }
137
+
75
138
  function defaultState(): GatewayState {
76
139
  return { cookieSecret: randomBytes(32).toString('base64'), sessionEpoch: 0 }
77
140
  }
78
141
 
142
+ /** Keep the still-live entries of a persisted revocation list, or undefined. */
143
+ function parseRevokedSessions(raw: unknown): Record<string, number> | undefined {
144
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return undefined
145
+ const now = Date.now()
146
+ const out: Record<string, number> = {}
147
+ let anyLive = false
148
+ for (const [sid, exp] of Object.entries(raw)) {
149
+ if (typeof exp === 'number' && Number.isFinite(exp) && exp > now) {
150
+ out[sid] = exp
151
+ anyLive = true
152
+ }
153
+ }
154
+ return anyLive ? out : undefined
155
+ }
156
+
79
157
  /** Load state; on first run (or a corrupt file) generate a fresh secret. */
80
158
  export function loadState(home: string = homedir()): GatewayState {
81
159
  const dir = stateDir(home)
@@ -91,6 +169,9 @@ export function loadState(home: string = homedir()): GatewayState {
91
169
  : 0
92
170
  const base: GatewayState = { cookieSecret: parsed.cookieSecret, sessionEpoch }
93
171
  if (parsed.password !== undefined) base.password = parsed.password
172
+ // Anything the file lists past its own expiry has lapsed on its own.
173
+ const revoked = parseRevokedSessions(parsed.revokedSessions)
174
+ if (revoked !== undefined) base.revokedSessions = revoked
94
175
  return base
95
176
  }
96
177
  return defaultState()
package/src/tls.ts CHANGED
@@ -71,6 +71,27 @@ export function loadOrCreateSelfSigned(
71
71
  return { material, created: true }
72
72
  }
73
73
 
74
+ /**
75
+ * Load the self-signed material a listener should serve: generate it on first
76
+ * use, reuse the persisted pair otherwise, and replace a persisted certificate
77
+ * whose validity has already lapsed.
78
+ *
79
+ * Nothing renews a self-signed certificate in place, and a browser refuses a
80
+ * lapsed one outright, so without this a certificate that ran out would keep
81
+ * being served until an operator happened to read the expiry date out of
82
+ * `status` and act on it. Renewing mints a fresh key, so a client that had
83
+ * trusted the old certificate has to trust the new one — but that is the case
84
+ * either way, the old one having lapsed.
85
+ */
86
+ export function loadOrRenewSelfSigned(
87
+ opts: SelfSignedTlsOptions,
88
+ home: string = homedir(),
89
+ ): { material: TlsMaterial; renewed: boolean } {
90
+ const { material, created } = loadOrCreateSelfSigned(opts, home)
91
+ if (created || !isCertExpired(material.cert)) return { material, renewed: false }
92
+ return { material: regenerateSelfSigned(opts, home), renewed: true }
93
+ }
94
+
74
95
  /**
75
96
  * Force-regenerate the self-signed certificate (new key + cert), replacing
76
97
  * the persisted files. Used by `lan_gateway tls-regenerate`.
@@ -84,6 +105,29 @@ export function regenerateSelfSigned(opts: SelfSignedTlsOptions, home: string =
84
105
  return material
85
106
  }
86
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
+
87
131
  function generateSelfSignedMaterial(opts: SelfSignedTlsOptions): TlsMaterial {
88
132
  const hosts = opts.hosts.map(h => h.trim()).filter(h => h !== '')
89
133
  if (hosts.length === 0) {
@@ -146,6 +190,18 @@ export interface CertInfo {
146
190
  san?: string
147
191
  }
148
192
 
193
+ /**
194
+ * Whether a PEM certificate's validity window has already closed. A lapsed
195
+ * certificate is a hard failure browsers will not let the user proceed past,
196
+ * so the listener replaces one rather than keep serving it.
197
+ */
198
+ export function isCertExpired(certPem: string, now: number = Date.now()): boolean {
199
+ const expiresAt = Date.parse(new X509Certificate(certPem).validTo)
200
+ // An unparseable date reads as "not expired": serving the certificate we were
201
+ // handed beats discarding it over a date-parsing quirk.
202
+ return Number.isFinite(expiresAt) && expiresAt <= now
203
+ }
204
+
149
205
  /** Describe a PEM certificate (throws on malformed input). */
150
206
  export function describeCert(certPem: string): CertInfo {
151
207
  const cert = new X509Certificate(certPem)
@@ -35,10 +35,14 @@ interface HeldCookie {
35
35
  expiresAt: number
36
36
  }
37
37
 
38
- /** The minimal shared-session contract the gateway consumes. */
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. */
@@ -73,20 +77,49 @@ function nameValueOnly(setCookie: string): string {
73
77
  return (semi === -1 ? setCookie : setCookie.slice(0, semi)).trim()
74
78
  }
75
79
 
80
+ /**
81
+ * The pathname of a URL, for logging. Never the whole URL: the authenticated
82
+ * URL carries the launch token as a query parameter, and that token is a
83
+ * bearer credential for the upstream harness.
84
+ */
85
+ function pathOf(url: string): string {
86
+ try {
87
+ return new URL(url).pathname
88
+ } catch {
89
+ return '<unparseable>'
90
+ }
91
+ }
92
+
76
93
  /** The cookie name of a `Set-Cookie` string (`''` when it is malformed). */
77
94
  function cookieNameOf(setCookie: string): string {
78
95
  const eq = setCookie.indexOf('=')
79
96
  return eq === -1 ? '' : setCookie.slice(0, eq).trim()
80
97
  }
81
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
+
82
109
  /**
83
110
  * Whether a `Set-Cookie` string is the upstream browser-session cookie. The
84
111
  * name upstream mints is `dsh-auth-<base64url(sha256(authority))>`: the prefix
85
112
  * is followed by the authority hash, never by `=` itself, so the test is a
86
113
  * prefix plus at least one character — matching on `dsh-auth-=` finds nothing
87
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.
88
121
  */
89
- function isUpstreamSessionCookie(setCookie: string): boolean {
122
+ export function isUpstreamSessionCookie(setCookie: string): boolean {
90
123
  const name = cookieNameOf(setCookie)
91
124
  return name.startsWith(UPSTREAM_COOKIE_PREFIX) && name.length > UPSTREAM_COOKIE_PREFIX.length
92
125
  }
@@ -121,7 +154,7 @@ function exchange(
121
154
  try {
122
155
  target = new URL(url)
123
156
  } catch {
124
- log(`exchange: unparseable authenticatedUrl ${url}`)
157
+ log('exchange: authenticatedUrl is not parseable')
125
158
  resolve(undefined)
126
159
  return
127
160
  }
@@ -195,10 +228,6 @@ export class UpstreamSessionRelay implements UpstreamSession {
195
228
  return Date.now() < held.expiresAt - 60_000
196
229
  }
197
230
 
198
- peek(): string | undefined {
199
- return this.held?.header
200
- }
201
-
202
231
  invalidate(): void {
203
232
  if (this.held !== undefined) this.log('invalidating held session (upstream rejected it)')
204
233
  this.held = undefined
@@ -228,7 +257,9 @@ export class UpstreamSessionRelay implements UpstreamSession {
228
257
  this.log('authenticatedUrl() returned undefined; keeping current session')
229
258
  return this.held?.header
230
259
  }
231
- this.log(`acquiring session from ${url}`)
260
+ // Log only the path: the URL carries the launch token in its query string,
261
+ // and that token is a bearer credential for the upstream harness.
262
+ this.log(`acquiring session from ${pathOf(url)}`)
232
263
  const result = await exchange(url, this.authority, this.port, this.log)
233
264
  if (result !== undefined) {
234
265
  this.held = result