@riceawa/dsh-lan-gateway 0.3.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/LICENSE +20 -0
- package/README.md +221 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +733 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +71 -0
- package/lib/index.js +1310 -0
- package/package.json +86 -0
- package/skills/lan-gateway.md +56 -0
- package/src/auth.ts +182 -0
- package/src/client/index.ts +89 -0
- package/src/client/lan-gateway-card.tsx +603 -0
- package/src/gateway.ts +343 -0
- package/src/index.ts +498 -0
- package/src/login.ts +133 -0
- package/src/state.ts +93 -0
- package/src/tls.ts +164 -0
- package/src/tool.ts +82 -0
- package/src/x509.ts +314 -0
package/src/gateway.ts
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reverse-proxy gateway: a `node:http` server bound to `0.0.0.0` that
|
|
3
|
+
* forwards every request to the loopback dsh web server, rewriting Host and
|
|
4
|
+
* Origin so the dsh `/api` trust fence (which only trusts loopback) passes.
|
|
5
|
+
*
|
|
6
|
+
* Security model:
|
|
7
|
+
* - Source is classified from `socket.remoteAddress` only (never
|
|
8
|
+
* `X-Forwarded-For`). LAN/loopback sources are proxied without a password;
|
|
9
|
+
* anything else must present a valid signed cookie or complete the login.
|
|
10
|
+
* - Because this gateway rewrites Origin to loopback, dsh's own CSRF fence is
|
|
11
|
+
* blinded — so the gateway runs its own origin check on `/api*` requests
|
|
12
|
+
* BEFORE rewriting (reject `sec-fetch-site: cross-site` and any Origin that
|
|
13
|
+
* does not match the gateway authority the browser actually used).
|
|
14
|
+
*
|
|
15
|
+
* @module @riceawa/dsh-lan-gateway/gateway
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import http from 'node:http'
|
|
19
|
+
import https from 'node:https'
|
|
20
|
+
import type { Duplex } from 'node:stream'
|
|
21
|
+
import {
|
|
22
|
+
classifySource,
|
|
23
|
+
RateLimiter,
|
|
24
|
+
signCookie,
|
|
25
|
+
verifyCookie,
|
|
26
|
+
type SourceClass,
|
|
27
|
+
} from './auth.ts'
|
|
28
|
+
import {
|
|
29
|
+
COOKIE_NAME,
|
|
30
|
+
LOGIN_PATH,
|
|
31
|
+
readBody,
|
|
32
|
+
renderLoginPage,
|
|
33
|
+
serveLoginGet,
|
|
34
|
+
type LoginPageOptions,
|
|
35
|
+
} from './login.ts'
|
|
36
|
+
import { verifyPassword, type GatewayState } from './state.ts'
|
|
37
|
+
|
|
38
|
+
/** Configuration the gateway needs at listen time. */
|
|
39
|
+
export interface GatewayConfig {
|
|
40
|
+
/** Port to bind on 0.0.0.0. */
|
|
41
|
+
gatewayPort: number
|
|
42
|
+
/** The loopback dsh web server port to forward to. */
|
|
43
|
+
dshPort: number
|
|
44
|
+
/** LAN CIDRs treated as password-free. */
|
|
45
|
+
lanCidrs: readonly string[]
|
|
46
|
+
/** Whether non-LAN sources require a password. */
|
|
47
|
+
authRequired: boolean
|
|
48
|
+
/** Cookie lifetime in days. */
|
|
49
|
+
cookieMaxAgeDays: number
|
|
50
|
+
/** Cookie name. */
|
|
51
|
+
cookieName: string
|
|
52
|
+
/** PEM cert/key material; when present the listener speaks HTTPS. */
|
|
53
|
+
tls?: { cert: string; key: string }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const DEFAULT_BODY_LIMIT_BYTES = 64 * 1024
|
|
57
|
+
const LOGIN_ATTEMPTS_LIMIT = 5
|
|
58
|
+
const LOGIN_ATTEMPTS_WINDOW_MS = 60_000
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The running gateway: owns the HTTP server and the auth state needed per
|
|
62
|
+
* request. Created by the plugin on enable; torn down by the plugin on
|
|
63
|
+
* disable or tree disposal.
|
|
64
|
+
*/
|
|
65
|
+
export class LanGateway {
|
|
66
|
+
readonly server: http.Server
|
|
67
|
+
private readonly loginLimiter = new RateLimiter(LOGIN_ATTEMPTS_LIMIT, LOGIN_ATTEMPTS_WINDOW_MS)
|
|
68
|
+
private state: GatewayState
|
|
69
|
+
private disposed = false
|
|
70
|
+
|
|
71
|
+
constructor(private readonly config: GatewayConfig, state: GatewayState) {
|
|
72
|
+
this.state = state
|
|
73
|
+
const handle = (req: http.IncomingMessage, res: http.ServerResponse): void => {
|
|
74
|
+
void this.handleHttp(req, res)
|
|
75
|
+
}
|
|
76
|
+
this.server = this.config.tls !== undefined
|
|
77
|
+
? https.createServer({ cert: this.config.tls.cert, key: this.config.tls.key }, handle)
|
|
78
|
+
: http.createServer(handle)
|
|
79
|
+
this.server.on('upgrade', (req, socket, head) => {
|
|
80
|
+
void this.handleUpgrade(req, socket, head)
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Replace the in-memory state (e.g. after a password change). */
|
|
85
|
+
setState(state: GatewayState): void {
|
|
86
|
+
this.state = state
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Start listening; rejects if the port is already in use. */
|
|
90
|
+
async listen(): Promise<void> {
|
|
91
|
+
return new Promise((resolve, reject) => {
|
|
92
|
+
const onError = (err: Error): void => {
|
|
93
|
+
this.server.off('listening', onListening)
|
|
94
|
+
reject(err)
|
|
95
|
+
}
|
|
96
|
+
const onListening = (): void => {
|
|
97
|
+
this.server.off('error', onError)
|
|
98
|
+
resolve()
|
|
99
|
+
}
|
|
100
|
+
this.server.once('error', onError)
|
|
101
|
+
this.server.once('listening', onListening)
|
|
102
|
+
this.server.listen(this.config.gatewayPort, '0.0.0.0')
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Close the server and stop accepting connections. */
|
|
107
|
+
async close(): Promise<void> {
|
|
108
|
+
if (this.disposed) return
|
|
109
|
+
this.disposed = true
|
|
110
|
+
return new Promise((resolve) => {
|
|
111
|
+
this.server.close(() => resolve())
|
|
112
|
+
this.server.closeAllConnections()
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private sourceClass(req: http.IncomingMessage): SourceClass {
|
|
117
|
+
return classifySource(req.socket.remoteAddress, this.config.lanCidrs)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Parse the session cookie out of a Cookie header. */
|
|
121
|
+
private sessionCookie(req: http.IncomingMessage): string | undefined {
|
|
122
|
+
const header = req.headers.cookie
|
|
123
|
+
if (typeof header !== 'string') return undefined
|
|
124
|
+
for (const part of header.split(';')) {
|
|
125
|
+
const trimmed = part.trim()
|
|
126
|
+
if (trimmed.startsWith(`${this.config.cookieName}=`)) {
|
|
127
|
+
return trimmed.slice(this.config.cookieName.length + 1)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return undefined
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Whether a request carries a valid session for its source. */
|
|
134
|
+
private authorized(req: http.IncomingMessage): boolean {
|
|
135
|
+
const cookie = this.sessionCookie(req)
|
|
136
|
+
return cookie !== undefined && verifyCookie(this.state.cookieSecret, cookie, Date.now())
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private serveUnauthorized(res: http.ServerResponse, limited: boolean): void {
|
|
140
|
+
res.writeHead(302, {
|
|
141
|
+
location: `${LOGIN_PATH}${limited ? '?limited=1' : ''}`,
|
|
142
|
+
...this.securityHeaders(),
|
|
143
|
+
})
|
|
144
|
+
res.end()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private serveLoginError(res: http.ServerResponse, message: string): void {
|
|
148
|
+
const opts: LoginPageOptions = { error: message }
|
|
149
|
+
res.writeHead(401, {
|
|
150
|
+
'content-type': 'text/html; charset=utf-8',
|
|
151
|
+
'cache-control': 'no-store',
|
|
152
|
+
...this.securityHeaders(),
|
|
153
|
+
})
|
|
154
|
+
res.end(renderLoginPage(opts))
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** HSTS when the listener is HTTPS (never sent on plain HTTP). */
|
|
158
|
+
private securityHeaders(): http.OutgoingHttpHeaders {
|
|
159
|
+
return this.config.tls === undefined
|
|
160
|
+
? {}
|
|
161
|
+
: { 'strict-transport-security': 'max-age=15552000' }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Handle one HTTP request: auth gate → CSRF fence → forward. */
|
|
165
|
+
private async handleHttp(req: http.IncomingMessage, res: http.ServerResponse): Promise<void> {
|
|
166
|
+
const source = this.sourceClass(req)
|
|
167
|
+
const url = req.url ?? '/'
|
|
168
|
+
const pathname = url.split('?')[0] ?? '/'
|
|
169
|
+
|
|
170
|
+
if (pathname === LOGIN_PATH) {
|
|
171
|
+
this.handleLogin(req, res)
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (source === 'internet' && this.config.authRequired) {
|
|
176
|
+
if (!this.authorized(req)) {
|
|
177
|
+
this.serveUnauthorized(res, false)
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// CSRF fence for /api before any rewriting (see module docs).
|
|
183
|
+
if (pathname === '/api' || pathname.startsWith('/api/')) {
|
|
184
|
+
if (!this.passesCsrfFence(req)) {
|
|
185
|
+
res.writeHead(403, this.securityHeaders())
|
|
186
|
+
res.end('forbidden')
|
|
187
|
+
return
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
this.forward(req, res, url)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Reject cross-site API traffic: the gateway's own origin check. */
|
|
195
|
+
private passesCsrfFence(req: http.IncomingMessage): boolean {
|
|
196
|
+
const headers = req.headers
|
|
197
|
+
if (headers['sec-fetch-site'] === 'cross-site') return false
|
|
198
|
+
const origin = headers.origin
|
|
199
|
+
if (origin === undefined) return true
|
|
200
|
+
try {
|
|
201
|
+
const originHost = new URL(origin).host
|
|
202
|
+
const requestHost = typeof headers.host === 'string' ? headers.host : ''
|
|
203
|
+
// Compare with the gateway authority the browser actually used; a
|
|
204
|
+
// browser always fills Host from the URL it loaded.
|
|
205
|
+
return originHost === requestHost || originHost === stripDefaultPort(requestHost)
|
|
206
|
+
} catch {
|
|
207
|
+
return false
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Handle the login GET form / POST submission. */
|
|
212
|
+
private handleLogin(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
213
|
+
const limited = req.url?.includes('limited=1') ?? false
|
|
214
|
+
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
215
|
+
serveLoginGet(res, this.securityHeaders())
|
|
216
|
+
return
|
|
217
|
+
}
|
|
218
|
+
if (req.method !== 'POST') {
|
|
219
|
+
res.writeHead(405, { allow: 'GET, POST' })
|
|
220
|
+
res.end()
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const key = req.socket.remoteAddress ?? 'unknown'
|
|
225
|
+
if (!this.loginLimiter.allow(key)) {
|
|
226
|
+
this.serveLoginError(res, 'Too many attempts — please wait a minute.')
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
void readBody(req, DEFAULT_BODY_LIMIT_BYTES, res).then((body) => {
|
|
231
|
+
if (body === undefined) return // response already sent (413/400)
|
|
232
|
+
let password: string | undefined
|
|
233
|
+
try {
|
|
234
|
+
const fields = new URLSearchParams(body)
|
|
235
|
+
password = fields.get('password') ?? undefined
|
|
236
|
+
} catch {
|
|
237
|
+
password = undefined
|
|
238
|
+
}
|
|
239
|
+
if (password === undefined || !verifyPassword(this.state, password)) {
|
|
240
|
+
this.serveLoginError(res, 'Incorrect password.')
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
const expiresMs = Date.now() + this.config.cookieMaxAgeDays * 86_400_000
|
|
244
|
+
const cookie = signCookie(this.state.cookieSecret, expiresMs)
|
|
245
|
+
const secure = this.config.tls !== undefined ? '; Secure' : ''
|
|
246
|
+
res.writeHead(302, {
|
|
247
|
+
location: '/',
|
|
248
|
+
...this.securityHeaders(),
|
|
249
|
+
'set-cookie': [
|
|
250
|
+
`${this.config.cookieName}=${cookie}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${this.config.cookieMaxAgeDays * 86_400}${secure}`,
|
|
251
|
+
],
|
|
252
|
+
})
|
|
253
|
+
res.end()
|
|
254
|
+
})
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Forward an HTTP request to dsh, rewriting Host/Origin to loopback. */
|
|
258
|
+
private forward(req: http.IncomingMessage, res: http.ServerResponse, url: string): void {
|
|
259
|
+
const headers: http.OutgoingHttpHeaders = { ...req.headers }
|
|
260
|
+
headers.host = `127.0.0.1:${this.config.dshPort}`
|
|
261
|
+
if (typeof headers.origin === 'string') {
|
|
262
|
+
headers.origin = `http://127.0.0.1:${this.config.dshPort}`
|
|
263
|
+
}
|
|
264
|
+
// Hop-by-hop headers the gateway must not forward.
|
|
265
|
+
delete headers['proxy-connection']
|
|
266
|
+
delete headers.connection
|
|
267
|
+
|
|
268
|
+
const proxyReq = http.request({
|
|
269
|
+
host: '127.0.0.1',
|
|
270
|
+
port: this.config.dshPort,
|
|
271
|
+
method: req.method,
|
|
272
|
+
path: url,
|
|
273
|
+
headers,
|
|
274
|
+
}, (proxyRes) => {
|
|
275
|
+
res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers)
|
|
276
|
+
proxyRes.pipe(res)
|
|
277
|
+
})
|
|
278
|
+
proxyReq.on('error', () => {
|
|
279
|
+
if (!res.headersSent) {
|
|
280
|
+
res.writeHead(502)
|
|
281
|
+
}
|
|
282
|
+
res.destroy()
|
|
283
|
+
})
|
|
284
|
+
req.pipe(proxyReq)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Forward a WebSocket upgrade, splicing the raw duplex through to dsh. */
|
|
288
|
+
private handleUpgrade(req: http.IncomingMessage, socket: Duplex, head: Buffer): void {
|
|
289
|
+
const source = this.sourceClass(req)
|
|
290
|
+
if (source === 'internet' && this.config.authRequired && !this.authorized(req)) {
|
|
291
|
+
socket.write(
|
|
292
|
+
'HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n',
|
|
293
|
+
)
|
|
294
|
+
socket.destroy()
|
|
295
|
+
return
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const headers: http.OutgoingHttpHeaders = { ...req.headers }
|
|
299
|
+
headers.host = `127.0.0.1:${this.config.dshPort}`
|
|
300
|
+
if (typeof headers.origin === 'string') {
|
|
301
|
+
headers.origin = `http://127.0.0.1:${this.config.dshPort}`
|
|
302
|
+
}
|
|
303
|
+
// Unlike the plain-HTTP path, keep Connection: Upgrade / Upgrade: websocket
|
|
304
|
+
// so dsh answers with 101 and node's client emits 'upgrade'.
|
|
305
|
+
delete headers['proxy-connection']
|
|
306
|
+
|
|
307
|
+
const proxyReq = http.request({
|
|
308
|
+
host: '127.0.0.1',
|
|
309
|
+
port: this.config.dshPort,
|
|
310
|
+
method: 'GET',
|
|
311
|
+
path: req.url ?? '/',
|
|
312
|
+
headers,
|
|
313
|
+
})
|
|
314
|
+
proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
|
|
315
|
+
// node's http client has already consumed the 101 response headers, so
|
|
316
|
+
// reconstruct them on the client socket before splicing.
|
|
317
|
+
const statusLine = `HTTP/1.1 ${proxyRes.statusCode ?? 101} ${proxyRes.statusMessage ?? 'Switching Protocols'}\r\n`
|
|
318
|
+
const headerLines = Object.entries(proxyRes.headers)
|
|
319
|
+
.map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(', ') : value}\r\n`)
|
|
320
|
+
.join('')
|
|
321
|
+
socket.write(`${statusLine}${headerLines}\r\n`)
|
|
322
|
+
// Forward the client's own head bytes (initial WebSocket frames) to dsh.
|
|
323
|
+
if (head !== undefined && head.length > 0) {
|
|
324
|
+
proxySocket.write(head)
|
|
325
|
+
}
|
|
326
|
+
proxySocket.pipe(socket).pipe(proxySocket)
|
|
327
|
+
if (proxyHead !== undefined && proxyHead.length > 0) {
|
|
328
|
+
proxySocket.unshift(proxyHead)
|
|
329
|
+
}
|
|
330
|
+
socket.on('error', () => proxySocket.destroy())
|
|
331
|
+
proxySocket.on('error', () => socket.destroy())
|
|
332
|
+
})
|
|
333
|
+
proxyReq.on('error', () => socket.destroy())
|
|
334
|
+
proxyReq.end()
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Strip an explicit default port from a Host authority, if present. */
|
|
339
|
+
function stripDefaultPort(host: string): string {
|
|
340
|
+
const parsed = /^(.+?)(?::(\d+))?$/.exec(host)
|
|
341
|
+
if (parsed?.[2] === '80' || parsed?.[2] === '443') return parsed[1]!
|
|
342
|
+
return host
|
|
343
|
+
}
|