dsh-remote-access-proxy 0.0.0-stage → 1.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/LICENSE +21 -0
- package/README.md +161 -2
- package/README.zh-CN.md +164 -0
- package/client/index.js +541 -0
- package/config/cordis.patch.example.yml +43 -0
- package/cordis.patch.yml +18 -0
- package/icon.svg +4 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +66 -4
- package/plugin/dsh-remote-access-proxy.mjs +1224 -0
- package/test-remote-access-proxy.mjs +993 -0
|
@@ -0,0 +1,1224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-remote-access-proxy — embedded open RPC gateway for the DSH web GUI, as a
|
|
3
|
+
* host-plane plugin.
|
|
4
|
+
*
|
|
5
|
+
* - Starts/stops with DSH (plugin apply/dispose), no external tool needed.
|
|
6
|
+
* - Listens on every interface (easytier, ZeroTier, Tailscale, a plain LAN or
|
|
7
|
+
* a reverse-proxy front all work); `allowFrom` decides which client
|
|
8
|
+
* addresses may connect, dropping the rest at the TCP level.
|
|
9
|
+
* - Random-path gate, an optional password sign-in, and a signed 30-day
|
|
10
|
+
* remembered-device cookie in front of the DSH app and its /api.
|
|
11
|
+
* - Forwards to DSH with Host rewritten to loopback and Origin stripped, so
|
|
12
|
+
* the /api trust fence accepts any front — no per-host trustedHosts needed.
|
|
13
|
+
* - Forwards to the running DSH instance: `upstreamHost`/`upstreamPort` default
|
|
14
|
+
* to auto-detect from the live `webServer` service (the port the OS actually
|
|
15
|
+
* bound — the desktop launcher asks for `--port 0`, so only that service knows
|
|
16
|
+
* it), falling back to `webStartup` and then to 3080, so no configuration is
|
|
17
|
+
* needed; explicit values override.
|
|
18
|
+
* - Optional TLS (self-signed pfx): browsers require a secure context for
|
|
19
|
+
* crypto.randomUUID, so plain-HTTP remote access breaks the web client.
|
|
20
|
+
* - Configured from the Plugins page: the bundle's row page (sidebar → Plugins
|
|
21
|
+
* → dsh-remote-access-proxy → the `remote-access-proxy` row → configure).
|
|
22
|
+
* A volatile-only edit never remounts this plugin; it restarts the embedded
|
|
23
|
+
* server in place.
|
|
24
|
+
* - Diagnostics go to a size-bounded `access.log` beside this file: it rotates
|
|
25
|
+
* at `logMaxBytes` and keeps `logKeep` older files (`access.log.1`…), so a
|
|
26
|
+
* long-running deployment cannot grow an unbounded log.
|
|
27
|
+
* - Updates are managed by DSH through the npm registry.
|
|
28
|
+
*
|
|
29
|
+
* DSH browser-auth bridge (2026-09-04): DSH now authenticates every browser
|
|
30
|
+
* session with a per-process launch token (`http://<upstream>/?token=…`)
|
|
31
|
+
* plus an authority-bound signed cookie (`dsh-auth-*`); without it every /api
|
|
32
|
+
* request is 401. This proxy therefore performs the token exchange itself and
|
|
33
|
+
* forwards carrying the minted DSH cookie, so remote users keep the old gate
|
|
34
|
+
* UX (random path + HttpOnly cookie) and never need to know the launch token.
|
|
35
|
+
* The cookie is signed with a durable secret and lives 30 days, surviving DSH
|
|
36
|
+
* restarts; an upstream 401 drops it and triggers a fresh exchange.
|
|
37
|
+
*
|
|
38
|
+
* DSH 0.1.7 configuration model (2026-09-24): `settings.yaml` is gone. A plugin
|
|
39
|
+
* declares its configurable values in its own Cordis `Config`, marks the ones
|
|
40
|
+
* that may change without a remount `.volatile()`, and reads them through those
|
|
41
|
+
* references; Loader commits volatile-only changes in place and notifies this
|
|
42
|
+
* instance through `loader/volatile-update`. Edits persist into the active
|
|
43
|
+
* profile's own `cordis.patch.yml` through the configuration editor, so the
|
|
44
|
+
* profile entry id — not a separately registered namespace — IS the settings
|
|
45
|
+
* namespace. Every field below is volatile.
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import http from "node:http"
|
|
49
|
+
import https from "node:https"
|
|
50
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"
|
|
51
|
+
import { BlockList, isIP } from "node:net"
|
|
52
|
+
import { appendFileSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"
|
|
53
|
+
import { networkInterfaces } from "node:os"
|
|
54
|
+
import { fileURLToPath } from "node:url"
|
|
55
|
+
import z from "@deepseek-ai/schemastery"
|
|
56
|
+
|
|
57
|
+
export const name = "dsh-remote-access-proxy"
|
|
58
|
+
/** Package name the bundle row loads this plugin by (portable across installs). */
|
|
59
|
+
const PACKAGE = "dsh-remote-access-proxy"
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Required services. `connection` carries this process's launch token. The
|
|
63
|
+
* settings service is deliberately NOT required: the plugin's configuration is
|
|
64
|
+
* its own Config, so the proxy still runs — and still serves remote clients —
|
|
65
|
+
* in a deployment that mounts no settings forms at all. `webStartup` (upstream
|
|
66
|
+
* auto-detect), `configEditor` (persisting generated secrets) and `hmr`
|
|
67
|
+
* (leaving the current hot-reload transaction before editing) are read
|
|
68
|
+
* optionally through `ctx.get`.
|
|
69
|
+
*/
|
|
70
|
+
export const inject = ["connection"]
|
|
71
|
+
|
|
72
|
+
/** Profile entry id of the host row. On DSH 0.1.7+ this IS the settings namespace. */
|
|
73
|
+
const NS = "remote-access-proxy"
|
|
74
|
+
const HERE = fileURLToPath(new URL(".", import.meta.url))
|
|
75
|
+
/** Name of the HttpOnly cookie this proxy's gate mints. */
|
|
76
|
+
const GATE_COOKIE = "dsh_et_gate"
|
|
77
|
+
/** Authenticated status route the configuration page reads (below DSH's own `/api`). */
|
|
78
|
+
export const STATUS_ROUTE = "/api/remote-access-proxy/status"
|
|
79
|
+
/** What a gate path may contain: it is one URL segment. */
|
|
80
|
+
const SECRET_PATH_PATTERN = /^[A-Za-z0-9_-]{4,32}$/
|
|
81
|
+
/** What a gate cookie value may contain: a cookie-octet subset without separators. */
|
|
82
|
+
const COOKIE_VALUE_PATTERN = /^[A-Za-z0-9_-]{8,128}$/
|
|
83
|
+
/** How long a remembered device stays signed in. */
|
|
84
|
+
const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60
|
|
85
|
+
/** Internal routes below the gate path (the login form posts here). */
|
|
86
|
+
const LOGIN_ROUTE = "/__rap/login"
|
|
87
|
+
const LOGOUT_ROUTE = "/__rap/logout"
|
|
88
|
+
/** Failed password attempts allowed per client address in LOGIN_WINDOW_MS. */
|
|
89
|
+
const LOGIN_MAX_FAILURES = 5
|
|
90
|
+
const LOGIN_WINDOW_MS = 5 * 60 * 1000
|
|
91
|
+
/** A ready-made allow list: private LANs, CGNAT/Tailscale, IPv6 ULA and link-local. */
|
|
92
|
+
export const PRIVATE_NETWORKS = "10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 100.64.0.0/10, fc00::/7, fe80::/10"
|
|
93
|
+
/** Rotation defaults, used when a settings object predates the log fields. */
|
|
94
|
+
const DEFAULT_LOG_MAX_BYTES = 1024 * 1024
|
|
95
|
+
const DEFAULT_LOG_KEEP = 3
|
|
96
|
+
|
|
97
|
+
/** Every configurable field, in card order. */
|
|
98
|
+
const FIELDS = [
|
|
99
|
+
"enabled",
|
|
100
|
+
"listenPort",
|
|
101
|
+
"allowFrom",
|
|
102
|
+
"secretPath",
|
|
103
|
+
"password",
|
|
104
|
+
"cookieValue",
|
|
105
|
+
"upstreamHost",
|
|
106
|
+
"upstreamPort",
|
|
107
|
+
"tlsEnabled",
|
|
108
|
+
"tlsPfxPath",
|
|
109
|
+
"tlsPassphrase",
|
|
110
|
+
"logMaxBytes",
|
|
111
|
+
"logKeep",
|
|
112
|
+
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The plugin's live configuration. Every field is `.volatile()`: a change is
|
|
117
|
+
* parsed and validated by Loader, committed into these stable references, and
|
|
118
|
+
* announced with `loader/volatile-update` — the running instance is retained,
|
|
119
|
+
* so the embedded server is restarted rather than re-imported. No ordinary
|
|
120
|
+
* field exists, so an edit never takes the remount lifecycle.
|
|
121
|
+
*/
|
|
122
|
+
export const Config = z.object({
|
|
123
|
+
enabled: z.boolean().default(true).volatile(),
|
|
124
|
+
listenPort: z.number().default(13337).volatile(),
|
|
125
|
+
// Which client addresses may connect: IPs, CIDR blocks or a-b ranges,
|
|
126
|
+
// separated by commas, spaces or new lines. Empty = any address. The proxy
|
|
127
|
+
// always listens on every interface; loopback is always allowed.
|
|
128
|
+
allowFrom: z.string().default("").volatile(),
|
|
129
|
+
secretPath: z.string().default("").volatile(),
|
|
130
|
+
// Optional access password. Empty = the secret path alone opens the site.
|
|
131
|
+
password: z.string().default("").volatile(),
|
|
132
|
+
// Server-side secret that signs the remembered-device cookie. Generated, never
|
|
133
|
+
// shown on the page; regenerating it signs every device out.
|
|
134
|
+
cookieValue: z.string().default("").volatile(),
|
|
135
|
+
// Upstream DSH body. Empty host / port 0 mean "follow the running instance":
|
|
136
|
+
// the address is read at start time from the live `webServer` service (the
|
|
137
|
+
// bound port) and then `webStartup` (the requested one), so both `dsh web` and
|
|
138
|
+
// a desktop launcher started with `--port 0` work without configuration. An
|
|
139
|
+
// explicit value always wins.
|
|
140
|
+
upstreamHost: z.string().default("").volatile(),
|
|
141
|
+
upstreamPort: z.number().default(0).volatile(),
|
|
142
|
+
tlsEnabled: z.boolean().default(false).volatile(),
|
|
143
|
+
tlsPfxPath: z.string().default("").volatile(),
|
|
144
|
+
tlsPassphrase: z.string().default("").volatile(),
|
|
145
|
+
// access.log rotation: rotate before a write that would pass this size
|
|
146
|
+
// (0 = never rotate, the pre-rotation behaviour); how many rotated files to
|
|
147
|
+
// keep (0 = keep no history, just truncate).
|
|
148
|
+
logMaxBytes: z.number().default(DEFAULT_LOG_MAX_BYTES).volatile(),
|
|
149
|
+
logKeep: z.number().default(DEFAULT_LOG_KEEP).volatile(),
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
/** This package's own version, read fresh: after a hot swap it is the new one. */
|
|
153
|
+
function packageVersion() {
|
|
154
|
+
try {
|
|
155
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"))
|
|
156
|
+
return typeof pkg.version === "string" ? pkg.version : undefined
|
|
157
|
+
} catch {
|
|
158
|
+
return undefined
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* A random lowercase alphanumeric token from the OS CSPRNG. The gate path and
|
|
164
|
+
* cookie ARE the access control, so `Math.random` (predictable) is not enough.
|
|
165
|
+
* Rejection sampling keeps every character equally likely.
|
|
166
|
+
*/
|
|
167
|
+
export function genToken(length) {
|
|
168
|
+
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
|
|
169
|
+
const limit = 256 - (256 % chars.length)
|
|
170
|
+
let out = ""
|
|
171
|
+
while (out.length < length) {
|
|
172
|
+
for (const byte of randomBytes(length * 2)) {
|
|
173
|
+
if (byte >= limit) continue
|
|
174
|
+
out += chars[byte % chars.length]
|
|
175
|
+
if (out.length === length) break
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return out
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Parse an allow list: IPs, CIDR blocks (`10.0.0.0/8`, `fd00::/8`) and ranges
|
|
183
|
+
* (`192.168.1.10-192.168.1.20`), separated by commas, semicolons or whitespace.
|
|
184
|
+
*
|
|
185
|
+
* @param text - the configured `allowFrom` value.
|
|
186
|
+
* @returns `{ entries, bad, list }`; `list` is undefined when every address may connect.
|
|
187
|
+
*/
|
|
188
|
+
export function parseAllowList(text) {
|
|
189
|
+
const entries = String(text ?? "").split(/[\s,;]+/).filter(Boolean)
|
|
190
|
+
const bad = []
|
|
191
|
+
if (entries.length === 0) return { entries, bad, list: undefined }
|
|
192
|
+
const list = new BlockList()
|
|
193
|
+
const family = (ip) => (isIP(ip) === 6 ? "ipv6" : "ipv4")
|
|
194
|
+
for (const entry of entries) {
|
|
195
|
+
const slash = entry.indexOf("/")
|
|
196
|
+
const dash = entry.indexOf("-")
|
|
197
|
+
try {
|
|
198
|
+
if (slash > 0) {
|
|
199
|
+
const ip = entry.slice(0, slash)
|
|
200
|
+
const bits = Number(entry.slice(slash + 1))
|
|
201
|
+
const max = isIP(ip) === 6 ? 128 : 32
|
|
202
|
+
if (!isIP(ip) || !/^\d+$/.test(entry.slice(slash + 1)) || bits > max) throw new Error("bad cidr")
|
|
203
|
+
list.addSubnet(ip, bits, family(ip))
|
|
204
|
+
} else if (dash > 0) {
|
|
205
|
+
const from = entry.slice(0, dash)
|
|
206
|
+
const to = entry.slice(dash + 1)
|
|
207
|
+
if (!isIP(from) || isIP(from) !== isIP(to)) throw new Error("bad range")
|
|
208
|
+
list.addRange(from, to, family(from))
|
|
209
|
+
} else {
|
|
210
|
+
if (!isIP(entry)) throw new Error("bad address")
|
|
211
|
+
list.addAddress(entry, family(entry))
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
bad.push(entry)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return { entries, bad, list }
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** A client socket address in plain form (`::ffff:1.2.3.4` becomes `1.2.3.4`). */
|
|
221
|
+
function plainAddress(address) {
|
|
222
|
+
const text = String(address ?? "")
|
|
223
|
+
const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(text)
|
|
224
|
+
return mapped !== null ? mapped[1] : text
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Whether a client address may connect. Loopback always may: it is this
|
|
229
|
+
* machine itself, and a local HTTPS reverse proxy connects from it.
|
|
230
|
+
*/
|
|
231
|
+
export function addressAllowed(list, address) {
|
|
232
|
+
const ip = plainAddress(address)
|
|
233
|
+
if (ip === "::1" || ip.startsWith("127.")) return true
|
|
234
|
+
if (list === undefined) return true
|
|
235
|
+
const version = isIP(ip)
|
|
236
|
+
if (version === 0) return false
|
|
237
|
+
return list.check(ip, version === 6 ? "ipv6" : "ipv4")
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The addresses a remote device can reach this listener on: every external
|
|
242
|
+
* IPv4 interface, those inside the allow list first (a VPN adapter in the
|
|
243
|
+
* allowed range is usually the one to use).
|
|
244
|
+
*
|
|
245
|
+
* @param list - the parsed allow list, or undefined for any address.
|
|
246
|
+
* @returns host literals, best first; never empty.
|
|
247
|
+
*/
|
|
248
|
+
export function reachableHosts(list) {
|
|
249
|
+
const hosts = []
|
|
250
|
+
for (const list of Object.values(networkInterfaces())) {
|
|
251
|
+
for (const item of list ?? []) {
|
|
252
|
+
const v4 = item.family === "IPv4" || item.family === 4
|
|
253
|
+
if (v4 && !item.internal) hosts.push(item.address)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if (hosts.length === 0) return ["127.0.0.1"]
|
|
257
|
+
if (list === undefined) return hosts
|
|
258
|
+
const inside = hosts.filter((host) => list.check(host, "ipv4"))
|
|
259
|
+
return [...inside, ...hosts.filter((host) => !inside.includes(host))]
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** A remembered-device cookie value: `<expiry>.<signature>`. */
|
|
263
|
+
export function mintSession(settings, now = Date.now()) {
|
|
264
|
+
const expiry = Math.floor(now / 1000 + SESSION_TTL_SECONDS).toString(36)
|
|
265
|
+
return `${expiry}.${sessionSignature(settings, expiry)}`
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Whether a cookie value is a live session for these gate settings. */
|
|
269
|
+
export function verifySession(settings, value, now = Date.now()) {
|
|
270
|
+
const dot = typeof value === "string" ? value.indexOf(".") : -1
|
|
271
|
+
if (dot <= 0) return false
|
|
272
|
+
const expiry = value.slice(0, dot)
|
|
273
|
+
if (!/^[0-9a-z]{1,12}$/.test(expiry) || parseInt(expiry, 36) * 1000 <= now) return false
|
|
274
|
+
const given = Buffer.from(value.slice(dot + 1))
|
|
275
|
+
const wanted = Buffer.from(sessionSignature(settings, expiry))
|
|
276
|
+
return given.length === wanted.length && timingSafeEqual(given, wanted)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The signing key covers the gate path and the password, so changing either —
|
|
281
|
+
* or regenerating the server secret — signs every remembered device out.
|
|
282
|
+
*/
|
|
283
|
+
function sessionSignature(settings, expiry) {
|
|
284
|
+
const key = createHmac("sha256", settings.cookieValue).update(`rap-session\0${settings.secretPath}\0${settings.password ?? ""}`).digest()
|
|
285
|
+
return createHmac("sha256", key).update(expiry).digest("base64url")
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Constant-time password comparison. */
|
|
289
|
+
function passwordMatches(given, wanted) {
|
|
290
|
+
const a = createHash("sha256").update(String(given)).digest()
|
|
291
|
+
const b = createHash("sha256").update(String(wanted)).digest()
|
|
292
|
+
return timingSafeEqual(a, b)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const LOGIN_COPY = {
|
|
296
|
+
zh: { title: "DSH 远程访问", prompt: "输入访问密码", button: "进入", wrong: "密码不对", locked: "尝试次数太多,请几分钟后再试" },
|
|
297
|
+
en: { title: "DSH remote access", prompt: "Enter the access password", button: "Continue", wrong: "Wrong password", locked: "Too many attempts. Try again in a few minutes." },
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function escapeHtml(text) {
|
|
301
|
+
return String(text).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c])
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The login page: one password field the browser can remember. */
|
|
305
|
+
export function loginPage({ action, next, error, lang }) {
|
|
306
|
+
const t = LOGIN_COPY[lang] ?? LOGIN_COPY.en
|
|
307
|
+
const message = error === "wrong" ? t.wrong : error === "locked" ? t.locked : ""
|
|
308
|
+
return `<!doctype html>
|
|
309
|
+
<html lang="${lang === "zh" ? "zh-CN" : "en"}"><head><meta charset="utf-8">
|
|
310
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
311
|
+
<meta name="robots" content="noindex">
|
|
312
|
+
<title>${escapeHtml(t.title)}</title>
|
|
313
|
+
<style>
|
|
314
|
+
:root{color-scheme:light dark;font-family:system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
315
|
+
body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:Canvas;color:CanvasText}
|
|
316
|
+
form{width:min(320px,86vw);display:flex;flex-direction:column;gap:12px}
|
|
317
|
+
h1{font-size:20px;font-weight:600;margin:0 0 4px}
|
|
318
|
+
label{font-size:14px;opacity:.75}
|
|
319
|
+
input[type=password]{font-size:17px;padding:12px;border-radius:10px;border:1px solid #8886;background:transparent;color:inherit}
|
|
320
|
+
button{font-size:16px;padding:12px;border-radius:10px;border:0;background:#2563eb;color:#fff;font-weight:600}
|
|
321
|
+
p{margin:0;color:#dc2626;font-size:14px}
|
|
322
|
+
</style></head><body>
|
|
323
|
+
<form method="post" action="${escapeHtml(action)}">
|
|
324
|
+
<h1>${escapeHtml(t.title)}</h1>
|
|
325
|
+
<input type="text" name="username" value="dsh" autocomplete="username" hidden>
|
|
326
|
+
<label for="password">${escapeHtml(t.prompt)}</label>
|
|
327
|
+
<input id="password" type="password" name="password" autocomplete="current-password" autofocus required>
|
|
328
|
+
<input type="hidden" name="next" value="${escapeHtml(next)}">
|
|
329
|
+
${message ? `<p role="alert">${escapeHtml(message)}</p>` : ""}
|
|
330
|
+
<button type="submit">${escapeHtml(t.button)}</button>
|
|
331
|
+
</form></body></html>
|
|
332
|
+
`
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Why a resolved configuration cannot start. `code` lets the page say it in the
|
|
337
|
+
* user's language; `message` is the English line for the log.
|
|
338
|
+
*
|
|
339
|
+
* @param settings - resolved settings (secrets already generated).
|
|
340
|
+
* @returns `{ code, message }`, or undefined when the values are usable.
|
|
341
|
+
*/
|
|
342
|
+
export function configProblem(settings) {
|
|
343
|
+
const port = settings.listenPort
|
|
344
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) return { code: "bad-port", message: `listenPort must be an integer from 1 to 65535 (got ${port})` }
|
|
345
|
+
if (!SECRET_PATH_PATTERN.test(settings.secretPath)) return { code: "bad-secret-path", message: "secretPath must be 4–32 letters, digits, '-' or '_'" }
|
|
346
|
+
if (!COOKIE_VALUE_PATTERN.test(settings.cookieValue)) return { code: "bad-cookie", message: "cookieValue must be 8–128 letters, digits, '-' or '_'" }
|
|
347
|
+
const allow = parseAllowList(settings.allowFrom)
|
|
348
|
+
if (allow.bad.length > 0) return { code: "bad-allow", detail: allow.bad.join(", "), message: `allowFrom entries are not an IP, CIDR block or range: ${allow.bad.join(", ")}` }
|
|
349
|
+
if (typeof settings.password === "string" && settings.password.length > 256) return { code: "bad-password", message: "password must be at most 256 characters" }
|
|
350
|
+
if (settings.tlsEnabled && !settings.tlsPfxPath) return { code: "tls-no-cert", message: "tlsEnabled needs tlsPfxPath (a .pfx certificate file)" }
|
|
351
|
+
return undefined
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Byte size of a file, or 0 when it does not exist. */
|
|
355
|
+
function sizeOf(path) {
|
|
356
|
+
try {
|
|
357
|
+
return statSync(path).size
|
|
358
|
+
} catch {
|
|
359
|
+
return 0
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Shift the log ring one step: `<base>` → `<base>.1` → … → `<base>.<keep>`, the
|
|
365
|
+
* oldest file dropped. Destinations are always freed before a rename, because
|
|
366
|
+
* Windows `rename` refuses to overwrite an existing file.
|
|
367
|
+
*
|
|
368
|
+
* @param base - absolute path of the live log file.
|
|
369
|
+
* @param keep - rotated files to keep; `<= 0` truncates instead of archiving.
|
|
370
|
+
*/
|
|
371
|
+
function rotateLog(base, keep) {
|
|
372
|
+
if (keep <= 0) {
|
|
373
|
+
try {
|
|
374
|
+
writeFileSync(base, "")
|
|
375
|
+
} catch {
|
|
376
|
+
/* best effort */
|
|
377
|
+
}
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
rmSync(`${base}.${keep}`, { force: true })
|
|
382
|
+
} catch {
|
|
383
|
+
/* best effort */
|
|
384
|
+
}
|
|
385
|
+
for (let i = keep - 1; i >= 1; i -= 1) {
|
|
386
|
+
try {
|
|
387
|
+
renameSync(`${base}.${i}`, `${base}.${i + 1}`)
|
|
388
|
+
} catch {
|
|
389
|
+
/* that ring slot is empty */
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
try {
|
|
393
|
+
renameSync(base, `${base}.1`)
|
|
394
|
+
} catch {
|
|
395
|
+
/* nothing to rotate yet */
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Append one timestamped line to `access.log`, rotating by size first.
|
|
401
|
+
*
|
|
402
|
+
* @param dir - directory holding the log (the plugin's own directory).
|
|
403
|
+
* @param line - the message, without the timestamp or newline.
|
|
404
|
+
* @param maxBytes - rotate before a write that would pass this size; `0` disables.
|
|
405
|
+
* @param keep - rotated files to keep.
|
|
406
|
+
*/
|
|
407
|
+
function writeLogLine(dir, line, maxBytes, keep) {
|
|
408
|
+
const base = dir + "access.log"
|
|
409
|
+
const entry = `[${new Date().toISOString()}] ${line}\n`
|
|
410
|
+
const cap = typeof maxBytes === "number" ? maxBytes : DEFAULT_LOG_MAX_BYTES
|
|
411
|
+
if (cap > 0 && sizeOf(base) + Buffer.byteLength(entry) > cap) {
|
|
412
|
+
rotateLog(base, typeof keep === "number" ? keep : DEFAULT_LOG_KEEP)
|
|
413
|
+
}
|
|
414
|
+
appendFileSync(base, entry)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Build the gate + forward server for one resolved settings value.
|
|
419
|
+
*
|
|
420
|
+
* @param settings - resolved proxy settings (gate + upstream).
|
|
421
|
+
* @param log - `(line) => void` diagnostic sink.
|
|
422
|
+
* @param getDshCookie - `() => Promise<string | undefined>`; the proxy-held DSH
|
|
423
|
+
* session cookie, exchanged from the launch token on first need.
|
|
424
|
+
* @param onUpstream401 - invoked when the upstream answers 401 to a gated
|
|
425
|
+
* request (stale DSH cookie); drop it and refresh in the background.
|
|
426
|
+
* @param getUpstream - `() => ({ host, port })`; read per request, so an
|
|
427
|
+
* auto-detected address can still change after the OS binds its port.
|
|
428
|
+
*/
|
|
429
|
+
function buildServer(settings, log, getDshCookie, onUpstream401, onDenied, getUpstream) {
|
|
430
|
+
const { secretPath } = settings
|
|
431
|
+
const upstreamHost = () => getUpstream().host
|
|
432
|
+
const upstreamPort = () => getUpstream().port
|
|
433
|
+
/** Every live socket, both sides of a bridged websocket included, so a stop can end them all. */
|
|
434
|
+
const sockets = new Set()
|
|
435
|
+
const track = (socket) => {
|
|
436
|
+
sockets.add(socket)
|
|
437
|
+
socket.once("close", () => sockets.delete(socket))
|
|
438
|
+
}
|
|
439
|
+
const prefix = "/" + secretPath
|
|
440
|
+
const password = settings.password || ""
|
|
441
|
+
const allow = parseAllowList(settings.allowFrom).list
|
|
442
|
+
/** Failed logins per client address: timestamps inside LOGIN_WINDOW_MS. */
|
|
443
|
+
const failures = new Map()
|
|
444
|
+
|
|
445
|
+
/** Whether the request carries a live remembered-device cookie. */
|
|
446
|
+
function hasGate(headers) {
|
|
447
|
+
const raw = headers.cookie || ""
|
|
448
|
+
for (const part of raw.split(";")) {
|
|
449
|
+
const idx = part.indexOf("=")
|
|
450
|
+
if (idx < 0) continue
|
|
451
|
+
if (part.slice(0, idx).trim() === GATE_COOKIE && verifySession(settings, part.slice(idx + 1).trim())) return true
|
|
452
|
+
}
|
|
453
|
+
return false
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function sessionCookie() {
|
|
457
|
+
return `${GATE_COOKIE}=${mintSession(settings)}; Path=/; Max-Age=${SESSION_TTL_SECONDS}; HttpOnly; SameSite=Strict${settings.tlsEnabled ? "; Secure" : ""}`
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** A page load (not a fetch or an asset): the place to show the login form. */
|
|
461
|
+
function isDocument(req) {
|
|
462
|
+
const dest = req.headers["sec-fetch-dest"]
|
|
463
|
+
if (typeof dest === "string") return dest === "document"
|
|
464
|
+
return String(req.headers.accept || "").includes("text/html")
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function language(req) {
|
|
468
|
+
return /^\s*zh/i.test(String(req.headers["accept-language"] || "")) ? "zh" : "en"
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Where to go after login: only somewhere under the gate path. */
|
|
472
|
+
function safeNext(value) {
|
|
473
|
+
return typeof value === "string" && value.startsWith(prefix + "/") && !value.startsWith(prefix + "/__rap/") ? value : prefix + "/"
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function recentFailures(address) {
|
|
477
|
+
const now = Date.now()
|
|
478
|
+
const list = (failures.get(address) ?? []).filter((at) => now - at < LOGIN_WINDOW_MS)
|
|
479
|
+
if (list.length > 0) failures.set(address, list)
|
|
480
|
+
else failures.delete(address)
|
|
481
|
+
return list
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function sendLogin(req, res, status, next, error) {
|
|
485
|
+
res.writeHead(status, {
|
|
486
|
+
"content-type": "text/html; charset=utf-8",
|
|
487
|
+
"cache-control": "no-store",
|
|
488
|
+
"x-frame-options": "DENY",
|
|
489
|
+
"referrer-policy": "no-referrer",
|
|
490
|
+
})
|
|
491
|
+
res.end(req.method === "HEAD" ? undefined : loginPage({ action: prefix + LOGIN_ROUTE, next, error, lang: language(req) }))
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function handleLogin(req, res) {
|
|
495
|
+
const address = plainAddress(req.socket.remoteAddress)
|
|
496
|
+
let body = ""
|
|
497
|
+
let tooBig = false
|
|
498
|
+
req.setEncoding("utf8")
|
|
499
|
+
req.on("data", (chunk) => {
|
|
500
|
+
body += chunk
|
|
501
|
+
if (body.length > 8192) {
|
|
502
|
+
tooBig = true
|
|
503
|
+
req.destroy()
|
|
504
|
+
}
|
|
505
|
+
})
|
|
506
|
+
req.on("end", () => {
|
|
507
|
+
if (tooBig) return
|
|
508
|
+
const form = new URLSearchParams(body)
|
|
509
|
+
const next = safeNext(form.get("next"))
|
|
510
|
+
if (recentFailures(address).length >= LOGIN_MAX_FAILURES) {
|
|
511
|
+
log(`LOGIN-LOCKED ${address}`)
|
|
512
|
+
sendLogin(req, res, 429, next, "locked")
|
|
513
|
+
return
|
|
514
|
+
}
|
|
515
|
+
if (password !== "" && passwordMatches(form.get("password") ?? "", password)) {
|
|
516
|
+
failures.delete(address)
|
|
517
|
+
log(`LOGIN ${address}`)
|
|
518
|
+
res.writeHead(303, { location: next, "set-cookie": sessionCookie(), "cache-control": "no-store" })
|
|
519
|
+
res.end()
|
|
520
|
+
return
|
|
521
|
+
}
|
|
522
|
+
failures.set(address, [...recentFailures(address), Date.now()])
|
|
523
|
+
log(`LOGIN-FAIL ${address}`)
|
|
524
|
+
// A short pause makes guessing slow without bothering a person.
|
|
525
|
+
setTimeout(() => sendLogin(req, res, 401, next, "wrong"), 400)
|
|
526
|
+
})
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function routePath(pathname) {
|
|
530
|
+
if (pathname === prefix) return "/"
|
|
531
|
+
if (pathname.startsWith(prefix + "/")) return pathname.slice(prefix.length)
|
|
532
|
+
return undefined
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Present any request to DSH as loopback: the /api trust fence accepts it no
|
|
537
|
+
* matter which VPN/reverse-proxy front this proxy sits behind (stripping
|
|
538
|
+
* Origin satisfies the fence's same-origin check). The proxy-held DSH cookie
|
|
539
|
+
* satisfies the browser-auth gate, and replacing the client's own Cookie
|
|
540
|
+
* header keeps it from reaching DSH. Security is still our gate in front.
|
|
541
|
+
*/
|
|
542
|
+
function upstreamHeaders(req, cookie) {
|
|
543
|
+
const headers = { ...req.headers }
|
|
544
|
+
headers.host = `${upstreamHost()}:${upstreamPort()}`
|
|
545
|
+
delete headers.origin
|
|
546
|
+
if (cookie === undefined) delete headers.cookie
|
|
547
|
+
else headers.cookie = cookie
|
|
548
|
+
return headers
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Upstream path with the launch token stripped — the proxy owns the DSH cookie.
|
|
553
|
+
*
|
|
554
|
+
* Operates on the RAW query string: DSH's client-plugin bundler serves bundles
|
|
555
|
+
* at `/plugins/??<id>,<id>&rev=<hash>`, a scheme whose `?`, `,`, `@` and `/`
|
|
556
|
+
* must reach the upstream verbatim. A URL/searchParams round-trip percent-
|
|
557
|
+
* encodes them (`??id` -> `?%3Fid`), the bundle 404s, and every client plugin
|
|
558
|
+
* (incl. the `@deepseek-ai/dsh-client-modules` bootstrap) fails to preload.
|
|
559
|
+
* So drop only the `token=` segment, textually, and touch nothing else.
|
|
560
|
+
*/
|
|
561
|
+
function upstreamTarget(path, search) {
|
|
562
|
+
if (!search || search.indexOf("token=") === -1) return path + search
|
|
563
|
+
const lead = /^\?+/.exec(search)?.[0] ?? "?"
|
|
564
|
+
const kept = search.slice(lead.length).split("&").filter((part) => !part.startsWith("token="))
|
|
565
|
+
return kept.length > 0 ? path + lead + kept.join("&") : path
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
async function forward(req, res, path, search, gated, setGate) {
|
|
569
|
+
const cookie = await getDshCookie()
|
|
570
|
+
const upstreamReq = http.request(
|
|
571
|
+
{
|
|
572
|
+
hostname: upstreamHost(),
|
|
573
|
+
port: upstreamPort(),
|
|
574
|
+
method: req.method,
|
|
575
|
+
path: upstreamTarget(path, search),
|
|
576
|
+
headers: upstreamHeaders(req, cookie),
|
|
577
|
+
},
|
|
578
|
+
(upRes) => {
|
|
579
|
+
if (upRes.statusCode === 401) onUpstream401?.()
|
|
580
|
+
const outHeaders = { ...upRes.headers }
|
|
581
|
+
if (gated) {
|
|
582
|
+
// Defensive: today the token is always stripped, so DSH never redirects
|
|
583
|
+
// on the gate path; should it ever answer a root-relative Location,
|
|
584
|
+
// re-enter through the gate instead of the ungated origin root.
|
|
585
|
+
if (typeof outHeaders.location === "string" && outHeaders.location.startsWith("/")) {
|
|
586
|
+
outHeaders.location = prefix + outHeaders.location
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
// Each page load renews the remembered device for another 30 days.
|
|
590
|
+
if (setGate) outHeaders["set-cookie"] = sessionCookie()
|
|
591
|
+
res.writeHead(upRes.statusCode || 502, outHeaders)
|
|
592
|
+
upRes.pipe(res)
|
|
593
|
+
},
|
|
594
|
+
)
|
|
595
|
+
upstreamReq.on("error", (err) => {
|
|
596
|
+
log(`upstream error ${err.code || err.message}`)
|
|
597
|
+
if (!res.headersSent) {
|
|
598
|
+
res.writeHead(502, { "content-type": "text/plain" })
|
|
599
|
+
res.end("bad gateway\n")
|
|
600
|
+
} else res.destroy()
|
|
601
|
+
})
|
|
602
|
+
req.pipe(upstreamReq)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const handler = (req, res) => {
|
|
606
|
+
const url = new URL(req.url, "http://" + (req.headers.host || "local"))
|
|
607
|
+
const path = routePath(url.pathname)
|
|
608
|
+
if (url.pathname === prefix && (req.method === "GET" || req.method === "HEAD")) {
|
|
609
|
+
// The client resolves its assets against `<base href="./">`: without the
|
|
610
|
+
// trailing slash they would land outside the gate path.
|
|
611
|
+
log(`ENTRY ${req.method} ${req.url} -> ${prefix}/`)
|
|
612
|
+
res.writeHead(308, { location: prefix + "/" + url.search })
|
|
613
|
+
res.end()
|
|
614
|
+
return
|
|
615
|
+
}
|
|
616
|
+
if (path === LOGIN_ROUTE && req.method === "POST") return handleLogin(req, res)
|
|
617
|
+
if (path === LOGOUT_ROUTE) {
|
|
618
|
+
log(`LOGOUT ${plainAddress(req.socket.remoteAddress)}`)
|
|
619
|
+
res.writeHead(303, { location: prefix + "/", "set-cookie": `${GATE_COOKIE}=; Path=/; Max-Age=0; HttpOnly; SameSite=Strict`, "cache-control": "no-store" })
|
|
620
|
+
res.end()
|
|
621
|
+
return
|
|
622
|
+
}
|
|
623
|
+
const authed = hasGate(req.headers)
|
|
624
|
+
if (path !== undefined) {
|
|
625
|
+
if (password === "" || authed) {
|
|
626
|
+
log(`ENTRY ${req.method} ${req.url}`)
|
|
627
|
+
return forward(req, res, path, url.search, true, isDocument(req) || !authed)
|
|
628
|
+
}
|
|
629
|
+
if ((req.method === "GET" || req.method === "HEAD") && isDocument(req)) {
|
|
630
|
+
log(`LOGIN-PAGE ${req.method} ${req.url}`)
|
|
631
|
+
return sendLogin(req, res, 200, safeNext(url.pathname + url.search))
|
|
632
|
+
}
|
|
633
|
+
log(`UNAUTH ${req.method} ${req.url}`)
|
|
634
|
+
res.writeHead(401, { "content-type": "text/plain" })
|
|
635
|
+
res.end("sign in first\n")
|
|
636
|
+
return
|
|
637
|
+
}
|
|
638
|
+
if (authed) {
|
|
639
|
+
log(`PASS ${req.method} ${req.url}`)
|
|
640
|
+
return forward(req, res, url.pathname, url.search, false, false)
|
|
641
|
+
}
|
|
642
|
+
log(`DENY ${req.method} ${req.url}`)
|
|
643
|
+
res.writeHead(403, { "content-type": "text/plain" })
|
|
644
|
+
res.end("forbidden\n")
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const handleUpgrade = async (req, socket, head) => {
|
|
648
|
+
const url = new URL(req.url, "http://" + (req.headers.host || "local"))
|
|
649
|
+
const path = routePath(url.pathname)
|
|
650
|
+
const authed = hasGate(req.headers)
|
|
651
|
+
const allowed = authed || (path !== undefined && password === "")
|
|
652
|
+
if (!allowed) {
|
|
653
|
+
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n")
|
|
654
|
+
socket.destroy()
|
|
655
|
+
return
|
|
656
|
+
}
|
|
657
|
+
const target = path !== undefined ? path : url.pathname
|
|
658
|
+
const cookie = await getDshCookie()
|
|
659
|
+
const upstreamReq = http.request({
|
|
660
|
+
hostname: upstreamHost(),
|
|
661
|
+
port: upstreamPort(),
|
|
662
|
+
method: req.method,
|
|
663
|
+
path: upstreamTarget(target, url.search),
|
|
664
|
+
headers: upstreamHeaders(req, cookie),
|
|
665
|
+
})
|
|
666
|
+
upstreamReq.on("upgrade", (upRes, upSocket, upHead) => {
|
|
667
|
+
log(`WS-UPGRADE ${req.url}`)
|
|
668
|
+
socket.write("HTTP/1.1 101 Switching Protocols\r\n")
|
|
669
|
+
for (const [key, value] of Object.entries(upRes.headers)) {
|
|
670
|
+
if (Array.isArray(value)) value.forEach((item) => socket.write(`${key}: ${item}\r\n`))
|
|
671
|
+
else socket.write(`${key}: ${value}\r\n`)
|
|
672
|
+
}
|
|
673
|
+
socket.write("\r\n")
|
|
674
|
+
if (upHead && upHead.length) socket.write(upHead)
|
|
675
|
+
track(upSocket)
|
|
676
|
+
upSocket.pipe(socket)
|
|
677
|
+
socket.pipe(upSocket)
|
|
678
|
+
socket.on("error", () => upSocket.destroy())
|
|
679
|
+
upSocket.on("error", () => socket.destroy())
|
|
680
|
+
})
|
|
681
|
+
upstreamReq.on("error", (err) => {
|
|
682
|
+
log(`WS upstream error ${err.code || err.message}`)
|
|
683
|
+
socket.destroy()
|
|
684
|
+
})
|
|
685
|
+
if (head && head.length) upstreamReq.write(head)
|
|
686
|
+
upstreamReq.end()
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// TLS (self-signed cert) when enabled: browsers require a secure context for
|
|
690
|
+
// crypto.randomUUID, so plain-HTTP remote access breaks the web client.
|
|
691
|
+
let server
|
|
692
|
+
if (settings.tlsEnabled) {
|
|
693
|
+
try {
|
|
694
|
+
server = https.createServer(
|
|
695
|
+
{ pfx: readFileSync(settings.tlsPfxPath), passphrase: settings.tlsPassphrase || "" },
|
|
696
|
+
handler,
|
|
697
|
+
)
|
|
698
|
+
} catch (err) {
|
|
699
|
+
log(`TLS init failed (${err.message}); proxy NOT started — set tlsEnabled=false for plain HTTP, or fix tlsPfxPath/tlsPassphrase`)
|
|
700
|
+
return { code: "tls-load", error: `TLS certificate could not be loaded: ${err.message}` }
|
|
701
|
+
}
|
|
702
|
+
} else {
|
|
703
|
+
server = http.createServer(handler)
|
|
704
|
+
}
|
|
705
|
+
server.on("upgrade", handleUpgrade)
|
|
706
|
+
// The allow list acts like a firewall: a client outside it is dropped before
|
|
707
|
+
// any byte (or TLS handshake) is exchanged, so a scan learns nothing.
|
|
708
|
+
server.on("connection", (socket) => {
|
|
709
|
+
if (addressAllowed(allow, socket.remoteAddress)) {
|
|
710
|
+
track(socket)
|
|
711
|
+
return
|
|
712
|
+
}
|
|
713
|
+
const address = plainAddress(socket.remoteAddress)
|
|
714
|
+
log(`DENY-ADDRESS ${address}`)
|
|
715
|
+
onDenied?.(address)
|
|
716
|
+
socket.destroy()
|
|
717
|
+
})
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Stop listening. `hard` also ends every open connection at once — keep-alive
|
|
721
|
+
* sockets, requests in flight and bridged websockets — so nothing of this
|
|
722
|
+
* instance outlives it (a hot swap disposes it; the browser reconnects to the
|
|
723
|
+
* new version). A configuration restart is soft: it frees the port and closes
|
|
724
|
+
* idle keep-alive sockets, and a websocket open now keeps running.
|
|
725
|
+
*/
|
|
726
|
+
function stop(hard) {
|
|
727
|
+
try {
|
|
728
|
+
server.close()
|
|
729
|
+
} catch {
|
|
730
|
+
/* already closed */
|
|
731
|
+
}
|
|
732
|
+
if (hard) {
|
|
733
|
+
try {
|
|
734
|
+
server.closeAllConnections?.()
|
|
735
|
+
} catch {
|
|
736
|
+
/* best effort */
|
|
737
|
+
}
|
|
738
|
+
for (const socket of sockets) socket.destroy()
|
|
739
|
+
sockets.clear()
|
|
740
|
+
} else {
|
|
741
|
+
try {
|
|
742
|
+
server.closeIdleConnections?.()
|
|
743
|
+
} catch {
|
|
744
|
+
/* best effort */
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
return { server, stop }
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Mount the proxy and keep it in step with the plugin's live configuration.
|
|
754
|
+
*
|
|
755
|
+
* @param ctx - the plugin context (`connection` is injected).
|
|
756
|
+
* @param config - the schema-parsed Config: one `Volatile` reference per field.
|
|
757
|
+
*/
|
|
758
|
+
export function apply(ctx, config) {
|
|
759
|
+
let server = undefined
|
|
760
|
+
/** Stops the running server (soft or hard); see buildServer. */
|
|
761
|
+
let stopServer = undefined
|
|
762
|
+
let current = undefined
|
|
763
|
+
let dshCookie = undefined
|
|
764
|
+
let exchanging = undefined
|
|
765
|
+
/**
|
|
766
|
+
* What the configuration page shows: whether the listener is up and, if not,
|
|
767
|
+
* why. `state` is one of starting | listening | disabled | error.
|
|
768
|
+
*/
|
|
769
|
+
let status = { state: "starting" }
|
|
770
|
+
/** The last client address the allow list turned away, for the page. */
|
|
771
|
+
let lastDenied = undefined
|
|
772
|
+
/**
|
|
773
|
+
* This package's installed version. Read on every use, not once: an update
|
|
774
|
+
* whose host file is unchanged (only the page or the metadata changed) is
|
|
775
|
+
* installed without remounting this instance, and the page must still see
|
|
776
|
+
* the new number.
|
|
777
|
+
*/
|
|
778
|
+
const installedVersion = () => packageVersion()
|
|
779
|
+
const version = installedVersion()
|
|
780
|
+
// This plugin ships its own page for its row; tell the settings service not to
|
|
781
|
+
// offer a generated one. Optional: a deployment without settings still runs.
|
|
782
|
+
ctx.inject(["settings"], (child) => {
|
|
783
|
+
child.effect(() => child.settings.configure({ auto: false }, ctx.fiber), "dsh-remote-access-proxy: page policy")
|
|
784
|
+
})
|
|
785
|
+
|
|
786
|
+
function log(line) {
|
|
787
|
+
try {
|
|
788
|
+
writeLogLine(HERE, line, current?.logMaxBytes, current?.logKeep)
|
|
789
|
+
} catch {
|
|
790
|
+
/* best effort */
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** Record a failure the page should show, and say it on the DSH console too. */
|
|
795
|
+
function fail(message, code = "error", detail) {
|
|
796
|
+
status = { state: "error", code, error: message, ...(detail !== undefined ? { detail } : {}) }
|
|
797
|
+
log(message)
|
|
798
|
+
ctx.logger?.warn?.("remote-access-proxy: %s", message)
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/** The status document the configuration page renders. */
|
|
802
|
+
function statusDocument() {
|
|
803
|
+
const settings = current
|
|
804
|
+
const doc = { ...status, version: installedVersion() }
|
|
805
|
+
if (settings !== undefined) {
|
|
806
|
+
const scheme = settings.tlsEnabled ? "https" : "http"
|
|
807
|
+
doc.scheme = scheme
|
|
808
|
+
const allow = parseAllowList(settings.allowFrom)
|
|
809
|
+
doc.listenPort = settings.listenPort
|
|
810
|
+
// The page must show the address requests actually go to, not the one
|
|
811
|
+
// resolved at start time (an auto-detected port can still have been unbound).
|
|
812
|
+
const live = upstreamNow()
|
|
813
|
+
doc.upstream = `${live.host}:${live.port}`
|
|
814
|
+
doc.tls = settings.tlsEnabled === true
|
|
815
|
+
doc.password = typeof settings.password === "string" && settings.password !== ""
|
|
816
|
+
doc.allowFrom = allow.bad.length === 0 ? allow.entries : []
|
|
817
|
+
if (lastDenied !== undefined) doc.lastDenied = lastDenied
|
|
818
|
+
doc.urls = reachableHosts(allow.bad.length === 0 ? allow.list : undefined).map((host) => `${scheme}://${host.includes(":") ? `[${host}]` : host}:${settings.listenPort}/${settings.secretPath}/`)
|
|
819
|
+
}
|
|
820
|
+
return doc
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// The page reads the live status through DSH's own authenticated /api channel,
|
|
824
|
+
// so it is exactly as private as the rest of the API.
|
|
825
|
+
if (typeof ctx.connection?.fetch?.register === "function") {
|
|
826
|
+
ctx.effect(() => {
|
|
827
|
+
const dispose = ctx.connection.fetch.register({
|
|
828
|
+
path: STATUS_ROUTE,
|
|
829
|
+
methods: ["GET"],
|
|
830
|
+
requestBody: "buffered",
|
|
831
|
+
fetch: async () => new Response(JSON.stringify(statusDocument()), {
|
|
832
|
+
status: 200,
|
|
833
|
+
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
|
|
834
|
+
}),
|
|
835
|
+
})
|
|
836
|
+
return () => {
|
|
837
|
+
void Promise.resolve(typeof dispose === "function" ? dispose() : undefined).catch(() => {})
|
|
838
|
+
}
|
|
839
|
+
}, "dsh-remote-access-proxy: status route")
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/** Read one `.get()` per configured field; an absent field keeps its schema default. */
|
|
843
|
+
function readConfig() {
|
|
844
|
+
const parsed = config ?? Config({})
|
|
845
|
+
const values = {}
|
|
846
|
+
for (const field of FIELDS) {
|
|
847
|
+
const ref = parsed[field]
|
|
848
|
+
values[field] = ref !== undefined && typeof ref.get === "function" ? ref.get() : ref
|
|
849
|
+
}
|
|
850
|
+
return values
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/**
|
|
854
|
+
* Write generated values back into the active profile's plugin configuration.
|
|
855
|
+
*
|
|
856
|
+
* The values live in the profile's own `cordis.patch.yml`, so the write goes
|
|
857
|
+
* through the configuration editor — once the Loader has settled *and* outside
|
|
858
|
+
* the hot-reload transaction that is mounting this plugin, because the editor
|
|
859
|
+
* refuses to run re-entrantly ("HMR transactions cannot be nested").
|
|
860
|
+
* Without an editor (a profile-less run) the generated values stay
|
|
861
|
+
* process-local and change on the next start.
|
|
862
|
+
*/
|
|
863
|
+
function persistConfig(patch) {
|
|
864
|
+
const entry = ctx.fiber?.entry
|
|
865
|
+
const editor = ctx.get?.("configEditor")
|
|
866
|
+
if (entry === undefined || editor === undefined) {
|
|
867
|
+
log("no profile configuration editor; generated values are process-local")
|
|
868
|
+
return
|
|
869
|
+
}
|
|
870
|
+
// HMR's guard rejects re-entrant edits with this exact message; another
|
|
871
|
+
// transaction (ours, or a peer plugin's) may still be in flight.
|
|
872
|
+
const NESTED = /HMR transactions cannot be nested/i
|
|
873
|
+
const MAX_ATTEMPTS = 5
|
|
874
|
+
const write = (attempts = MAX_ATTEMPTS) => {
|
|
875
|
+
editor.edit(entry, (existing = {}) => ({ ...existing, ...patch })).catch((error) => {
|
|
876
|
+
const message = error?.message ?? String(error)
|
|
877
|
+
if (attempts > 0 && NESTED.test(message)) {
|
|
878
|
+
// Retry outside the current async store rather than dropping the value.
|
|
879
|
+
setTimeout(() => outside(() => write(attempts - 1)), 250)
|
|
880
|
+
return
|
|
881
|
+
}
|
|
882
|
+
log(`configuration write failed (${error?.message ?? error}); generated values are process-local`)
|
|
883
|
+
})
|
|
884
|
+
}
|
|
885
|
+
// `configEditor.edit` runs through `hmr.runExclusive`, and this plugin's
|
|
886
|
+
// apply()/commit runs *inside* HMR's transaction: the guard rejects with
|
|
887
|
+
// "HMR transactions cannot be nested". AsyncLocalStorage keeps the store on
|
|
888
|
+
// every continuation the transaction started — including one chained off
|
|
889
|
+
// `loader.await()` — so leaving the context explicitly is the only way out
|
|
890
|
+
// (the loader's own config watcher does the same). `runExclusive` then
|
|
891
|
+
// queues the edit behind the in-flight transaction.
|
|
892
|
+
const hmr = ctx.get?.("hmr")
|
|
893
|
+
const outside = hmr?.executing !== undefined && typeof hmr.executing.exit === "function"
|
|
894
|
+
? (work) => hmr.executing.exit(work)
|
|
895
|
+
: (work) => work()
|
|
896
|
+
const loader = ctx.root?.loader
|
|
897
|
+
const settled = loader !== undefined && typeof loader.await === "function" ? loader.await() : Promise.resolve()
|
|
898
|
+
settled.then(() => outside(write), () => outside(write))
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** A port that can actually be connected to; `0` is the OS's "pick a free one". */
|
|
902
|
+
const usablePort = (value) => Number.isInteger(value) && value > 0 && value < 65536
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* The running web server, whose `port` is the port the OS really bound. The
|
|
906
|
+
* strict read is tried first; a non-strict one still answers while the
|
|
907
|
+
* `webserver` row has not reached ACTIVE, and its `port` stays `undefined`
|
|
908
|
+
* until `listen` binds — hence `usablePort` rather than a plain truthiness test.
|
|
909
|
+
*/
|
|
910
|
+
function runningWebServer() {
|
|
911
|
+
return ctx.get?.("webServer") ?? ctx.get?.("webServer", false)
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* Resolve the upstream address this proxy fronts.
|
|
916
|
+
*
|
|
917
|
+
* Both fields may be left unset (empty host / port 0) to follow the running
|
|
918
|
+
* instance. The authoritative source is the `webServer` service, which
|
|
919
|
+
* publishes the port the OS actually bound; the desktop launcher passes
|
|
920
|
+
* `--port 0` ("let the OS pick one"), so its connectable port exists only
|
|
921
|
+
* there. `webStartup` carries the *requested* address instead — still 0 on the
|
|
922
|
+
* desktop, but the real port under `dsh web` — so it is the fallback. An
|
|
923
|
+
* explicit value always wins; the last resort is the historical loopback
|
|
924
|
+
* 127.0.0.1:3080.
|
|
925
|
+
*
|
|
926
|
+
* @returns `{ upstreamHost, upstreamPort, known }`, where `known` says whether
|
|
927
|
+
* a live address was found (rather than the historical fallback).
|
|
928
|
+
* @param quiet - skip the log line; used by the per-request re-resolution,
|
|
929
|
+
* which must not write to the log on every request.
|
|
930
|
+
*/
|
|
931
|
+
function resolveUpstream(settings, quiet = false) {
|
|
932
|
+
const bound = runningWebServer()
|
|
933
|
+
const live = ctx.get?.("webStartup")
|
|
934
|
+
let host = settings.upstreamHost || bound?.host || live?.host || "127.0.0.1"
|
|
935
|
+
// A bind address is not a connect address: 0.0.0.0/:: mean "every host".
|
|
936
|
+
if (host === "0.0.0.0" || host === "::" || host === "[::]" || host === "*") host = "127.0.0.1"
|
|
937
|
+
const boundPort = usablePort(bound?.port) ? bound.port : undefined
|
|
938
|
+
const livePort = usablePort(live?.port) ? live.port : undefined
|
|
939
|
+
const known = Boolean(settings.upstreamPort) || boundPort !== undefined || livePort !== undefined
|
|
940
|
+
const port = settings.upstreamPort || boundPort || livePort || 3080
|
|
941
|
+
if (!quiet && (!settings.upstreamHost || !settings.upstreamPort)) {
|
|
942
|
+
log(`upstream ${host}:${port}${known ? " (auto)" : " (unavailable, using the 3080 fallback)"}`)
|
|
943
|
+
}
|
|
944
|
+
return { upstreamHost: host, upstreamPort: port, known }
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* The settings a resolution re-reads later, while the address is auto-detected.
|
|
949
|
+
* `undefined` once an explicit address is configured — the value is then fixed.
|
|
950
|
+
*/
|
|
951
|
+
let upstreamAuto
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* The address to forward to right now.
|
|
955
|
+
*
|
|
956
|
+
* An auto-detected address is re-resolved on every request rather than fixed at
|
|
957
|
+
* start time: a `--port 0` host (the desktop launcher) publishes its bound port
|
|
958
|
+
* only after the OS has assigned one, and nothing orders the `webserver` row
|
|
959
|
+
* before this plugin's, so the value read at start time can still be the 3080
|
|
960
|
+
* fallback — which is what answered 502 bad gateway to every remote request.
|
|
961
|
+
* Re-resolving removes the start-order dependency entirely; an explicit
|
|
962
|
+
* address is never re-read.
|
|
963
|
+
*/
|
|
964
|
+
function upstreamNow() {
|
|
965
|
+
if (upstreamAuto === undefined || current === undefined) {
|
|
966
|
+
return current === undefined
|
|
967
|
+
? { host: "127.0.0.1", port: 3080 }
|
|
968
|
+
: { host: current.upstreamHost, port: current.upstreamPort }
|
|
969
|
+
}
|
|
970
|
+
const live = resolveUpstream(upstreamAuto, true)
|
|
971
|
+
if (live.known && (live.upstreamHost !== current.upstreamHost || live.upstreamPort !== current.upstreamPort)) {
|
|
972
|
+
log(`upstream ${live.upstreamHost}:${live.upstreamPort} (auto)`)
|
|
973
|
+
current = { ...current, upstreamHost: live.upstreamHost, upstreamPort: live.upstreamPort }
|
|
974
|
+
}
|
|
975
|
+
return { host: current.upstreamHost, port: current.upstreamPort }
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function ensureSecrets(settings) {
|
|
979
|
+
const patch = {}
|
|
980
|
+
if (!settings.secretPath) patch.secretPath = genToken(12)
|
|
981
|
+
if (!settings.cookieValue) patch.cookieValue = genToken(24)
|
|
982
|
+
if (Object.keys(patch).length > 0) {
|
|
983
|
+
persistConfig(patch)
|
|
984
|
+
return { ...settings, ...patch }
|
|
985
|
+
}
|
|
986
|
+
return settings
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/** The launch token of this DSH process, read from the connection service. */
|
|
990
|
+
function launchToken(host, port) {
|
|
991
|
+
try {
|
|
992
|
+
const base = `http://${host}:${port}`
|
|
993
|
+
return new URL(ctx.connection.authenticatedUrl(base)).searchParams.get("token") || undefined
|
|
994
|
+
} catch (err) {
|
|
995
|
+
log(`launch token unavailable (${err.message})`)
|
|
996
|
+
return undefined
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/** Exchange the launch token for DSH's authority-bound session cookie. */
|
|
1001
|
+
function exchangeDshCookie(settings) {
|
|
1002
|
+
// The cookie is fetched from the address requests actually go to: at start
|
|
1003
|
+
// time an auto-detected port may not be bound yet, and an exchange that ran
|
|
1004
|
+
// against the 3080 fallback would simply never produce a cookie.
|
|
1005
|
+
const { host, port } = upstreamNow()
|
|
1006
|
+
const token = launchToken(host, port)
|
|
1007
|
+
if (!token) return Promise.resolve(undefined)
|
|
1008
|
+
return new Promise((resolve) => {
|
|
1009
|
+
const req = http.request(
|
|
1010
|
+
{ hostname: host, port, method: "GET", path: "/?token=" + encodeURIComponent(token), headers: { host: `${host}:${port}` } },
|
|
1011
|
+
(res) => {
|
|
1012
|
+
const setCookie = res.headers["set-cookie"]
|
|
1013
|
+
res.resume()
|
|
1014
|
+
if (res.statusCode === 303 && Array.isArray(setCookie)) {
|
|
1015
|
+
for (const line of setCookie) {
|
|
1016
|
+
const pair = line.split(";")[0]
|
|
1017
|
+
if (pair.startsWith("dsh-auth-")) {
|
|
1018
|
+
dshCookie = pair
|
|
1019
|
+
log("DSH session cookie acquired")
|
|
1020
|
+
return resolve(pair)
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
log(`DSH session exchange returned ${res.statusCode || "no status"}`)
|
|
1025
|
+
resolve(undefined)
|
|
1026
|
+
},
|
|
1027
|
+
)
|
|
1028
|
+
req.on("error", (err) => {
|
|
1029
|
+
log(`DSH session exchange error ${err.code || err.message}`)
|
|
1030
|
+
resolve(undefined)
|
|
1031
|
+
})
|
|
1032
|
+
req.end()
|
|
1033
|
+
})
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/** Return the held DSH cookie, exchanging on first need (concurrent-safe). */
|
|
1037
|
+
function getDshCookie() {
|
|
1038
|
+
if (dshCookie !== undefined) return Promise.resolve(dshCookie)
|
|
1039
|
+
if (exchanging !== undefined) return exchanging
|
|
1040
|
+
if (current === undefined) return Promise.resolve(undefined)
|
|
1041
|
+
exchanging = exchangeDshCookie(current).finally(() => {
|
|
1042
|
+
exchanging = undefined
|
|
1043
|
+
})
|
|
1044
|
+
return exchanging
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/** Upstream 401 means our DSH cookie is stale; drop and refresh it. */
|
|
1048
|
+
function onUpstream401() {
|
|
1049
|
+
if (dshCookie === undefined) return
|
|
1050
|
+
dshCookie = undefined
|
|
1051
|
+
log("DSH 401 observed; refreshing session cookie")
|
|
1052
|
+
void getDshCookie()
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* Releases before 1.2 named the row by a relative path (`./plugin/…mjs`). DSH
|
|
1057
|
+
* resolved it to an absolute file URL, and the configuration editor copied that
|
|
1058
|
+
* URL into the user patch together with the generated gate values. The bundle
|
|
1059
|
+
* now names the row by its package, and a user row naming a different module
|
|
1060
|
+
* no longer applies to it — the saved gate path and cookie would be silently
|
|
1061
|
+
* replaced by fresh ones, and the address every device bookmarked would die.
|
|
1062
|
+
*
|
|
1063
|
+
* So rewrite such a stale row's `name:` to the package, synchronously, before
|
|
1064
|
+
* the first start. The loader's config watcher then reconciles the row and the
|
|
1065
|
+
* saved values arrive as an ordinary volatile update.
|
|
1066
|
+
*
|
|
1067
|
+
* @returns true when the user patch was rewritten.
|
|
1068
|
+
*/
|
|
1069
|
+
function healUserPatch() {
|
|
1070
|
+
const path = ctx.get?.("configEditor")?.documentPath
|
|
1071
|
+
if (typeof path !== "string") return false
|
|
1072
|
+
let text
|
|
1073
|
+
try {
|
|
1074
|
+
text = readFileSync(path, "utf8")
|
|
1075
|
+
} catch {
|
|
1076
|
+
return false
|
|
1077
|
+
}
|
|
1078
|
+
let inRow = false
|
|
1079
|
+
let changed = false
|
|
1080
|
+
const lines = text.split("\n").map((line) => {
|
|
1081
|
+
const id = /^\s*-\s+id:\s*(['"]?)([^'"\s]+)\1\s*$/.exec(line)
|
|
1082
|
+
if (id !== null) {
|
|
1083
|
+
inRow = id[2] === NS
|
|
1084
|
+
return line
|
|
1085
|
+
}
|
|
1086
|
+
if (/^\s*-\s/.test(line)) inRow = false
|
|
1087
|
+
if (!inRow) return line
|
|
1088
|
+
const match = /^(\s*)name:\s*(.*?)\s*$/.exec(line)
|
|
1089
|
+
if (match === null) return line
|
|
1090
|
+
const value = match[2].replace(/^(['"])(.*)\1$/, "$2").replace(/\\/g, "/")
|
|
1091
|
+
if (value === PACKAGE || !value.endsWith("plugin/dsh-remote-access-proxy.mjs")) return line
|
|
1092
|
+
changed = true
|
|
1093
|
+
return `${match[1]}name: ${PACKAGE}${line.endsWith("\r") ? "\r" : ""}`
|
|
1094
|
+
})
|
|
1095
|
+
if (!changed) return false
|
|
1096
|
+
try {
|
|
1097
|
+
writeFileSync(path, lines.join("\n"))
|
|
1098
|
+
log(`migrated the row's module name to "${PACKAGE}" in ${path}`)
|
|
1099
|
+
return true
|
|
1100
|
+
} catch (error) {
|
|
1101
|
+
log(`could not migrate the row's module name (${error.message})`)
|
|
1102
|
+
return false
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function start(settings) {
|
|
1107
|
+
stop(false)
|
|
1108
|
+
if (awaitingMigration && (!settings.secretPath || !settings.cookieValue)) {
|
|
1109
|
+
status = { state: "starting" }
|
|
1110
|
+
log("waiting for the migrated configuration before starting")
|
|
1111
|
+
return
|
|
1112
|
+
}
|
|
1113
|
+
const upstream = resolveUpstream(settings)
|
|
1114
|
+
// An auto-detected address stays a live query (an explicit one is fixed), so
|
|
1115
|
+
// a resolution that raced the OS binding its port heals on the next request.
|
|
1116
|
+
upstreamAuto = !settings.upstreamHost || !settings.upstreamPort ? settings : undefined
|
|
1117
|
+
const resolved = ensureSecrets({
|
|
1118
|
+
...settings,
|
|
1119
|
+
upstreamHost: upstream.upstreamHost,
|
|
1120
|
+
upstreamPort: upstream.upstreamPort,
|
|
1121
|
+
})
|
|
1122
|
+
current = resolved
|
|
1123
|
+
if (!resolved.enabled) {
|
|
1124
|
+
status = { state: "disabled" }
|
|
1125
|
+
log("disabled")
|
|
1126
|
+
return
|
|
1127
|
+
}
|
|
1128
|
+
const problem = configProblem(resolved)
|
|
1129
|
+
if (problem !== undefined) {
|
|
1130
|
+
fail(`not started: ${problem.message}`, problem.code, problem.detail)
|
|
1131
|
+
return
|
|
1132
|
+
}
|
|
1133
|
+
lastDenied = undefined
|
|
1134
|
+
const built = buildServer(resolved, log, getDshCookie, onUpstream401, (address) => {
|
|
1135
|
+
lastDenied = { address, at: new Date().toISOString() }
|
|
1136
|
+
}, upstreamNow)
|
|
1137
|
+
if (built.error !== undefined) {
|
|
1138
|
+
fail(built.error, built.code)
|
|
1139
|
+
return
|
|
1140
|
+
}
|
|
1141
|
+
const own = built.server
|
|
1142
|
+
server = own
|
|
1143
|
+
stopServer = built.stop
|
|
1144
|
+
status = { state: "starting" }
|
|
1145
|
+
own.on("error", (err) => {
|
|
1146
|
+
if (server !== own) return
|
|
1147
|
+
// A machine with IPv6 turned off cannot bind "::"; every-interface IPv4 then.
|
|
1148
|
+
if (bindHost === "::" && (err.code === "EAFNOSUPPORT" || err.code === "EADDRNOTAVAIL")) {
|
|
1149
|
+
bindHost = "0.0.0.0"
|
|
1150
|
+
own.listen(resolved.listenPort, bindHost, onListening)
|
|
1151
|
+
return
|
|
1152
|
+
}
|
|
1153
|
+
// The instance this one replaces (a hot swap, a restart) may still be
|
|
1154
|
+
// releasing the port for a moment: try again briefly before giving up.
|
|
1155
|
+
if (err.code === "EADDRINUSE" && bindRetries > 0) {
|
|
1156
|
+
bindRetries -= 1
|
|
1157
|
+
setTimeout(() => {
|
|
1158
|
+
if (server === own) own.listen(resolved.listenPort, bindHost, onListening)
|
|
1159
|
+
}, 250)
|
|
1160
|
+
return
|
|
1161
|
+
}
|
|
1162
|
+
const code = { EADDRINUSE: "port-in-use", EACCES: "port-denied" }[err.code] ?? "listen-error"
|
|
1163
|
+
const reason = err.code === "EADDRINUSE"
|
|
1164
|
+
? `port ${resolved.listenPort} is already in use (another program, or a second DSH app sharing this profile)`
|
|
1165
|
+
: err.code === "EACCES"
|
|
1166
|
+
? `no permission to listen on port ${resolved.listenPort}`
|
|
1167
|
+
: `listener error ${err.code || err.message}`
|
|
1168
|
+
fail(reason, code)
|
|
1169
|
+
server = undefined
|
|
1170
|
+
})
|
|
1171
|
+
// Every interface, IPv6 and IPv4 alike; who may connect is the allow list's job.
|
|
1172
|
+
let bindHost = "::"
|
|
1173
|
+
let bindRetries = 8
|
|
1174
|
+
const onListening = () => {
|
|
1175
|
+
if (server !== own) return
|
|
1176
|
+
status = { state: "listening" }
|
|
1177
|
+
log(`listening on ${bindHost === "::" ? "[::]" : bindHost}:${resolved.listenPort}${version ? ` v${version}` : ""} secret=/${resolved.secretPath} password=${resolved.password ? "on" : "off"} allow=${resolved.allowFrom || "any"}`)
|
|
1178
|
+
}
|
|
1179
|
+
own.listen(resolved.listenPort, bindHost, onListening)
|
|
1180
|
+
// Warm the DSH session cookie so the first remote request is fast.
|
|
1181
|
+
void getDshCookie()
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/** Stop the listener; `hard` (dispose) also ends every open connection. */
|
|
1185
|
+
function stop(hard) {
|
|
1186
|
+
if (server !== undefined) {
|
|
1187
|
+
try {
|
|
1188
|
+
stopServer?.(hard)
|
|
1189
|
+
} catch {
|
|
1190
|
+
/* already closed */
|
|
1191
|
+
}
|
|
1192
|
+
server = undefined
|
|
1193
|
+
stopServer = undefined
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// A migrated user patch brings the saved gate values back through the loader
|
|
1198
|
+
// watcher; until then, do not mint (and persist) replacements for them.
|
|
1199
|
+
let awaitingMigration = healUserPatch()
|
|
1200
|
+
let migrationTimer
|
|
1201
|
+
if (awaitingMigration) {
|
|
1202
|
+
migrationTimer = setTimeout(() => {
|
|
1203
|
+
if (!awaitingMigration) return
|
|
1204
|
+
awaitingMigration = false
|
|
1205
|
+
log("migrated values did not arrive; starting with the current configuration")
|
|
1206
|
+
start(readConfig())
|
|
1207
|
+
}, 10000)
|
|
1208
|
+
migrationTimer.unref?.()
|
|
1209
|
+
}
|
|
1210
|
+
start(readConfig())
|
|
1211
|
+
|
|
1212
|
+
// A volatile-only configuration edit keeps this instance and lands here.
|
|
1213
|
+
ctx.on("loader/volatile-update", () => {
|
|
1214
|
+
awaitingMigration = false
|
|
1215
|
+
clearTimeout(migrationTimer)
|
|
1216
|
+
start(readConfig())
|
|
1217
|
+
})
|
|
1218
|
+
|
|
1219
|
+
ctx.effect(() => () => {
|
|
1220
|
+
clearTimeout(migrationTimer)
|
|
1221
|
+
stop(true)
|
|
1222
|
+
log(`stopped${version ? ` (v${version})` : ""}`)
|
|
1223
|
+
}, "dsh-remote-access-proxy: proxy server")
|
|
1224
|
+
}
|