@riceawa/dsh-lan-gateway 0.5.3 → 0.5.4

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
@@ -4,7 +4,8 @@
4
4
  *
5
5
  * dsh's web CLI hard-refuses `--host 0.0.0.0` (exposing remote code execution
6
6
  * to the network), so this plugin leaves dsh bound to 127.0.0.1 and starts its
7
- * own reverse-proxy gateway on 0.0.0.0 that forwards to the loopback dsh port,
7
+ * own reverse-proxy gateway on the unspecified address — both families, so
8
+ * IPv6 clients reach it too — that forwards to the loopback dsh port,
8
9
  * rewriting Host/Origin so the request reaches the dsh web server as if it came
9
10
  * from the loopback authority it names.
10
11
  *
@@ -22,8 +23,10 @@
22
23
  * TLS-terminating proxy, or an explicit `allowInsecurePlaintext` opt-in is
23
24
  * present.
24
25
  * - The gateway never relays its own surface (`/lan-gateway/*`, the login and
25
- * logout pages); sessions carry a revocation epoch that a password change or
26
- * secret rotation bumps, killing old cookies and established WebSockets.
26
+ * logout pages). Sessions are revocable: each carries a random id, so signing
27
+ * out retires that one session and the WebSockets it opened, and each carries
28
+ * a revocation epoch, so a password change or secret rotation kills every
29
+ * session at once.
27
30
  *
28
31
  * Every tunable is also exposed as the `lan-gateway` user-settings namespace
29
32
  * (`ctx.settings`), so the official DSH Settings → Plugins page can adjust
@@ -56,6 +59,7 @@ import {
56
59
  describeCert,
57
60
  loadCustomCert,
58
61
  loadOrCreateSelfSigned,
62
+ loadOrRenewSelfSigned,
59
63
  parseSelfSignedHosts,
60
64
  regenerateSelfSigned,
61
65
  type TlsMaterial,
@@ -113,7 +117,7 @@ export interface GatewayController {
113
117
  export interface Config {
114
118
  /** Whether the gateway listener is started at boot. Default false (safe). */
115
119
  enabled: boolean
116
- /** Port to bind on 0.0.0.0. */
120
+ /** Port to bind on the unspecified address, both address families. */
117
121
  gatewayPort: number
118
122
  /** Explicit dsh target port; defaults to the live `ctx.webServer.port`. */
119
123
  dshTargetPort?: number
@@ -145,7 +149,18 @@ export interface Config {
145
149
  tlsKeyPath?: string
146
150
  /** Self-signed mode: comma/space separated DNS names and IPs for the SANs. */
147
151
  tlsSelfSignedHosts?: string
148
- /** Self-signed certificate validity in days (default 825 ≈ 27 months). */
152
+ /**
153
+ * Self-signed certificate validity in days (default 825 ≈ 27 months).
154
+ *
155
+ * 825 is the ceiling Apple states for TLS server certificates, and the
156
+ * well-known 398-day limit — which this default is sometimes mistaken for
157
+ * exceeding — applies only to certificates chaining to a root preinstalled
158
+ * by the platform: Apple exempts user- and administrator-added roots
159
+ * outright, and a self-signed certificate is always one of those. Since a
160
+ * self-signed certificate is either clicked through or trusted by hand,
161
+ * there is nothing to gain from the shorter window and a re-trust to lose
162
+ * every time it lapses.
163
+ */
149
164
  tlsCertMaxAgeDays: number
150
165
  /**
151
166
  * Escape hatch (default false): permit plaintext HTTP. Never derived from
@@ -259,17 +274,16 @@ export function resolveSecureCookies(
259
274
  }
260
275
 
261
276
  /** Resolve the TLS material for a config, or undefined when TLS is off. */
262
- function resolveTls(cfg: Config): TlsMaterial | undefined {
277
+ function resolveTls(cfg: Config): { material: TlsMaterial; renewed: boolean } | undefined {
263
278
  if (!cfg.tlsEnabled) return undefined
264
279
  if (cfg.tlsMode === 'custom') {
265
- return loadCustomCert(cfg.tlsCertPath ?? '', cfg.tlsKeyPath ?? '')
280
+ return { material: loadCustomCert(cfg.tlsCertPath ?? '', cfg.tlsKeyPath ?? ''), renewed: false }
266
281
  }
267
282
  const hosts = parseSelfSignedHosts(cfg.tlsSelfSignedHosts)
268
283
  if (hosts.length === 0) {
269
284
  throw new Error('tlsSelfSignedHosts must name at least one host (DNS name or IP)')
270
285
  }
271
- const { material } = loadOrCreateSelfSigned({ hosts, days: cfg.tlsCertMaxAgeDays })
272
- return material
286
+ return loadOrRenewSelfSigned({ hosts, days: cfg.tlsCertMaxAgeDays })
273
287
  }
274
288
 
275
289
  /**
@@ -383,7 +397,8 @@ export function apply(ctx: Context, config: Config): void {
383
397
  throw new Error(`dsh-lan-gateway: cannot start — ${problems.join(' ')}`)
384
398
  }
385
399
  const dshPort = cfg.dshTargetPort ?? ctx.webServer.port
386
- const tls = resolveTls(cfg)
400
+ const resolved = resolveTls(cfg)
401
+ const tls = resolved?.material
387
402
  const encryptedIngress = cfg.tlsEnabled || cfg.trustedTerminator !== undefined
388
403
  const secureCookies = resolveSecureCookies(cfg)
389
404
  const next = new LanGateway({
@@ -396,15 +411,28 @@ export function apply(ctx: Context, config: Config): void {
396
411
  secureCookies,
397
412
  ...(tls !== undefined ? { tls } : {}),
398
413
  ...(makeRelay !== undefined ? { upstreamSession: makeRelay(dshPort) } : {}),
414
+ onStateChange: (updated) => {
415
+ // The gateway retired a session itself (sign-out). Keep the plugin's
416
+ // copy and the state file in step, or a restart would resurrect a
417
+ // session the user signed out of.
418
+ state = updated
419
+ saveState(state)
420
+ },
399
421
  }, state)
400
422
  await next.listen()
401
423
  gateway = next
402
424
  startedWith = listenerKey(cfg, makeRelay !== undefined)
403
425
  ctx.logger.info(
404
- `dsh-lan-gateway: listening on 0.0.0.0:${cfg.gatewayPort}${tls !== undefined ? ' (TLS)' : ''}`
426
+ `dsh-lan-gateway: listening on ${next.boundAddress()}${tls !== undefined ? ' (TLS)' : ''}`
405
427
  + ` -> 127.0.0.1:${dshPort}${encryptedIngress ? '' : ' (plaintext, explicit allowInsecurePlaintext)'}`
406
428
  + `${makeRelay !== undefined ? ' [shared upstream session relay]' : ' [no upstream session relay: base has no browser-session auth]'}`,
407
429
  )
430
+ if (resolved?.renewed === true) {
431
+ ctx.logger.warn(
432
+ 'dsh-lan-gateway: the self-signed certificate had expired and was replaced with a fresh one '
433
+ + '— clients that had trusted the old certificate must trust the new one.',
434
+ )
435
+ }
408
436
  }
409
437
 
410
438
  const stopGateway = async (): Promise<void> => {
@@ -590,11 +618,12 @@ export function apply(ctx: Context, config: Config): void {
590
618
  return {
591
619
  ok: true,
592
620
  message:
593
- `LAN gateway: ${gateway !== undefined ? `LISTENING on 0.0.0.0:${cfg.gatewayPort}` : 'stopped'}`
621
+ `LAN gateway: ${gateway !== undefined ? `LISTENING on ${gateway.boundAddress()}` : 'stopped'}`
594
622
  + `\n- dsh target: 127.0.0.1:${dshPort}`
595
623
  + `\n- password: ${state.password !== undefined ? 'set' : 'NOT SET'}`
596
624
  + `\n- login required for all sources: true${cfg.lanPasswordless ? ' (LAN/loopback exempt via lanPasswordless)' : ''}`
597
625
  + `\n- session epoch: ${state.sessionEpoch}`
626
+ + `\n- signed-out sessions still held: ${Object.keys(state.revokedSessions ?? {}).length} (each drops when its own cookie would have expired)`
598
627
  + `\n- upstream session relay: ${upstreamSessionAvailable ? 'active (dsh browser-session auth present)' : 'absent (older dsh base)'}`
599
628
  + `\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
629
  + `\n- session cookie: ${cfg.cookieName}, ${cfg.cookieMaxAgeDays}d, ${resolveSecureCookies(cfg) ? 'Secure' : 'no Secure attribute (plaintext browser ingress)'}`
@@ -608,7 +637,7 @@ export function apply(ctx: Context, config: Config): void {
608
637
  manualOverride = true
609
638
  await syncGateway('tool enable')
610
639
  return gateway !== undefined
611
- ? { ok: true, message: `Gateway enabled: listening on 0.0.0.0:${effective().gatewayPort}` }
640
+ ? { ok: true, message: `Gateway enabled: listening on ${gateway.boundAddress()}` }
612
641
  : { ok: false, message: `Failed to enable gateway: ${lastError ?? 'unknown error'}` }
613
642
  },
614
643
  async disable(): Promise<ToolResult> {
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, scryptSync, 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
@@ -60,10 +86,14 @@ export function verifyPassword(state: GatewayState, password: string): boolean {
60
86
  * password change must invalidate sessions the old password authorized.
61
87
  */
62
88
  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 }
89
+ // The new epoch invalidates every cookie on its own account, so the list of
90
+ // individually revoked sessions has nothing left to say: drop it rather than
91
+ // carry entries that can never match again.
92
+ const base: GatewayState = {
93
+ cookieSecret: state.cookieSecret,
94
+ sessionEpoch: state.sessionEpoch + 1,
66
95
  }
96
+ if (password === undefined) return base
67
97
  const salt = randomBytes(16)
68
98
  const hash = scryptSync(password, salt, 64)
69
99
  return {
@@ -72,10 +102,47 @@ export function setPassword(state: GatewayState, password: string | undefined):
72
102
  }
73
103
  }
74
104
 
105
+ /**
106
+ * Record a session id as revoked.
107
+ * @param expiresMs - the revoked cookie's own expiry. Past it the cookie is
108
+ * rejected on its own account, so the entry is no longer needed; dropping
109
+ * expired entries here is what keeps the list bounded.
110
+ */
111
+ export function revokeSession(state: GatewayState, sid: string, expiresMs: number): GatewayState {
112
+ const now = Date.now()
113
+ const revoked: Record<string, number> = {}
114
+ for (const [id, exp] of Object.entries(state.revokedSessions ?? {})) {
115
+ if (exp > now) revoked[id] = exp
116
+ }
117
+ revoked[sid] = expiresMs
118
+ return { ...state, revokedSessions: revoked }
119
+ }
120
+
121
+ /** Whether `sid` names a session that has been signed out. */
122
+ export function isSessionRevoked(state: GatewayState, sid: string | undefined): boolean {
123
+ if (sid === undefined) return false
124
+ return Object.hasOwn(state.revokedSessions ?? {}, sid)
125
+ }
126
+
75
127
  function defaultState(): GatewayState {
76
128
  return { cookieSecret: randomBytes(32).toString('base64'), sessionEpoch: 0 }
77
129
  }
78
130
 
131
+ /** Keep the still-live entries of a persisted revocation list, or undefined. */
132
+ function parseRevokedSessions(raw: unknown): Record<string, number> | undefined {
133
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return undefined
134
+ const now = Date.now()
135
+ const out: Record<string, number> = {}
136
+ let anyLive = false
137
+ for (const [sid, exp] of Object.entries(raw)) {
138
+ if (typeof exp === 'number' && Number.isFinite(exp) && exp > now) {
139
+ out[sid] = exp
140
+ anyLive = true
141
+ }
142
+ }
143
+ return anyLive ? out : undefined
144
+ }
145
+
79
146
  /** Load state; on first run (or a corrupt file) generate a fresh secret. */
80
147
  export function loadState(home: string = homedir()): GatewayState {
81
148
  const dir = stateDir(home)
@@ -91,6 +158,9 @@ export function loadState(home: string = homedir()): GatewayState {
91
158
  : 0
92
159
  const base: GatewayState = { cookieSecret: parsed.cookieSecret, sessionEpoch }
93
160
  if (parsed.password !== undefined) base.password = parsed.password
161
+ // Anything the file lists past its own expiry has lapsed on its own.
162
+ const revoked = parseRevokedSessions(parsed.revokedSessions)
163
+ if (revoked !== undefined) base.revokedSessions = revoked
94
164
  return base
95
165
  }
96
166
  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`.
@@ -146,6 +167,18 @@ export interface CertInfo {
146
167
  san?: string
147
168
  }
148
169
 
170
+ /**
171
+ * Whether a PEM certificate's validity window has already closed. A lapsed
172
+ * certificate is a hard failure browsers will not let the user proceed past,
173
+ * so the listener replaces one rather than keep serving it.
174
+ */
175
+ export function isCertExpired(certPem: string, now: number = Date.now()): boolean {
176
+ const expiresAt = Date.parse(new X509Certificate(certPem).validTo)
177
+ // An unparseable date reads as "not expired": serving the certificate we were
178
+ // handed beats discarding it over a date-parsing quirk.
179
+ return Number.isFinite(expiresAt) && expiresAt <= now
180
+ }
181
+
149
182
  /** Describe a PEM certificate (throws on malformed input). */
150
183
  export function describeCert(certPem: string): CertInfo {
151
184
  const cert = new X509Certificate(certPem)
@@ -73,6 +73,19 @@ function nameValueOnly(setCookie: string): string {
73
73
  return (semi === -1 ? setCookie : setCookie.slice(0, semi)).trim()
74
74
  }
75
75
 
76
+ /**
77
+ * The pathname of a URL, for logging. Never the whole URL: the authenticated
78
+ * URL carries the launch token as a query parameter, and that token is a
79
+ * bearer credential for the upstream harness.
80
+ */
81
+ function pathOf(url: string): string {
82
+ try {
83
+ return new URL(url).pathname
84
+ } catch {
85
+ return '<unparseable>'
86
+ }
87
+ }
88
+
76
89
  /** The cookie name of a `Set-Cookie` string (`''` when it is malformed). */
77
90
  function cookieNameOf(setCookie: string): string {
78
91
  const eq = setCookie.indexOf('=')
@@ -121,7 +134,7 @@ function exchange(
121
134
  try {
122
135
  target = new URL(url)
123
136
  } catch {
124
- log(`exchange: unparseable authenticatedUrl ${url}`)
137
+ log('exchange: authenticatedUrl is not parseable')
125
138
  resolve(undefined)
126
139
  return
127
140
  }
@@ -228,7 +241,9 @@ export class UpstreamSessionRelay implements UpstreamSession {
228
241
  this.log('authenticatedUrl() returned undefined; keeping current session')
229
242
  return this.held?.header
230
243
  }
231
- this.log(`acquiring session from ${url}`)
244
+ // Log only the path: the URL carries the launch token in its query string,
245
+ // and that token is a bearer credential for the upstream harness.
246
+ this.log(`acquiring session from ${pathOf(url)}`)
232
247
  const result = await exchange(url, this.authority, this.port, this.log)
233
248
  if (result !== undefined) {
234
249
  this.held = result