@riceawa/dsh-lan-gateway 0.5.2 → 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 +134 -238
- package/lib/client.js +68 -32
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +34 -3
- package/lib/index.js +426 -83
- package/package.json +3 -2
- package/skills/lan-gateway.md +8 -4
- package/src/auth.ts +98 -26
- package/src/client/lan-gateway-card.tsx +46 -2
- package/src/gateway.ts +238 -28
- package/src/index.ts +82 -16
- package/src/state.ts +77 -7
- package/src/tls.ts +33 -0
- package/src/upstream-session.ts +60 -7
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
|
@@ -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,19 @@ function nameValueOnly(setCookie: string): string {
|
|
|
65
73
|
return (semi === -1 ? setCookie : setCookie.slice(0, semi)).trim()
|
|
66
74
|
}
|
|
67
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
|
+
|
|
68
89
|
/** The cookie name of a `Set-Cookie` string (`''` when it is malformed). */
|
|
69
90
|
function cookieNameOf(setCookie: string): string {
|
|
70
91
|
const eq = setCookie.indexOf('=')
|
|
@@ -106,12 +127,14 @@ function exchange(
|
|
|
106
127
|
url: string,
|
|
107
128
|
authority: string,
|
|
108
129
|
port: number,
|
|
130
|
+
log: (message: string) => void,
|
|
109
131
|
): Promise<ExchangeResult | undefined> {
|
|
110
132
|
return new Promise((resolve) => {
|
|
111
133
|
let target: URL
|
|
112
134
|
try {
|
|
113
135
|
target = new URL(url)
|
|
114
136
|
} catch {
|
|
137
|
+
log('exchange: authenticatedUrl is not parseable')
|
|
115
138
|
resolve(undefined)
|
|
116
139
|
return
|
|
117
140
|
}
|
|
@@ -125,21 +148,33 @@ function exchange(
|
|
|
125
148
|
const setCookies = response.headers['set-cookie']
|
|
126
149
|
response.resume() // drain so the socket can be reused
|
|
127
150
|
if (setCookies === undefined) {
|
|
151
|
+
log(`exchange ${target.pathname} -> ${response.statusCode} (no set-cookie)`)
|
|
128
152
|
resolve(undefined)
|
|
129
153
|
return
|
|
130
154
|
}
|
|
131
|
-
const
|
|
132
|
-
|
|
155
|
+
const all = Array.isArray(setCookies) ? setCookies : [setCookies]
|
|
156
|
+
const raw = all.find(isUpstreamSessionCookie)
|
|
133
157
|
if (raw === undefined) {
|
|
158
|
+
// Name what did come back: a missing session cookie is otherwise
|
|
159
|
+
// indistinguishable from an upstream that mints a differently-named one.
|
|
160
|
+
const names = all.map(cookieNameOf).filter((name) => name !== '')
|
|
161
|
+
log(`exchange ${target.pathname} -> ${response.statusCode} (no ${UPSTREAM_COOKIE_PREFIX}* cookie; got: ${names.join(', ') || 'none'})`)
|
|
134
162
|
resolve(undefined)
|
|
135
163
|
return
|
|
136
164
|
}
|
|
137
165
|
const header = nameValueOnly(raw)
|
|
138
166
|
const maxAge = maxAgeSeconds(raw)
|
|
167
|
+
log(`exchange ${target.pathname} -> ${response.statusCode} (got ${cookieNameOf(raw)}, maxAge=${maxAge ?? 'n/a'})`)
|
|
139
168
|
resolve({ header, expiresAt: Date.now() + (maxAge ?? 0) * 1000 })
|
|
140
169
|
})
|
|
141
|
-
request.on('error', () =>
|
|
142
|
-
|
|
170
|
+
request.on('error', (error: Error) => {
|
|
171
|
+
log(`exchange error: ${error.message}`)
|
|
172
|
+
resolve(undefined)
|
|
173
|
+
})
|
|
174
|
+
request.setTimeout(5000, () => {
|
|
175
|
+
log('exchange timeout (5s)')
|
|
176
|
+
request.destroy(new Error('upstream-session exchange timeout'))
|
|
177
|
+
})
|
|
143
178
|
request.end()
|
|
144
179
|
})
|
|
145
180
|
}
|
|
@@ -153,6 +188,7 @@ export class UpstreamSessionRelay implements UpstreamSession {
|
|
|
153
188
|
private readonly port: number
|
|
154
189
|
private readonly authority: string
|
|
155
190
|
private readonly authenticatedUrl: () => string | undefined
|
|
191
|
+
private readonly log: (message: string) => void
|
|
156
192
|
private held: HeldCookie | undefined
|
|
157
193
|
private inflight: Promise<string | undefined> | undefined
|
|
158
194
|
|
|
@@ -160,6 +196,7 @@ export class UpstreamSessionRelay implements UpstreamSession {
|
|
|
160
196
|
this.port = options.port
|
|
161
197
|
this.authority = options.authority ?? `127.0.0.1:${options.port}`
|
|
162
198
|
this.authenticatedUrl = options.authenticatedUrl
|
|
199
|
+
this.log = options.log ?? (() => { /* no debug sink configured */ })
|
|
163
200
|
}
|
|
164
201
|
|
|
165
202
|
/** Whether the held session is still comfortably inside its lifetime. */
|
|
@@ -176,6 +213,7 @@ export class UpstreamSessionRelay implements UpstreamSession {
|
|
|
176
213
|
}
|
|
177
214
|
|
|
178
215
|
invalidate(): void {
|
|
216
|
+
if (this.held !== undefined) this.log('invalidating held session (upstream rejected it)')
|
|
179
217
|
this.held = undefined
|
|
180
218
|
}
|
|
181
219
|
|
|
@@ -195,9 +233,24 @@ export class UpstreamSessionRelay implements UpstreamSession {
|
|
|
195
233
|
|
|
196
234
|
private async doExchange(): Promise<string | undefined> {
|
|
197
235
|
const url = this.authenticatedUrl()
|
|
198
|
-
if (url === undefined)
|
|
199
|
-
|
|
200
|
-
|
|
236
|
+
if (url === undefined) {
|
|
237
|
+
// The launch URL is transiently unavailable (e.g. the connection service
|
|
238
|
+
// is mid-restart). Keep whatever is held: returning undefined here would
|
|
239
|
+
// forward with no cookie at all and draw a 401 upstream, which is worse
|
|
240
|
+
// than riding a session that is probably still valid.
|
|
241
|
+
this.log('authenticatedUrl() returned undefined; keeping current session')
|
|
242
|
+
return this.held?.header
|
|
243
|
+
}
|
|
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)}`)
|
|
247
|
+
const result = await exchange(url, this.authority, this.port, this.log)
|
|
248
|
+
if (result !== undefined) {
|
|
249
|
+
this.held = result
|
|
250
|
+
this.log('session acquired and cached')
|
|
251
|
+
} else {
|
|
252
|
+
this.log('exchange failed; keeping current session')
|
|
253
|
+
}
|
|
201
254
|
// On a transient failure keep whatever session is still held rather than
|
|
202
255
|
// dropping to anonymous.
|
|
203
256
|
return this.held?.header
|