@riceawa/dsh-lan-gateway 0.5.2 → 0.5.3

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/index.ts CHANGED
@@ -158,6 +158,14 @@ export interface Config {
158
158
  * encrypted-ingress gate) without this listener sending HSTS.
159
159
  */
160
160
  trustedTerminator?: string
161
+ /**
162
+ * Explicit override for the session cookie's `Secure` attribute. Unset =
163
+ * automatic: Secure when the gateway serves TLS itself or a
164
+ * `trustedTerminator` is declared. Set `false` when the trusted proxy fronts
165
+ * a plaintext browser ingress — browsers refuse to store a Secure cookie over
166
+ * plain HTTP, so every login would bounce straight back to `/__login`.
167
+ */
168
+ secureCookies?: boolean
161
169
  }
162
170
 
163
171
  /**
@@ -189,6 +197,7 @@ export const Config: z<Config> = z.object({
189
197
  tlsCertMaxAgeDays: z.natural().min(1).max(3650).default(825),
190
198
  allowInsecurePlaintext: z.boolean().default(false),
191
199
  trustedTerminator: z.string(),
200
+ secureCookies: z.boolean(),
192
201
  })
193
202
 
194
203
  /** Facts the fail-closed start guard needs to judge a config. */
@@ -227,6 +236,28 @@ export function gatewayStartProblems(cfg: Config, facts: StartFacts): string[] {
227
236
  return problems
228
237
  }
229
238
 
239
+ /**
240
+ * Resolve the effective `Secure` attribute for the session cookie: an explicit
241
+ * `secureCookies` always wins; unset falls back to automatic — Secure when the
242
+ * gateway terminates TLS itself or a trusted terminator is declared. The
243
+ * override exists for a trusted proxy that authenticates users but speaks plain
244
+ * HTTP to browsers: `encryptedIngress` is a fair proxy for "a proxy is in front"
245
+ * but not for "the browser leg is encrypted", and a Secure cookie on a plain
246
+ * HTTP origin is silently dropped, looping the login.
247
+ *
248
+ * Exported for tests.
249
+ */
250
+ export function resolveSecureCookies(
251
+ cfg: Pick<Config, 'secureCookies' | 'tlsEnabled' | 'trustedTerminator'>,
252
+ ): boolean {
253
+ // Test for a real boolean, not just `!== undefined`: the settings route
254
+ // clears a key by posting null and schemastery passes that through rather
255
+ // than coercing it to undefined, so `null` reaches here on the save path.
256
+ // Only an explicit true/false overrides the automatic rule.
257
+ if (typeof cfg.secureCookies === 'boolean') return cfg.secureCookies
258
+ return cfg.tlsEnabled || cfg.trustedTerminator !== undefined
259
+ }
260
+
230
261
  /** Resolve the TLS material for a config, or undefined when TLS is off. */
231
262
  function resolveTls(cfg: Config): TlsMaterial | undefined {
232
263
  if (!cfg.tlsEnabled) return undefined
@@ -265,6 +296,7 @@ function listenerKey(cfg: Config, relayAvailable: boolean): string {
265
296
  cfg.tlsCertMaxAgeDays,
266
297
  cfg.allowInsecurePlaintext,
267
298
  cfg.trustedTerminator,
299
+ cfg.secureCookies,
268
300
  relayAvailable,
269
301
  ])
270
302
  }
@@ -353,6 +385,7 @@ export function apply(ctx: Context, config: Config): void {
353
385
  const dshPort = cfg.dshTargetPort ?? ctx.webServer.port
354
386
  const tls = resolveTls(cfg)
355
387
  const encryptedIngress = cfg.tlsEnabled || cfg.trustedTerminator !== undefined
388
+ const secureCookies = resolveSecureCookies(cfg)
356
389
  const next = new LanGateway({
357
390
  gatewayPort: cfg.gatewayPort,
358
391
  dshPort,
@@ -360,7 +393,7 @@ export function apply(ctx: Context, config: Config): void {
360
393
  lanPasswordless: cfg.lanPasswordless,
361
394
  cookieMaxAgeDays: cfg.cookieMaxAgeDays,
362
395
  cookieName: cfg.cookieName,
363
- secureCookies: encryptedIngress,
396
+ secureCookies,
364
397
  ...(tls !== undefined ? { tls } : {}),
365
398
  ...(makeRelay !== undefined ? { upstreamSession: makeRelay(dshPort) } : {}),
366
399
  }, state)
@@ -435,9 +468,13 @@ export function apply(ctx: Context, config: Config): void {
435
468
  // gateway forwards without a relay, and lanPasswordless stays refused.
436
469
  ctx.inject(['connection'], (ccx) => {
437
470
  upstreamSessionAvailable = true
471
+ ctx.logger.info('dsh-lan-gateway: connection service attached; upstream session relay enabled')
438
472
  makeRelay = (dshPort) => new UpstreamSessionRelay({
439
473
  port: dshPort,
440
474
  authenticatedUrl: () => ccx.connection.authenticatedUrl(`http://127.0.0.1:${dshPort}`),
475
+ // The relay never throws, so a failing exchange is otherwise invisible
476
+ // and looks exactly like a base with no browser sessions.
477
+ log: (message) => ctx.logger.info(`dsh-lan-gateway relay: ${message}`),
441
478
  })
442
479
  // A listener that started before the connection service appeared must
443
480
  // restart so it picks up the relay (and the now-correct fail-closed facts).
@@ -559,8 +596,8 @@ export function apply(ctx: Context, config: Config): void {
559
596
  + `\n- login required for all sources: true${cfg.lanPasswordless ? ' (LAN/loopback exempt via lanPasswordless)' : ''}`
560
597
  + `\n- session epoch: ${state.sessionEpoch}`
561
598
  + `\n- upstream session relay: ${upstreamSessionAvailable ? 'active (dsh browser-session auth present)' : 'absent (older dsh base)'}`
562
- + `\n- ingress: ${cfg.tlsEnabled ? `TLS (${tlsStatusLine(cfg)})` : cfg.trustedTerminator !== undefined ? `TLS terminated by trusted proxy (${cfg.trustedTerminator})` : encrypted ? 'encrypted' : cfg.allowInsecurePlaintext ? 'PLAINTEXT (explicit allowInsecurePlaintext)' : 'plaintext — will not start'}`
563
- + `\n- session cookie: ${cfg.cookieName}, ${cfg.cookieMaxAgeDays}d`
599
+ + `\n- ingress: ${cfg.tlsEnabled ? `TLS (${tlsStatusLine(cfg)})` : cfg.trustedTerminator !== undefined ? `trusted proxy (${cfg.trustedTerminator}, ${resolveSecureCookies(cfg) ? 'TLS' : 'plaintext'} browser ingress)` : encrypted ? 'encrypted' : cfg.allowInsecurePlaintext ? 'PLAINTEXT (explicit allowInsecurePlaintext)' : 'plaintext — will not start'}`
600
+ + `\n- session cookie: ${cfg.cookieName}, ${cfg.cookieMaxAgeDays}d, ${resolveSecureCookies(cfg) ? 'Secure' : 'no Secure attribute (plaintext browser ingress)'}`
564
601
  + (manualOverride !== undefined
565
602
  ? `\n- manual override: ${manualOverride ? 'enabled' : 'disabled'}`
566
603
  : '')
@@ -57,6 +57,14 @@ export interface UpstreamSessionRelayOptions {
57
57
  * upstream restart is picked up.
58
58
  */
59
59
  authenticatedUrl: () => string | undefined
60
+ /**
61
+ * Optional low-frequency debug sink: one line per acquisition attempt,
62
+ * exchange outcome, and invalidation. Every other failure path here is
63
+ * silent by design (the relay never throws), which makes a mis-named cookie
64
+ * or an unreachable upstream indistinguishable from "the base has no
65
+ * browser sessions" — wire `ctx.logger` to make that visible.
66
+ */
67
+ log?: (message: string) => void
60
68
  }
61
69
 
62
70
  /** Split `name=value; Path=/; …` into the `name=value` request-Cookie fragment. */
@@ -106,12 +114,14 @@ function exchange(
106
114
  url: string,
107
115
  authority: string,
108
116
  port: number,
117
+ log: (message: string) => void,
109
118
  ): Promise<ExchangeResult | undefined> {
110
119
  return new Promise((resolve) => {
111
120
  let target: URL
112
121
  try {
113
122
  target = new URL(url)
114
123
  } catch {
124
+ log(`exchange: unparseable authenticatedUrl ${url}`)
115
125
  resolve(undefined)
116
126
  return
117
127
  }
@@ -125,21 +135,33 @@ function exchange(
125
135
  const setCookies = response.headers['set-cookie']
126
136
  response.resume() // drain so the socket can be reused
127
137
  if (setCookies === undefined) {
138
+ log(`exchange ${target.pathname} -> ${response.statusCode} (no set-cookie)`)
128
139
  resolve(undefined)
129
140
  return
130
141
  }
131
- const raw = (Array.isArray(setCookies) ? setCookies : [setCookies])
132
- .find(isUpstreamSessionCookie)
142
+ const all = Array.isArray(setCookies) ? setCookies : [setCookies]
143
+ const raw = all.find(isUpstreamSessionCookie)
133
144
  if (raw === undefined) {
145
+ // Name what did come back: a missing session cookie is otherwise
146
+ // indistinguishable from an upstream that mints a differently-named one.
147
+ const names = all.map(cookieNameOf).filter((name) => name !== '')
148
+ log(`exchange ${target.pathname} -> ${response.statusCode} (no ${UPSTREAM_COOKIE_PREFIX}* cookie; got: ${names.join(', ') || 'none'})`)
134
149
  resolve(undefined)
135
150
  return
136
151
  }
137
152
  const header = nameValueOnly(raw)
138
153
  const maxAge = maxAgeSeconds(raw)
154
+ log(`exchange ${target.pathname} -> ${response.statusCode} (got ${cookieNameOf(raw)}, maxAge=${maxAge ?? 'n/a'})`)
139
155
  resolve({ header, expiresAt: Date.now() + (maxAge ?? 0) * 1000 })
140
156
  })
141
- request.on('error', () => resolve(undefined))
142
- request.setTimeout(5000, () => request.destroy(new Error('upstream-session exchange timeout')))
157
+ request.on('error', (error: Error) => {
158
+ log(`exchange error: ${error.message}`)
159
+ resolve(undefined)
160
+ })
161
+ request.setTimeout(5000, () => {
162
+ log('exchange timeout (5s)')
163
+ request.destroy(new Error('upstream-session exchange timeout'))
164
+ })
143
165
  request.end()
144
166
  })
145
167
  }
@@ -153,6 +175,7 @@ export class UpstreamSessionRelay implements UpstreamSession {
153
175
  private readonly port: number
154
176
  private readonly authority: string
155
177
  private readonly authenticatedUrl: () => string | undefined
178
+ private readonly log: (message: string) => void
156
179
  private held: HeldCookie | undefined
157
180
  private inflight: Promise<string | undefined> | undefined
158
181
 
@@ -160,6 +183,7 @@ export class UpstreamSessionRelay implements UpstreamSession {
160
183
  this.port = options.port
161
184
  this.authority = options.authority ?? `127.0.0.1:${options.port}`
162
185
  this.authenticatedUrl = options.authenticatedUrl
186
+ this.log = options.log ?? (() => { /* no debug sink configured */ })
163
187
  }
164
188
 
165
189
  /** Whether the held session is still comfortably inside its lifetime. */
@@ -176,6 +200,7 @@ export class UpstreamSessionRelay implements UpstreamSession {
176
200
  }
177
201
 
178
202
  invalidate(): void {
203
+ if (this.held !== undefined) this.log('invalidating held session (upstream rejected it)')
179
204
  this.held = undefined
180
205
  }
181
206
 
@@ -195,9 +220,22 @@ export class UpstreamSessionRelay implements UpstreamSession {
195
220
 
196
221
  private async doExchange(): Promise<string | undefined> {
197
222
  const url = this.authenticatedUrl()
198
- if (url === undefined) return undefined
199
- const result = await exchange(url, this.authority, this.port)
200
- if (result !== undefined) this.held = result
223
+ if (url === undefined) {
224
+ // The launch URL is transiently unavailable (e.g. the connection service
225
+ // is mid-restart). Keep whatever is held: returning undefined here would
226
+ // forward with no cookie at all and draw a 401 upstream, which is worse
227
+ // than riding a session that is probably still valid.
228
+ this.log('authenticatedUrl() returned undefined; keeping current session')
229
+ return this.held?.header
230
+ }
231
+ this.log(`acquiring session from ${url}`)
232
+ const result = await exchange(url, this.authority, this.port, this.log)
233
+ if (result !== undefined) {
234
+ this.held = result
235
+ this.log('session acquired and cached')
236
+ } else {
237
+ this.log('exchange failed; keeping current session')
238
+ }
201
239
  // On a transient failure keep whatever session is still held rather than
202
240
  // dropping to anonymous.
203
241
  return this.held?.header