@riceawa/dsh-lan-gateway 0.3.0 → 0.5.0
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 +145 -55
- package/cordis.patch.yml +5 -2
- package/lib/client.js +78 -44
- package/lib/client.js.map +1 -1
- package/lib/index.d.ts +52 -5
- package/lib/index.js +470 -108
- package/package.json +2 -2
- package/skills/lan-gateway.md +47 -29
- package/src/auth.ts +39 -6
- package/src/client/index.ts +16 -2
- package/src/client/lan-gateway-card.tsx +56 -21
- package/src/gateway.ts +230 -86
- package/src/index.ts +196 -48
- package/src/login.ts +3 -0
- package/src/state.ts +27 -6
- package/src/tool.ts +12 -8
- package/src/upstream-session.ts +187 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared upstream session relay for session-capable dsh bases (>= 0.1.2).
|
|
3
|
+
*
|
|
4
|
+
* When dsh added browser-session authentication it stopped trusting a loopback
|
|
5
|
+
* Host header alone: every `/api` request (and the remote WebSocket mux) must
|
|
6
|
+
* now present a signed cookie bound to the authority it names
|
|
7
|
+
* (`dsh-auth-<sha256(authority)>`), minted at the index route by exchanging the
|
|
8
|
+
* process launch token. A reverse proxy that rewrites Host to loopback — which
|
|
9
|
+
* is what this gateway does — therefore gets a 401 no matter how the Host is
|
|
10
|
+
* forged. The gateway cannot mint that cookie itself (the signing secret lives
|
|
11
|
+
* in dsh's credential provider), so it does exactly what a browser does: on the
|
|
12
|
+
* loopback transport it visits the launch-token URL, keeps the Set-Cookie it
|
|
13
|
+
* earns, and replays that one shared session on every request it forwards.
|
|
14
|
+
*
|
|
15
|
+
* Semantics match the pre-existing "single password = single operator" model:
|
|
16
|
+
* whoever passes the gateway's own login rides this one upstream session. It is
|
|
17
|
+
* not multi-user authorization, and upstream (which holds the secret) remains
|
|
18
|
+
* the actual authority over what the session may do.
|
|
19
|
+
*
|
|
20
|
+
* The relay is a no-op on a base without browser sessions: acquisition fails
|
|
21
|
+
* and `cookie()` returns undefined, so the gateway simply forwards without a
|
|
22
|
+
* session cookie exactly as it did against an older dsh.
|
|
23
|
+
*
|
|
24
|
+
* @module @riceawa/dsh-lan-gateway/upstream-session
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import http from 'node:http'
|
|
28
|
+
|
|
29
|
+
/** The session-cookie name prefix upstream signs (`dsh-auth-<b64url(sha256)>`). */
|
|
30
|
+
const UPSTREAM_COOKIE_PREFIX = 'dsh-auth-'
|
|
31
|
+
|
|
32
|
+
/** A held session: the raw `name=value` and the epoch-millis it lapses. */
|
|
33
|
+
interface HeldCookie {
|
|
34
|
+
header: string
|
|
35
|
+
expiresAt: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The minimal shared-session contract the gateway consumes. */
|
|
39
|
+
export interface UpstreamSession {
|
|
40
|
+
/** The current `name=value` without triggering a re-acquisition. */
|
|
41
|
+
peek(): string | undefined
|
|
42
|
+
/** The current `name=value`, re-acquiring when missing or stale. Never throws. */
|
|
43
|
+
cookie(): Promise<string | undefined>
|
|
44
|
+
/** Forget a session upstream rejected, so the next request re-acquires. */
|
|
45
|
+
invalidate(): void
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface UpstreamSessionRelayOptions {
|
|
49
|
+
/** The loopback dsh port both the exchange and every forward target. */
|
|
50
|
+
port: number
|
|
51
|
+
/** A host[:port] to send as Host on the exchange (default `127.0.0.1:<port>`). */
|
|
52
|
+
authority?: string
|
|
53
|
+
/**
|
|
54
|
+
* Returns the launch-token-carrying root URL for the upstream origin
|
|
55
|
+
* (`http://127.0.0.1:<port>/?token=…`), or undefined when the upstream does
|
|
56
|
+
* not expose one. Re-read on every acquisition so a fresh token after an
|
|
57
|
+
* upstream restart is picked up.
|
|
58
|
+
*/
|
|
59
|
+
authenticatedUrl: () => string | undefined
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Split `name=value; Path=/; …` into the `name=value` request-Cookie fragment. */
|
|
63
|
+
function nameValueOnly(setCookie: string): string {
|
|
64
|
+
const semi = setCookie.indexOf(';')
|
|
65
|
+
return (semi === -1 ? setCookie : setCookie.slice(0, semi)).trim()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Pull the Max-Age attribute (seconds) out of a Set-Cookie string, if any. */
|
|
69
|
+
function maxAgeSeconds(setCookie: string): number | undefined {
|
|
70
|
+
const match = /\bMax-Age=(\d+)\b/i.exec(setCookie)
|
|
71
|
+
return match === null ? undefined : Number(match[1])
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The result of one token exchange. */
|
|
75
|
+
interface ExchangeResult {
|
|
76
|
+
header: string
|
|
77
|
+
expiresAt: number
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Perform the token exchange over loopback: GET the launch-token URL with the
|
|
82
|
+
* upstream authority as Host, read the Set-Cookie the index route mints, and
|
|
83
|
+
* return its `name=value` plus expiry (or undefined when the exchange failed
|
|
84
|
+
* or no session cookie came back — e.g. an older base without browser
|
|
85
|
+
* sessions).
|
|
86
|
+
*/
|
|
87
|
+
function exchange(
|
|
88
|
+
url: string,
|
|
89
|
+
authority: string,
|
|
90
|
+
port: number,
|
|
91
|
+
): Promise<ExchangeResult | undefined> {
|
|
92
|
+
return new Promise((resolve) => {
|
|
93
|
+
let target: URL
|
|
94
|
+
try {
|
|
95
|
+
target = new URL(url)
|
|
96
|
+
} catch {
|
|
97
|
+
resolve(undefined)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
const request = http.request({
|
|
101
|
+
host: '127.0.0.1',
|
|
102
|
+
port,
|
|
103
|
+
method: 'GET',
|
|
104
|
+
path: `${target.pathname}${target.search}`,
|
|
105
|
+
headers: { host: authority, accept: 'text/html' },
|
|
106
|
+
}, (response) => {
|
|
107
|
+
const setCookies = response.headers['set-cookie']
|
|
108
|
+
response.resume() // drain so the socket can be reused
|
|
109
|
+
if (setCookies === undefined) {
|
|
110
|
+
resolve(undefined)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
const raw = (Array.isArray(setCookies) ? setCookies : [setCookies])
|
|
114
|
+
.find((value) => value.startsWith(`${UPSTREAM_COOKIE_PREFIX}=`))
|
|
115
|
+
if (raw === undefined) {
|
|
116
|
+
resolve(undefined)
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
const header = nameValueOnly(raw)
|
|
120
|
+
const maxAge = maxAgeSeconds(raw)
|
|
121
|
+
resolve({ header, expiresAt: Date.now() + (maxAge ?? 0) * 1000 })
|
|
122
|
+
})
|
|
123
|
+
request.on('error', () => resolve(undefined))
|
|
124
|
+
request.setTimeout(5000, () => request.destroy(new Error('upstream-session exchange timeout')))
|
|
125
|
+
request.end()
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* A cached {@link UpstreamSession} acquired through the launch-token exchange.
|
|
131
|
+
* Acquisition runs at most once concurrently and the result is cached until it
|
|
132
|
+
* nears expiry or {@link invalidate} is called.
|
|
133
|
+
*/
|
|
134
|
+
export class UpstreamSessionRelay implements UpstreamSession {
|
|
135
|
+
private readonly port: number
|
|
136
|
+
private readonly authority: string
|
|
137
|
+
private readonly authenticatedUrl: () => string | undefined
|
|
138
|
+
private held: HeldCookie | undefined
|
|
139
|
+
private inflight: Promise<string | undefined> | undefined
|
|
140
|
+
|
|
141
|
+
constructor(options: UpstreamSessionRelayOptions) {
|
|
142
|
+
this.port = options.port
|
|
143
|
+
this.authority = options.authority ?? `127.0.0.1:${options.port}`
|
|
144
|
+
this.authenticatedUrl = options.authenticatedUrl
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Whether the held session is still comfortably inside its lifetime. */
|
|
148
|
+
private fresh(): boolean {
|
|
149
|
+
const held = this.held
|
|
150
|
+
if (held === undefined) return false
|
|
151
|
+
// Refresh up to a minute before the cookie actually lapses so a slow
|
|
152
|
+
// request is never rejected mid-flight by an expiring session.
|
|
153
|
+
return Date.now() < held.expiresAt - 60_000
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
peek(): string | undefined {
|
|
157
|
+
return this.held?.header
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
invalidate(): void {
|
|
161
|
+
this.held = undefined
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async cookie(): Promise<string | undefined> {
|
|
165
|
+
if (this.fresh()) return this.held?.header
|
|
166
|
+
return this.acquire()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private acquire(): Promise<string | undefined> {
|
|
170
|
+
if (this.inflight !== undefined) return this.inflight
|
|
171
|
+
const pending = this.doExchange().finally(() => {
|
|
172
|
+
this.inflight = undefined
|
|
173
|
+
})
|
|
174
|
+
this.inflight = pending
|
|
175
|
+
return pending
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private async doExchange(): Promise<string | undefined> {
|
|
179
|
+
const url = this.authenticatedUrl()
|
|
180
|
+
if (url === undefined) return undefined
|
|
181
|
+
const result = await exchange(url, this.authority, this.port)
|
|
182
|
+
if (result !== undefined) this.held = result
|
|
183
|
+
// On a transient failure keep whatever session is still held rather than
|
|
184
|
+
// dropping to anonymous.
|
|
185
|
+
return this.held?.header
|
|
186
|
+
}
|
|
187
|
+
}
|