@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.
- package/README.md +128 -217
- package/lib/client.js +68 -32
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +21 -1
- package/lib/index.js +83 -17
- package/package.json +10 -8
- package/src/client/lan-gateway-card.tsx +46 -2
- package/src/index.ts +52 -7
- package/src/upstream-session.ts +63 -7
|
@@ -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
|
-
{
|
|
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
|
-
/**
|
|
245
|
-
|
|
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
|
|
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 ? `
|
|
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
|
: '')
|
package/src/upstream-session.ts
CHANGED
|
@@ -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
|
|
114
|
-
|
|
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', () =>
|
|
124
|
-
|
|
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)
|
|
181
|
-
|
|
182
|
-
|
|
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
|