@riceawa/dsh-lan-gateway 0.5.1 → 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.
@@ -46,6 +46,7 @@ export interface LanGatewaySettings {
46
46
  tlsCertMaxAgeDays?: number
47
47
  allowInsecurePlaintext?: boolean
48
48
  trustedTerminator?: string
49
+ secureCookies?: boolean
49
50
  }
50
51
 
51
52
  /** GET /lan-gateway/config response. */
@@ -80,6 +81,7 @@ interface Labels {
80
81
  lastError: string
81
82
  [key: `field.${string}`]: string
82
83
  [key: `hint.${string}`]: string
84
+ [key: `opt.${string}`]: string
83
85
  }
84
86
 
85
87
  const LABELS: Record<'zh' | 'en', Labels> = {
@@ -114,6 +116,11 @@ const LABELS: Record<'zh' | 'en', Labels> = {
114
116
  'hint.allowInsecurePlaintext': '危险:关闭 TLS 或受信终止代理时仍启动监听,密码与会话将以明文传输',
115
117
  'field.trustedTerminator': '受信 TLS 终止代理',
116
118
  'hint.trustedTerminator': '可选:声明前置代理标识,视为加密入口(如 nginx)。留空 = 未声明',
119
+ 'field.secureCookies': '会话 cookie 的 Secure 属性',
120
+ 'hint.secureCookies': '自动 = TLS 或已声明受信终止代理时加 Secure。受信代理只做明文鉴权、浏览器走 http 访问时须设为 false,否则浏览器拒收 Secure cookie,登录会无限弹回登录页',
121
+ 'opt.auto': '自动',
122
+ 'opt.true': '始终 Secure',
123
+ 'opt.false': '不加 Secure(明文浏览器入口)',
117
124
  'field.cookieMaxAgeDays': '会话有效期(天)',
118
125
  'hint.cookieMaxAgeDays': '登录 cookie 的存活天数(默认 7)',
119
126
  'field.tlsEnabled': '启用 TLS(HTTPS)',
@@ -160,6 +167,11 @@ const LABELS: Record<'zh' | 'en', Labels> = {
160
167
  'hint.allowInsecurePlaintext': 'Dangerous: start the listener even without TLS or a trusted terminator; passwords and sessions travel in clear',
161
168
  'field.trustedTerminator': 'Trusted TLS terminator',
162
169
  'hint.trustedTerminator': 'Optional identifier for a front proxy (e.g. nginx) treated as the encrypted ingress. Empty = none declared',
170
+ 'field.secureCookies': 'Session cookie Secure attribute',
171
+ 'hint.secureCookies': 'Auto = Secure when TLS or a trusted terminator is declared. Set false when the trusted proxy only authenticates over plaintext and browsers reach it over http — otherwise browsers drop the Secure cookie and every login bounces back to the login page',
172
+ 'opt.auto': 'Auto',
173
+ 'opt.true': 'Always Secure',
174
+ 'opt.false': 'No Secure (plaintext browser ingress)',
163
175
  'field.cookieMaxAgeDays': 'Session lifetime (days)',
164
176
  'hint.cookieMaxAgeDays': 'Login cookie lifetime (default 7)',
165
177
  'field.tlsEnabled': 'Enable TLS (HTTPS)',
@@ -186,7 +198,7 @@ function labels(): Labels {
186
198
  /* Field model */
187
199
  /* ------------------------------------------------------------------ */
188
200
 
189
- type FieldKind = 'boolean' | 'number' | 'text' | 'cidrs' | 'select'
201
+ type FieldKind = 'boolean' | 'number' | 'text' | 'cidrs' | 'select' | 'tristate'
190
202
 
191
203
  interface FieldDef {
192
204
  field: keyof LanGatewaySettings
@@ -195,6 +207,18 @@ interface FieldDef {
195
207
  options?: readonly string[]
196
208
  }
197
209
 
210
+ /**
211
+ * The card's field table and its two value codecs are exported for tests: the
212
+ * tri-state codec is the load-bearing part of the settings round-trip (an
213
+ * unset value must stay distinguishable from an explicit false, or the
214
+ * plaintext-proxy escape hatch silently reverts).
215
+ */
216
+ export { TRISTATE_OPTIONS, FIELDS, formatValue, parseValue }
217
+ export type { FieldDef, Write }
218
+
219
+ /** The three states of a tri-state field, in display order. */
220
+ const TRISTATE_OPTIONS = ['auto', 'true', 'false'] as const
221
+
198
222
  const FIELDS: readonly FieldDef[] = [
199
223
  { field: 'enabled', kind: 'boolean' },
200
224
  { field: 'gatewayPort', kind: 'number' },
@@ -210,6 +234,7 @@ const FIELDS: readonly FieldDef[] = [
210
234
  { field: 'tlsCertMaxAgeDays', kind: 'number' },
211
235
  { field: 'allowInsecurePlaintext', kind: 'boolean' },
212
236
  { field: 'trustedTerminator', kind: 'text', optional: true },
237
+ { field: 'secureCookies', kind: 'tristate' },
213
238
  ]
214
239
 
215
240
  function formatValue(def: FieldDef, value: unknown): string {
@@ -218,6 +243,8 @@ function formatValue(def: FieldDef, value: unknown): string {
218
243
  case 'number': return typeof value === 'number' ? String(value) : ''
219
244
  case 'cidrs': return Array.isArray(value) ? value.join(', ') : ''
220
245
  case 'select': return typeof value === 'string' ? value : (def.options?.[0] ?? '')
246
+ // Tri-state: an unset value is a distinct third state ("auto"), never "false".
247
+ case 'tristate': return value === true ? 'true' : value === false ? 'false' : 'auto'
221
248
  case 'text': return typeof value === 'string' ? value : ''
222
249
  }
223
250
  }
@@ -242,6 +269,13 @@ function parseValue(def: FieldDef, text: string): Write | undefined {
242
269
  }
243
270
  case 'select':
244
271
  return def.options?.includes(trimmed) ? { kind: 'set', value: trimmed } : undefined
272
+ case 'tristate':
273
+ // 'auto' clears the key so it re-inherits the composition layer (and the
274
+ // resolution rule), which is what an unset tri-state means.
275
+ if (trimmed === 'auto') return { kind: 'clear' }
276
+ if (trimmed === 'true') return { kind: 'set', value: true }
277
+ if (trimmed === 'false') return { kind: 'set', value: false }
278
+ return undefined
245
279
  case 'text':
246
280
  return trimmed === '' ? (def.optional ? { kind: 'clear' } : undefined) : { kind: 'set', value: trimmed }
247
281
  }
@@ -384,6 +418,11 @@ export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
384
418
  </div>
385
419
  )
386
420
  case 'select':
421
+ case 'tristate': {
422
+ // A tri-state renders as a three-way select because neither a checkbox
423
+ // (cannot express "unset") nor a text field (cannot express false)
424
+ // distinguishes auto from an explicit false.
425
+ const options = def.kind === 'tristate' ? TRISTATE_OPTIONS : (def.options ?? [])
387
426
  return (
388
427
  <div style={styles.field}>
389
428
  <label style={styles.label} htmlFor={`lan-gw-${field}`}>{label}</label>
@@ -394,7 +433,11 @@ export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
394
433
  disabled={saving}
395
434
  onChange={(e: ChangeEvent<HTMLSelectElement>) => stage(field, e.target.value)}
396
435
  >
397
- {def.options?.map(option => <option key={option} value={option}>{option}</option>)}
436
+ {options.map(option => (
437
+ <option key={option} value={option}>
438
+ {def.kind === 'tristate' ? t[`opt.${option}`] : option}
439
+ </option>
440
+ ))}
398
441
  </select>
399
442
  <span style={styles.hint}>{hint}</span>
400
443
  <button
@@ -407,6 +450,7 @@ export function LanGatewayCard(_props: LanGatewayCardProps): ReactNode {
407
450
  </button>
408
451
  </div>
409
452
  )
453
+ }
410
454
  default:
411
455
  return (
412
456
  <div style={styles.field}>
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
@@ -241,8 +272,15 @@ function resolveTls(cfg: Config): TlsMaterial | undefined {
241
272
  return material
242
273
  }
243
274
 
244
- /** Config fields that require a listener restart when they change. */
245
- function listenerKey(cfg: Config): string {
275
+ /**
276
+ * Config fields that require a listener restart when they change, plus whether
277
+ * a shared upstream session relay is available at all. The relay flag belongs
278
+ * in the key: a listener that started before the `connection` service appeared
279
+ * was built without a relay and must restart once the service attaches,
280
+ * otherwise it silently forwards every request anonymously (the upstream 401s)
281
+ * while the status line still claims the relay is active.
282
+ */
283
+ function listenerKey(cfg: Config, relayAvailable: boolean): string {
246
284
  return JSON.stringify([
247
285
  cfg.gatewayPort,
248
286
  cfg.dshTargetPort,
@@ -258,6 +296,8 @@ function listenerKey(cfg: Config): string {
258
296
  cfg.tlsCertMaxAgeDays,
259
297
  cfg.allowInsecurePlaintext,
260
298
  cfg.trustedTerminator,
299
+ cfg.secureCookies,
300
+ relayAvailable,
261
301
  ])
262
302
  }
263
303
 
@@ -345,6 +385,7 @@ export function apply(ctx: Context, config: Config): void {
345
385
  const dshPort = cfg.dshTargetPort ?? ctx.webServer.port
346
386
  const tls = resolveTls(cfg)
347
387
  const encryptedIngress = cfg.tlsEnabled || cfg.trustedTerminator !== undefined
388
+ const secureCookies = resolveSecureCookies(cfg)
348
389
  const next = new LanGateway({
349
390
  gatewayPort: cfg.gatewayPort,
350
391
  dshPort,
@@ -352,13 +393,13 @@ export function apply(ctx: Context, config: Config): void {
352
393
  lanPasswordless: cfg.lanPasswordless,
353
394
  cookieMaxAgeDays: cfg.cookieMaxAgeDays,
354
395
  cookieName: cfg.cookieName,
355
- secureCookies: encryptedIngress,
396
+ secureCookies,
356
397
  ...(tls !== undefined ? { tls } : {}),
357
398
  ...(makeRelay !== undefined ? { upstreamSession: makeRelay(dshPort) } : {}),
358
399
  }, state)
359
400
  await next.listen()
360
401
  gateway = next
361
- startedWith = listenerKey(cfg)
402
+ startedWith = listenerKey(cfg, makeRelay !== undefined)
362
403
  ctx.logger.info(
363
404
  `dsh-lan-gateway: listening on 0.0.0.0:${cfg.gatewayPort}${tls !== undefined ? ' (TLS)' : ''}`
364
405
  + ` -> 127.0.0.1:${dshPort}${encryptedIngress ? '' : ' (plaintext, explicit allowInsecurePlaintext)'}`
@@ -387,7 +428,7 @@ export function apply(ctx: Context, config: Config): void {
387
428
  if (shouldRun) await startGateway(cfg)
388
429
  } else if (!shouldRun) {
389
430
  await stopGateway()
390
- } else if (startedWith !== listenerKey(cfg)) {
431
+ } else if (startedWith !== listenerKey(cfg, makeRelay !== undefined)) {
391
432
  await stopGateway()
392
433
  await startGateway(cfg)
393
434
  }
@@ -427,9 +468,13 @@ export function apply(ctx: Context, config: Config): void {
427
468
  // gateway forwards without a relay, and lanPasswordless stays refused.
428
469
  ctx.inject(['connection'], (ccx) => {
429
470
  upstreamSessionAvailable = true
471
+ ctx.logger.info('dsh-lan-gateway: connection service attached; upstream session relay enabled')
430
472
  makeRelay = (dshPort) => new UpstreamSessionRelay({
431
473
  port: dshPort,
432
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}`),
433
478
  })
434
479
  // A listener that started before the connection service appeared must
435
480
  // restart so it picks up the relay (and the now-correct fail-closed facts).
@@ -551,8 +596,8 @@ export function apply(ctx: Context, config: Config): void {
551
596
  + `\n- login required for all sources: true${cfg.lanPasswordless ? ' (LAN/loopback exempt via lanPasswordless)' : ''}`
552
597
  + `\n- session epoch: ${state.sessionEpoch}`
553
598
  + `\n- upstream session relay: ${upstreamSessionAvailable ? 'active (dsh browser-session auth present)' : 'absent (older dsh base)'}`
554
- + `\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'}`
555
- + `\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)'}`
556
601
  + (manualOverride !== undefined
557
602
  ? `\n- manual override: ${manualOverride ? 'enabled' : 'disabled'}`
558
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. */
@@ -65,6 +73,24 @@ function nameValueOnly(setCookie: string): string {
65
73
  return (semi === -1 ? setCookie : setCookie.slice(0, semi)).trim()
66
74
  }
67
75
 
76
+ /** The cookie name of a `Set-Cookie` string (`''` when it is malformed). */
77
+ function cookieNameOf(setCookie: string): string {
78
+ const eq = setCookie.indexOf('=')
79
+ return eq === -1 ? '' : setCookie.slice(0, eq).trim()
80
+ }
81
+
82
+ /**
83
+ * Whether a `Set-Cookie` string is the upstream browser-session cookie. The
84
+ * name upstream mints is `dsh-auth-<base64url(sha256(authority))>`: the prefix
85
+ * is followed by the authority hash, never by `=` itself, so the test is a
86
+ * prefix plus at least one character — matching on `dsh-auth-=` finds nothing
87
+ * and silently relays every request anonymously.
88
+ */
89
+ function isUpstreamSessionCookie(setCookie: string): boolean {
90
+ const name = cookieNameOf(setCookie)
91
+ return name.startsWith(UPSTREAM_COOKIE_PREFIX) && name.length > UPSTREAM_COOKIE_PREFIX.length
92
+ }
93
+
68
94
  /** Pull the Max-Age attribute (seconds) out of a Set-Cookie string, if any. */
69
95
  function maxAgeSeconds(setCookie: string): number | undefined {
70
96
  const match = /\bMax-Age=(\d+)\b/i.exec(setCookie)
@@ -88,12 +114,14 @@ function exchange(
88
114
  url: string,
89
115
  authority: string,
90
116
  port: number,
117
+ log: (message: string) => void,
91
118
  ): Promise<ExchangeResult | undefined> {
92
119
  return new Promise((resolve) => {
93
120
  let target: URL
94
121
  try {
95
122
  target = new URL(url)
96
123
  } catch {
124
+ log(`exchange: unparseable authenticatedUrl ${url}`)
97
125
  resolve(undefined)
98
126
  return
99
127
  }
@@ -107,21 +135,33 @@ function exchange(
107
135
  const setCookies = response.headers['set-cookie']
108
136
  response.resume() // drain so the socket can be reused
109
137
  if (setCookies === undefined) {
138
+ log(`exchange ${target.pathname} -> ${response.statusCode} (no set-cookie)`)
110
139
  resolve(undefined)
111
140
  return
112
141
  }
113
- const raw = (Array.isArray(setCookies) ? setCookies : [setCookies])
114
- .find((value) => value.startsWith(`${UPSTREAM_COOKIE_PREFIX}=`))
142
+ const all = Array.isArray(setCookies) ? setCookies : [setCookies]
143
+ const raw = all.find(isUpstreamSessionCookie)
115
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'})`)
116
149
  resolve(undefined)
117
150
  return
118
151
  }
119
152
  const header = nameValueOnly(raw)
120
153
  const maxAge = maxAgeSeconds(raw)
154
+ log(`exchange ${target.pathname} -> ${response.statusCode} (got ${cookieNameOf(raw)}, maxAge=${maxAge ?? 'n/a'})`)
121
155
  resolve({ header, expiresAt: Date.now() + (maxAge ?? 0) * 1000 })
122
156
  })
123
- request.on('error', () => resolve(undefined))
124
- 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
+ })
125
165
  request.end()
126
166
  })
127
167
  }
@@ -135,6 +175,7 @@ export class UpstreamSessionRelay implements UpstreamSession {
135
175
  private readonly port: number
136
176
  private readonly authority: string
137
177
  private readonly authenticatedUrl: () => string | undefined
178
+ private readonly log: (message: string) => void
138
179
  private held: HeldCookie | undefined
139
180
  private inflight: Promise<string | undefined> | undefined
140
181
 
@@ -142,6 +183,7 @@ export class UpstreamSessionRelay implements UpstreamSession {
142
183
  this.port = options.port
143
184
  this.authority = options.authority ?? `127.0.0.1:${options.port}`
144
185
  this.authenticatedUrl = options.authenticatedUrl
186
+ this.log = options.log ?? (() => { /* no debug sink configured */ })
145
187
  }
146
188
 
147
189
  /** Whether the held session is still comfortably inside its lifetime. */
@@ -158,6 +200,7 @@ export class UpstreamSessionRelay implements UpstreamSession {
158
200
  }
159
201
 
160
202
  invalidate(): void {
203
+ if (this.held !== undefined) this.log('invalidating held session (upstream rejected it)')
161
204
  this.held = undefined
162
205
  }
163
206
 
@@ -177,9 +220,22 @@ export class UpstreamSessionRelay implements UpstreamSession {
177
220
 
178
221
  private async doExchange(): Promise<string | undefined> {
179
222
  const url = this.authenticatedUrl()
180
- if (url === undefined) return undefined
181
- const result = await exchange(url, this.authority, this.port)
182
- 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
+ }
183
239
  // On a transient failure keep whatever session is still held rather than
184
240
  // dropping to anonymous.
185
241
  return this.held?.header