@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/README.md +47 -40
- package/lib/index.d.ts +13 -2
- package/lib/index.js +375 -74
- package/package.json +1 -1
- package/skills/lan-gateway.md +8 -4
- package/src/auth.ts +98 -26
- package/src/gateway.ts +238 -28
- package/src/index.ts +42 -13
- package/src/state.ts +77 -7
- package/src/tls.ts +33 -0
- package/src/upstream-session.ts +17 -2
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
|
|
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)
|
|
26
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
45
|
-
|
|
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 =
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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)
|
package/src/upstream-session.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|