@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/README.md +126 -237
- package/lib/client.js +68 -32
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +21 -1
- package/lib/index.js +54 -12
- package/package.json +3 -2
- package/src/client/lan-gateway-card.tsx +46 -2
- package/src/index.ts +40 -3
- package/src/upstream-session.ts +45 -7
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
|
|
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 ? `
|
|
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
|
: '')
|
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. */
|
|
@@ -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
|
|
132
|
-
|
|
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', () =>
|
|
142
|
-
|
|
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)
|
|
199
|
-
|
|
200
|
-
|
|
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
|