dsh-zen-remote 2.2.9 → 2.3.2

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.
Files changed (149) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/docs/interface.md +1 -1
  4. package/docs/remote-access.md +20 -0
  5. package/lan-gate.mjs +11 -1
  6. package/lib/adapters/dsh-better-sidebar/index.js +6 -2
  7. package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
  8. package/lib/adapters/dsh-better-sidebar/methods.js +3 -0
  9. package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -1
  10. package/lib/admin-routes.js +33 -2
  11. package/lib/admin-routes.js.map +1 -1
  12. package/lib/client.js +3293 -6
  13. package/lib/config.js +70 -0
  14. package/lib/config.js.map +1 -1
  15. package/lib/index.js +232 -2
  16. package/lib/index.js.map +1 -1
  17. package/lib/intercept.js +43 -3
  18. package/lib/intercept.js.map +1 -1
  19. package/lib/lan-gate-server.cjs +58 -3
  20. package/lib/merge-streams.js +42 -12
  21. package/lib/merge-streams.js.map +1 -1
  22. package/lib/types/adapters/dsh-better-sidebar/index.d.ts +1 -1
  23. package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
  24. package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -1
  25. package/lib/types/admin-routes.d.ts +13 -0
  26. package/lib/types/admin-routes.d.ts.map +1 -1
  27. package/lib/types/client/index.d.ts.map +1 -1
  28. package/lib/types/client/locales.d.ts +204 -1
  29. package/lib/types/client/locales.d.ts.map +1 -1
  30. package/lib/types/client/wizard/GatewayWizard.d.ts +29 -0
  31. package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -0
  32. package/lib/types/client/wizard/WizardCard.d.ts +30 -0
  33. package/lib/types/client/wizard/WizardCard.d.ts.map +1 -0
  34. package/lib/types/client/wizard/cards/MigrateCard.d.ts +15 -0
  35. package/lib/types/client/wizard/cards/MigrateCard.d.ts.map +1 -0
  36. package/lib/types/client/wizard/cards/PortForwardCard.d.ts +15 -0
  37. package/lib/types/client/wizard/cards/PortForwardCard.d.ts.map +1 -0
  38. package/lib/types/client/wizard/cards/ProxyCard.d.ts +19 -0
  39. package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -0
  40. package/lib/types/client/wizard/cards/TokenCard.d.ts +20 -0
  41. package/lib/types/client/wizard/cards/TokenCard.d.ts.map +1 -0
  42. package/lib/types/client/wizard/register-wizard.d.ts +51 -0
  43. package/lib/types/client/wizard/register-wizard.d.ts.map +1 -0
  44. package/lib/types/client/wizard/wizard-css.d.ts +10 -0
  45. package/lib/types/client/wizard/wizard-css.d.ts.map +1 -0
  46. package/lib/types/client-data/qr.d.ts +20 -0
  47. package/lib/types/client-data/qr.d.ts.map +1 -0
  48. package/lib/types/client-data/settings-form.d.ts +7 -2
  49. package/lib/types/client-data/settings-form.d.ts.map +1 -1
  50. package/lib/types/client-data/wizard.d.ts +247 -0
  51. package/lib/types/client-data/wizard.d.ts.map +1 -0
  52. package/lib/types/config.d.ts +27 -0
  53. package/lib/types/config.d.ts.map +1 -1
  54. package/lib/types/index.d.ts.map +1 -1
  55. package/lib/types/intercept.d.ts.map +1 -1
  56. package/lib/types/merge-streams.d.ts.map +1 -1
  57. package/lib/types/wizard/binary-io.d.ts +111 -0
  58. package/lib/types/wizard/binary-io.d.ts.map +1 -0
  59. package/lib/types/wizard/binary.d.ts +118 -0
  60. package/lib/types/wizard/binary.d.ts.map +1 -0
  61. package/lib/types/wizard/caddy-process.d.ts +101 -0
  62. package/lib/types/wizard/caddy-process.d.ts.map +1 -0
  63. package/lib/types/wizard/caddy.d.ts +53 -0
  64. package/lib/types/wizard/caddy.d.ts.map +1 -0
  65. package/lib/types/wizard/cloudflare.d.ts +112 -0
  66. package/lib/types/wizard/cloudflare.d.ts.map +1 -0
  67. package/lib/types/wizard/cloudflared.d.ts +71 -0
  68. package/lib/types/wizard/cloudflared.d.ts.map +1 -0
  69. package/lib/types/wizard/probe.d.ts +66 -0
  70. package/lib/types/wizard/probe.d.ts.map +1 -0
  71. package/lib/types/wizard/proxy-snippets.d.ts +60 -0
  72. package/lib/types/wizard/proxy-snippets.d.ts.map +1 -0
  73. package/lib/types/wizard/routes.d.ts +152 -0
  74. package/lib/types/wizard/routes.d.ts.map +1 -0
  75. package/lib/types/wizard/source-detect.d.ts +53 -0
  76. package/lib/types/wizard/source-detect.d.ts.map +1 -0
  77. package/lib/types/wizard/status.d.ts +115 -0
  78. package/lib/types/wizard/status.d.ts.map +1 -0
  79. package/lib/types/wizard/token-store.d.ts +27 -0
  80. package/lib/types/wizard/token-store.d.ts.map +1 -0
  81. package/lib/types/wizard/tunnel-orchestrator.d.ts +90 -0
  82. package/lib/types/wizard/tunnel-orchestrator.d.ts.map +1 -0
  83. package/lib/types/wizard/verify.d.ts +97 -0
  84. package/lib/types/wizard/verify.d.ts.map +1 -0
  85. package/lib/wizard/binary-io.js +386 -0
  86. package/lib/wizard/binary-io.js.map +1 -0
  87. package/lib/wizard/binary.js +138 -0
  88. package/lib/wizard/binary.js.map +1 -0
  89. package/lib/wizard/caddy-process.js +162 -0
  90. package/lib/wizard/caddy-process.js.map +1 -0
  91. package/lib/wizard/caddy.js +80 -0
  92. package/lib/wizard/caddy.js.map +1 -0
  93. package/lib/wizard/cloudflare.js +334 -0
  94. package/lib/wizard/cloudflare.js.map +1 -0
  95. package/lib/wizard/cloudflared.js +158 -0
  96. package/lib/wizard/cloudflared.js.map +1 -0
  97. package/lib/wizard/probe.js +118 -0
  98. package/lib/wizard/probe.js.map +1 -0
  99. package/lib/wizard/proxy-snippets.js +112 -0
  100. package/lib/wizard/proxy-snippets.js.map +1 -0
  101. package/lib/wizard/routes.js +462 -0
  102. package/lib/wizard/routes.js.map +1 -0
  103. package/lib/wizard/source-detect.js +77 -0
  104. package/lib/wizard/source-detect.js.map +1 -0
  105. package/lib/wizard/status.js +94 -0
  106. package/lib/wizard/status.js.map +1 -0
  107. package/lib/wizard/token-store.js +71 -0
  108. package/lib/wizard/token-store.js.map +1 -0
  109. package/lib/wizard/tunnel-orchestrator.js +155 -0
  110. package/lib/wizard/tunnel-orchestrator.js.map +1 -0
  111. package/lib/wizard/verify.js +171 -0
  112. package/lib/wizard/verify.js.map +1 -0
  113. package/package.json +37 -37
  114. package/scripts/check-settings-form.mjs +5 -4
  115. package/scripts/check-wizard-ui.mjs +690 -0
  116. package/src/adapters/dsh-better-sidebar/index.ts +6 -2
  117. package/src/adapters/dsh-better-sidebar/methods.ts +3 -0
  118. package/src/admin-routes.ts +44 -2
  119. package/src/client/index.tsx +9 -0
  120. package/src/client/locales.ts +410 -2
  121. package/src/client/wizard/GatewayWizard.tsx +2224 -0
  122. package/src/client/wizard/WizardCard.tsx +81 -0
  123. package/src/client/wizard/cards/MigrateCard.tsx +42 -0
  124. package/src/client/wizard/cards/PortForwardCard.tsx +37 -0
  125. package/src/client/wizard/cards/ProxyCard.tsx +56 -0
  126. package/src/client/wizard/cards/TokenCard.tsx +81 -0
  127. package/src/client/wizard/register-wizard.ts +99 -0
  128. package/src/client/wizard/wizard-css.ts +948 -0
  129. package/src/client-data/qr.ts +284 -0
  130. package/src/client-data/settings-form.ts +12 -3
  131. package/src/client-data/wizard.ts +432 -0
  132. package/src/config.ts +87 -0
  133. package/src/index.ts +227 -2
  134. package/src/intercept.ts +43 -3
  135. package/src/merge-streams.ts +41 -12
  136. package/src/wizard/binary-io.ts +449 -0
  137. package/src/wizard/binary.ts +177 -0
  138. package/src/wizard/caddy-process.ts +228 -0
  139. package/src/wizard/caddy.ts +97 -0
  140. package/src/wizard/cloudflare.ts +413 -0
  141. package/src/wizard/cloudflared.ts +196 -0
  142. package/src/wizard/probe.ts +161 -0
  143. package/src/wizard/proxy-snippets.ts +128 -0
  144. package/src/wizard/routes.ts +537 -0
  145. package/src/wizard/source-detect.ts +87 -0
  146. package/src/wizard/status.ts +180 -0
  147. package/src/wizard/token-store.ts +73 -0
  148. package/src/wizard/tunnel-orchestrator.ts +197 -0
  149. package/src/wizard/verify.ts +236 -0
@@ -0,0 +1,537 @@
1
+ /**
2
+ * Wizard route skeleton (GW-4, extended by GW-5): every
3
+ * `/_dsh/zen-remote/admin/wizard/*` route the gateway wizard serves, with
4
+ * the security walls applied to ALL of them, in this order:
5
+ *
6
+ * 1. `connection.admit` — webServer-registered routes skip DSH's `/api`
7
+ * authentication, so each request asks the connection service first;
8
+ * its rejection is relayed verbatim (401/403).
9
+ * 2. The gateway marker. A request wearing `x-zen-remote-via` came
10
+ * THROUGH the gateway from a remote device. Unlike the admin status
11
+ * route there is no read-only exception: the wizard is a server-owner
12
+ * surface, and a remote device gets a flat 403 `via-gateway` — GET
13
+ * included. The marker wall runs BEFORE same-origin because a
14
+ * forwarded request presents as same-origin (the gateway rewrites
15
+ * Origin/Host onto the upstream origin); the marker is the only thing
16
+ * identifying it.
17
+ * 3. `sameOriginPost` for every POST.
18
+ *
19
+ * Real routes: `GET /wizard/status` (the sanitized projection from
20
+ * status.ts — reads only, never mutates) and, since GW-5, `POST
21
+ * /wizard/trust-proxy` (merge one detected proxy IP into the persisted
22
+ * `trustedProxies` list). Every other POST under the prefix is a 501
23
+ * placeholder; later tasks fill those in. GET /status answers from a
24
+ * per-request config resolution and the injected runtime probe; a throwing
25
+ * runtime probe degrades to "nothing configured" instead of failing the
26
+ * read.
27
+ *
28
+ * The handler is built by a factory so the tests drive it over a real
29
+ * socket with mock admit/config (same seam as the admin routes).
30
+ */
31
+
32
+ import type { IncomingMessage, ServerResponse } from 'node:http'
33
+ import type { AdminAdmission, AdminConfigSnapshot } from '../admin-routes.js'
34
+ import type { ConfigSource } from '../config.js'
35
+ import { responseJson, sameOriginPost } from '../http.js'
36
+ import { hostOf } from '../config.js'
37
+ import { normalizeTrustedIp, mergeProxyList, parseProxyList } from './source-detect.js'
38
+ import type { CaddyStartResult } from './caddy-process.js'
39
+ import type { TunnelCheckResult, TunnelSetupResult } from './tunnel-orchestrator.js'
40
+ import { projectWizardStatus, type WizardRuntimeInput } from './status.js'
41
+ import type { VerificationCheckResult, VerificationStartResult } from './verify.js'
42
+
43
+ /** Prefix all wizard routes live under (one webServer prefix registration;
44
+ * the host web server matches longest-prefix-first, so these win over the
45
+ * plain admin prefix registration). */
46
+ export const WIZARD_ROUTE_PREFIX = '/_dsh/zen-remote/admin/wizard'
47
+
48
+ /** GET: the sanitized wizard status projection. */
49
+ export const WIZARD_STATUS_ROUTE = `${WIZARD_ROUTE_PREFIX}/status`
50
+
51
+ /** POST `{ip}`: merge one detected reverse-proxy IP into the persisted
52
+ * trustedProxies (GW-5). */
53
+ export const WIZARD_TRUST_PROXY_ROUTE = `${WIZARD_ROUTE_PREFIX}/trust-proxy`
54
+
55
+ /** POST `{publicUrl}`: start the final verification — mint the web pairing
56
+ * code (GW-9). GET: poll its status. */
57
+ export const WIZARD_VERIFY_ROUTE = `${WIZARD_ROUTE_PREFIX}/verify`
58
+
59
+ /** POST `{token}`: store the Cloudflare API token 0600 on this machine;
60
+ * DELETE: stop the consumers and remove it (GW-13a). */
61
+ export const WIZARD_CF_TOKEN_ROUTE = `${WIZARD_ROUTE_PREFIX}/cf-token`
62
+
63
+ /** POST `{token}`: store the cloudflared connector token (GW-13a). */
64
+ export const WIZARD_TUNNEL_TOKEN_ROUTE = `${WIZARD_ROUTE_PREFIX}/tunnel-token`
65
+
66
+ /** POST: start the managed Caddy from the saved row config; POST (stop)
67
+ * terminates it; GET answers its three-state (GW-13a). */
68
+ export const WIZARD_CADDY_START_ROUTE = `${WIZARD_ROUTE_PREFIX}/caddy/start`
69
+ export const WIZARD_CADDY_STOP_ROUTE = `${WIZARD_ROUTE_PREFIX}/caddy/stop`
70
+ export const WIZARD_CADDY_STATUS_ROUTE = `${WIZARD_ROUTE_PREFIX}/caddy/status`
71
+
72
+ /** POST `{domain}`: the zone/account probe the migration card re-checks;
73
+ * POST `{domain, tunnelId?}`: the full tunnel setup; POST (stop) and GET
74
+ * (three-state) mirror the Caddy pair (GW-13b). */
75
+ export const WIZARD_TUNNEL_CHECK_ROUTE = `${WIZARD_ROUTE_PREFIX}/tunnel/check-domain`
76
+ export const WIZARD_TUNNEL_SETUP_ROUTE = `${WIZARD_ROUTE_PREFIX}/tunnel/setup`
77
+ export const WIZARD_TUNNEL_STOP_ROUTE = `${WIZARD_ROUTE_PREFIX}/tunnel/stop`
78
+ export const WIZARD_TUNNEL_STATUS_ROUTE = `${WIZARD_ROUTE_PREFIX}/tunnel/status`
79
+
80
+ /** The managed Caddy's three-state. `stopped` means a start happened and
81
+ * the process has since exited (degraded); `never-started` precedes that. */
82
+ export type CaddyProcessState = 'running' | 'stopped' | 'never-started'
83
+
84
+ /** Inbound body cap — the wizard forms send a few hundred bytes. */
85
+ const MAX_BODY_BYTES = 16 * 1024
86
+
87
+ export interface WizardHandlerOptions {
88
+ /** The connection service's admit, injected by apply(); tests fake it. */
89
+ admit: (req: IncomingMessage) => AdminAdmission
90
+ /** Fresh resolveConfig() per call — the ingress fields are volatile row
91
+ * fields and must not go stale. */
92
+ getConfig: () => AdminConfigSnapshot
93
+ /** The live wizard runtime facts (token presence as booleans, process
94
+ * liveness, the gateway's actual port, probe memories, the untrusted-
95
+ * proxy detection), read PER REQUEST. MAY be async (the GW-5 wiring
96
+ * rounds-trips the gateway's own status). MUST be read-only — GET
97
+ * /status calls it, and a GET never changes state. Absent means
98
+ * "nothing configured, nothing running"; a throw is degraded to the
99
+ * same. Later tasks inject the real probe. */
100
+ getRuntime?: () => WizardRuntimeInput | Promise<WizardRuntimeInput>
101
+ /** The trust-proxy persistence (GW-5, GW-fix-2/L2), injected by apply():
102
+ * the CURRENTLY resolved trustedProxies with its layer, and the atomic
103
+ * persister for a merged list. `write` re-checks the persisted surface
104
+ * against `expected` (the value `resolved` answered moments earlier) —
105
+ * answering false when another writer moved it in between, which the
106
+ * route turns into a 409 instead of a silent clobber. Absent — a wiring
107
+ * that predates GW-5 — and the route answers 501 like the other
108
+ * placeholders. */
109
+ trustProxy?: {
110
+ resolved: () => { value: string; source: ConfigSource }
111
+ write: (expected: string, list: string) => boolean | Promise<boolean>
112
+ }
113
+ /** The Cloudflare token store (GW-13a), injected by apply(): 0600 files
114
+ * under the ingress directory. `deleteCfToken` is called AFTER the
115
+ * callers have stopped the consuming processes. Absent — a wiring that
116
+ * predates GW-13 — and the routes answer 501. */
117
+ tokenStore?: {
118
+ writeCfToken: (token: string) => void
119
+ deleteCfToken: () => void
120
+ cfTokenConfigured: () => boolean
121
+ writeTunnelToken: (token: string) => void
122
+ }
123
+ /** The managed Caddy (GW-13a), injected by apply(). `start` receives the
124
+ * SAVED row values and the gateway's actual port; the Cloudflare token
125
+ * comes from the token store inside the wiring (never through routes). */
126
+ caddy?: {
127
+ start: (input: { domain: string; httpsPort: number; gatewayPort: number }) => Promise<CaddyStartResult>
128
+ stop: () => void
129
+ status: () => CaddyProcessState
130
+ }
131
+ /** The Cloudflare Tunnel orchestration (GW-13b), injected by apply().
132
+ * `setup` receives the SAVED domain and (when the user adopted an
133
+ * existing tunnel) its id; the Cloudflare token comes from the token
134
+ * store inside the wiring. Absent — unwired — and the routes 501. */
135
+ tunnel?: {
136
+ checkDomain: (domain: string) => Promise<TunnelCheckResult>
137
+ setup: (input: { domain: string; tunnelId?: string }) => Promise<TunnelSetupResult>
138
+ stop: () => void
139
+ status: () => CaddyProcessState
140
+ }
141
+ /** The final-verification orchestration (GW-9), injected by apply():
142
+ * POST mints the pairing code, GET polls the session. Absent — a wiring
143
+ * that predates GW-9 — and both answer 501 like the placeholders. */
144
+ verify?: {
145
+ start: (input: { publicUrl: unknown; probeOnly?: boolean }) => Promise<VerificationStartResult>
146
+ check: () => Promise<VerificationCheckResult>
147
+ }
148
+ }
149
+
150
+ export type WizardHandler = (req: IncomingMessage, res: ServerResponse) => Promise<void>
151
+
152
+ /**
153
+ * Read the whole body under the cap and demand one JSON object. An empty
154
+ * body counts as `{}`. Answers null for anything malformed — the route
155
+ * maps that to 400.
156
+ */
157
+ async function readJsonObject(req: IncomingMessage): Promise<Record<string, unknown> | null> {
158
+ const declared = req.headers['content-length']
159
+ const expected = declared === undefined ? undefined : Number(declared)
160
+ if (expected !== undefined && Number.isSafeInteger(expected) && expected > MAX_BODY_BYTES) return null
161
+ const chunks: Buffer[] = []
162
+ let size = 0
163
+ for await (const chunk of req) {
164
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array)
165
+ size += bytes.length
166
+ if (size > MAX_BODY_BYTES) return null
167
+ chunks.push(bytes)
168
+ }
169
+ const text = Buffer.concat(chunks).toString('utf8')
170
+ if (text.trim() === '') return {}
171
+ try {
172
+ const parsed: unknown = JSON.parse(text)
173
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null
174
+ return parsed as Record<string, unknown>
175
+ } catch {
176
+ return null
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Build the wizard route handler for one plugin row. The returned handler
182
+ * owns the full response lifecycle of every request under
183
+ * {@link WIZARD_ROUTE_PREFIX} and never throws.
184
+ */
185
+ export function createWizardHandler(options: WizardHandlerOptions): WizardHandler {
186
+ return async (req, res) => {
187
+ // EVERYTHING rides inside the one guard (GW-fix-1/L1): a malformed
188
+ // req.url explodes in `new URL`, and a broken connection service can
189
+ // throw out of admit — neither may escape as an unhandled rejection,
190
+ // the handler owns the full response lifecycle, always.
191
+ try {
192
+ // Wall 1 first: DSH's own admission.
193
+ const admission = options.admit(req)
194
+ if ('rejection' in admission) {
195
+ responseJson(res, admission.rejection, {
196
+ ok: false,
197
+ error: { code: admission.rejection === 401 ? 'unauthorized' : 'forbidden' },
198
+ })
199
+ return
200
+ }
201
+ const url = new URL(req.url ?? '/', 'http://dsh.internal')
202
+ const route = url.pathname
203
+ const method = req.method ?? 'GET'
204
+ // Wall 2, on EVERY wizard route before anything else: unlike
205
+ // admin/status there is no remote-readable exception — the wizard is
206
+ // a server-owner surface, GET included. This runs BEFORE same-origin
207
+ // because a forwarded request presents as same-origin (the gateway
208
+ // rewrites Origin/Host); the marker is the identifying signal.
209
+ if (req.headers['x-zen-remote-via'] !== undefined) {
210
+ responseJson(res, 403, {
211
+ ok: false,
212
+ error: { code: 'via-gateway', message: '配置引导只能在服务端本机操作' },
213
+ })
214
+ return
215
+ }
216
+ if (route === WIZARD_STATUS_ROUTE && method === 'GET') {
217
+ let runtime: WizardRuntimeInput = {}
218
+ try {
219
+ runtime = (await options.getRuntime?.()) ?? {}
220
+ } catch {
221
+ // A broken runtime probe is "no information", never a failed read.
222
+ runtime = {}
223
+ }
224
+ responseJson(res, 200, projectWizardStatus(options.getConfig().values, runtime))
225
+ return
226
+ }
227
+ if (route === WIZARD_TRUST_PROXY_ROUTE && method === 'POST') {
228
+ if (!sameOriginPost(req)) {
229
+ responseJson(res, 403, {
230
+ ok: false,
231
+ error: { code: 'origin-rejected', message: 'The request must originate from this DSH Web application' },
232
+ })
233
+ return
234
+ }
235
+ const trust = options.trustProxy
236
+ // A wiring that predates GW-5 has no persister: same answer as the
237
+ // other unwired verbs.
238
+ if (trust === undefined) {
239
+ responseJson(res, 501, { ok: false, error: { code: 'not-implemented', message: 'This wizard step is not wired up yet' } })
240
+ return
241
+ }
242
+ const body = await readJsonObject(req)
243
+ if (body === null) {
244
+ responseJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'request body must be a JSON object' } })
245
+ return
246
+ }
247
+ const ip = normalizeTrustedIp(body.ip)
248
+ if (ip === undefined) {
249
+ responseJson(res, 400, { ok: false, error: { code: 'bad-ip', message: 'ip must be an IPv4 or IPv6 literal' } })
250
+ return
251
+ }
252
+ let resolved: { value: string; source: ConfigSource }
253
+ try {
254
+ resolved = trust.resolved()
255
+ } catch {
256
+ resolved = { value: '', source: 'default' }
257
+ }
258
+ // env / row values SHADOW the file layer the persister writes: a
259
+ // write there would silently do nothing. Refuse honestly instead.
260
+ if (resolved.source === 'env' || resolved.source === 'row') {
261
+ responseJson(res, 409, {
262
+ ok: false,
263
+ error: {
264
+ code: 'proxy-list-shadowed',
265
+ message: `trustedProxies 目前由${resolved.source === 'env' ? '环境变量' : '插件行配置'}提供,请先清掉那边的值再自动信任`,
266
+ },
267
+ })
268
+ return
269
+ }
270
+ if (parseProxyList(resolved.value).includes(ip)) {
271
+ responseJson(res, 200, { ok: true, added: false })
272
+ return
273
+ }
274
+ const merged = mergeProxyList(resolved.value, ip)
275
+ // GW-fix-2/L2: the persister re-reads the persisted surface and
276
+ // refuses when it moved since `resolved()` — two 信任 clicks in
277
+ // quick succession must not lose either IP.
278
+ const landed = await trust.write(resolved.value, merged)
279
+ if (landed === false) {
280
+ responseJson(res, 409, {
281
+ ok: false,
282
+ error: { code: 'trust-list-changed', message: '信任列表刚被其他操作修改,请刷新后重试' },
283
+ })
284
+ return
285
+ }
286
+ responseJson(res, 200, { ok: true, added: true })
287
+ return
288
+ }
289
+ if (route === WIZARD_CF_TOKEN_ROUTE && (method === 'POST' || method === 'DELETE')) {
290
+ if (!sameOriginPost(req)) {
291
+ responseJson(res, 403, {
292
+ ok: false,
293
+ error: { code: 'origin-rejected', message: 'The request must originate from this DSH Web application' },
294
+ })
295
+ return
296
+ }
297
+ const store = options.tokenStore
298
+ if (store === undefined) {
299
+ responseJson(res, 501, { ok: false, error: { code: 'not-implemented', message: 'This wizard step is not wired up yet' } })
300
+ return
301
+ }
302
+ if (method === 'DELETE') {
303
+ store.deleteCfToken()
304
+ responseJson(res, 200, { ok: true, configured: false })
305
+ return
306
+ }
307
+ const body = await readJsonObject(req)
308
+ const token = typeof body?.token === 'string' ? body.token.trim() : ''
309
+ if (token === '') {
310
+ responseJson(res, 400, { ok: false, error: { code: 'bad-token', message: 'token must be a non-empty string' } })
311
+ return
312
+ }
313
+ store.writeCfToken(token)
314
+ responseJson(res, 200, { ok: true, configured: true })
315
+ return
316
+ }
317
+ if (route === WIZARD_TUNNEL_TOKEN_ROUTE && method === 'POST') {
318
+ if (!sameOriginPost(req)) {
319
+ responseJson(res, 403, {
320
+ ok: false,
321
+ error: { code: 'origin-rejected', message: 'The request must originate from this DSH Web application' },
322
+ })
323
+ return
324
+ }
325
+ const store = options.tokenStore
326
+ if (store === undefined) {
327
+ responseJson(res, 501, { ok: false, error: { code: 'not-implemented', message: 'This wizard step is not wired up yet' } })
328
+ return
329
+ }
330
+ const body = await readJsonObject(req)
331
+ const token = typeof body?.token === 'string' ? body.token.trim() : ''
332
+ if (token === '') {
333
+ responseJson(res, 400, { ok: false, error: { code: 'bad-token', message: 'token must be a non-empty string' } })
334
+ return
335
+ }
336
+ store.writeTunnelToken(token)
337
+ responseJson(res, 200, { ok: true, configured: true })
338
+ return
339
+ }
340
+ if (route.startsWith(`${WIZARD_ROUTE_PREFIX}/caddy/`)) {
341
+ // Walls first, then the method shape, then (and only then) the
342
+ // wiring check — a 501 must never answer before the walls, and a
343
+ // wrong method on a wired route must still read 405.
344
+ if (method === 'POST' && !sameOriginPost(req)) {
345
+ responseJson(res, 403, {
346
+ ok: false,
347
+ error: { code: 'origin-rejected', message: 'The request must originate from this DSH Web application' },
348
+ })
349
+ return
350
+ }
351
+ if (method !== 'GET' && method !== 'POST') {
352
+ res.setHeader('Allow', 'GET, POST')
353
+ responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use GET or POST' } })
354
+ return
355
+ }
356
+ const caddy = options.caddy
357
+ if (caddy === undefined) {
358
+ responseJson(res, 501, { ok: false, error: { code: 'not-implemented', message: 'This wizard step is not wired up yet' } })
359
+ return
360
+ }
361
+ if (route === WIZARD_CADDY_STATUS_ROUTE && method === 'GET') {
362
+ responseJson(res, 200, { ok: true, state: caddy.status() })
363
+ return
364
+ }
365
+ if (route === WIZARD_CADDY_STOP_ROUTE && method !== 'POST') {
366
+ // GW-fix-4/H2: a GET must never stop a process, even by accident.
367
+ res.setHeader('Allow', 'GET, POST')
368
+ responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use POST to stop' } })
369
+ return
370
+ }
371
+ if (route === WIZARD_CADDY_STOP_ROUTE) {
372
+ caddy.stop()
373
+ responseJson(res, 200, { ok: true, state: caddy.status() })
374
+ return
375
+ }
376
+ if (route === WIZARD_CADDY_START_ROUTE && method === 'POST') {
377
+ const body = await readJsonObject(req)
378
+ const values = options.getConfig().values
379
+ const rawDomain = typeof body?.domain === 'string' && body.domain.trim() !== ''
380
+ ? body.domain.trim()
381
+ : values.ingressDomain
382
+ const domain = hostOf(rawDomain)
383
+ if (domain === undefined) {
384
+ responseJson(res, 400, { ok: false, error: { code: 'no-domain', message: '保存一个有效的公开域名后再启动' } })
385
+ return
386
+ }
387
+ const rawPort = typeof body?.httpsPort === 'number' && Number.isSafeInteger(body.httpsPort) && body.httpsPort >= 1 && body.httpsPort <= 65535
388
+ ? body.httpsPort
389
+ : values.ingressHttpsPort
390
+ let runtime: WizardRuntimeInput = {}
391
+ try {
392
+ runtime = (await options.getRuntime?.()) ?? {}
393
+ } catch {
394
+ runtime = {}
395
+ }
396
+ const gatewayPort = typeof runtime.gatewayActualPort === 'number' ? runtime.gatewayActualPort : values.port
397
+ const result = await caddy.start({ domain, httpsPort: rawPort, gatewayPort })
398
+ // The start result may carry a failure detail — never the token.
399
+ responseJson(res, 200, { ok: result.ok, ...(result.ok ? { caddyfile: result.caddyfile } : { error: result.error, detail: result.detail, installUrl: result.installUrl }) })
400
+ return
401
+ }
402
+ responseJson(res, 404, { ok: false, error: { code: 'not-found', message: 'Unknown wizard route' } })
403
+ return
404
+ }
405
+ if (route.startsWith(`${WIZARD_ROUTE_PREFIX}/tunnel/`)) {
406
+ // Same discipline as the caddy block: walls, then method shape,
407
+ // then the wiring check.
408
+ if (method === 'POST' && !sameOriginPost(req)) {
409
+ responseJson(res, 403, {
410
+ ok: false,
411
+ error: { code: 'origin-rejected', message: 'The request must originate from this DSH Web application' },
412
+ })
413
+ return
414
+ }
415
+ if (method !== 'GET' && method !== 'POST') {
416
+ res.setHeader('Allow', 'GET, POST')
417
+ responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use GET or POST' } })
418
+ return
419
+ }
420
+ const tunnel = options.tunnel
421
+ if (tunnel === undefined) {
422
+ responseJson(res, 501, { ok: false, error: { code: 'not-implemented', message: 'This wizard step is not wired up yet' } })
423
+ return
424
+ }
425
+ if (route === WIZARD_TUNNEL_STATUS_ROUTE && method === 'GET') {
426
+ responseJson(res, 200, { ok: true, state: tunnel.status() })
427
+ return
428
+ }
429
+ if (method !== 'POST') {
430
+ res.setHeader('Allow', 'GET, POST')
431
+ responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use GET or POST' } })
432
+ return
433
+ }
434
+ const body = await readJsonObject(req)
435
+ const domain = typeof body?.domain === 'string' ? body.domain : ''
436
+ if (route === WIZARD_TUNNEL_CHECK_ROUTE) {
437
+ const result = await tunnel.checkDomain(domain)
438
+ responseJson(res, 200, { ok: result.ok, ...(result.ok ? { onCloudflare: result.onCloudflare, zoneId: result.zoneId } : { reason: result.reason, detail: result.detail }) })
439
+ return
440
+ }
441
+ if (route === WIZARD_TUNNEL_STOP_ROUTE && method !== 'POST') {
442
+ res.setHeader('Allow', 'GET, POST')
443
+ responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use POST to stop' } })
444
+ return
445
+ }
446
+ if (route === WIZARD_TUNNEL_STOP_ROUTE) {
447
+ tunnel.stop()
448
+ responseJson(res, 200, { ok: true, state: tunnel.status() })
449
+ return
450
+ }
451
+ if (route === WIZARD_TUNNEL_SETUP_ROUTE) {
452
+ const tunnelId = typeof body?.tunnelId === 'string' && body.tunnelId !== '' ? body.tunnelId : undefined
453
+ const result = await tunnel.setup({ domain, tunnelId })
454
+ // The setup answer never carries the connector token — only its
455
+ // file path (a server-local fact) and the conflict list.
456
+ if (result.ok) {
457
+ responseJson(res, 200, { ok: true, tunnelId: result.tunnelId, publicUrl: result.publicUrl })
458
+ } else {
459
+ responseJson(res, 200, {
460
+ ok: false,
461
+ reason: result.reason,
462
+ ...(result.detail !== undefined ? { detail: result.detail } : {}),
463
+ ...(result.existing !== undefined ? { existing: result.existing } : {}),
464
+ ...(result.installUrl !== undefined ? { installUrl: result.installUrl } : {}),
465
+ })
466
+ }
467
+ return
468
+ }
469
+ responseJson(res, 404, { ok: false, error: { code: 'not-found', message: 'Unknown wizard route' } })
470
+ return
471
+ }
472
+ if (route === WIZARD_VERIFY_ROUTE && (method === 'POST' || method === 'GET')) {
473
+ // Wall 3 applies to the POST; the GET is read-only polling. Both
474
+ // answers are 200 with the orchestration's own verdict in the
475
+ // body — the UI branches on `ok` / `result`, not on status codes.
476
+ if (method === 'POST' && !sameOriginPost(req)) {
477
+ responseJson(res, 403, {
478
+ ok: false,
479
+ error: { code: 'origin-rejected', message: 'The request must originate from this DSH Web application' },
480
+ })
481
+ return
482
+ }
483
+ const verify = options.verify
484
+ if (verify === undefined) {
485
+ responseJson(res, 501, { ok: false, error: { code: 'not-implemented', message: 'This wizard step is not wired up yet' } })
486
+ return
487
+ }
488
+ if (method === 'GET') {
489
+ responseJson(res, 200, await verify.check())
490
+ return
491
+ }
492
+ const body = await readJsonObject(req)
493
+ if (body === null) {
494
+ responseJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'request body must be a JSON object' } })
495
+ return
496
+ }
497
+ responseJson(res, 200, await verify.start({
498
+ publicUrl: body.publicUrl,
499
+ ...(body.probeOnly === true ? { probeOnly: true } : {}),
500
+ }))
501
+ return
502
+ }
503
+ if (route.startsWith(`${WIZARD_ROUTE_PREFIX}/`)) {
504
+ if (method === 'POST') {
505
+ if (!sameOriginPost(req)) {
506
+ responseJson(res, 403, {
507
+ ok: false,
508
+ error: { code: 'origin-rejected', message: 'The request must originate from this DSH Web application' },
509
+ })
510
+ return
511
+ }
512
+ // Skeleton placeholder: later tasks mount the real verbs here.
513
+ responseJson(res, 501, {
514
+ ok: false,
515
+ error: { code: 'not-implemented', message: 'This wizard step is not wired up yet' },
516
+ })
517
+ return
518
+ }
519
+ if (method === 'GET') {
520
+ // Unknown GET under the wizard prefix: 404, after the walls.
521
+ responseJson(res, 404, { ok: false, error: { code: 'not-found', message: 'Unknown wizard route' } })
522
+ return
523
+ }
524
+ res.setHeader('Allow', 'GET, POST')
525
+ responseJson(res, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use GET or POST' } })
526
+ return
527
+ }
528
+ responseJson(res, 404, { ok: false, error: { code: 'not-found', message: 'Unknown wizard route' } })
529
+ } catch {
530
+ if (!res.headersSent) {
531
+ responseJson(res, 500, { ok: false, error: { code: 'internal', message: 'The wizard route failed' } })
532
+ } else {
533
+ res.destroy()
534
+ }
535
+ }
536
+ }
537
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Reverse-proxy source detection (GW-5), pure logic — the browser-facing
3
+ * half of the 「检测到反代从 {ip} 连过来,自动信任?」flow.
4
+ *
5
+ * The gateway (lib/lan-gate-server.cjs) records the last request that
6
+ * carried proxy headers from a socket it does NOT trust — almost always
7
+ * the user's own reverse proxy whose IP never made it into
8
+ * `trustedProxies` — as `lastForwardedSource: { ip, at }` in its
9
+ * `/lan-gate/status` answer. {@link detectForwardedSource} lifts that
10
+ * field out (defensively: the gateway may be old and send nothing), and
11
+ * {@link mergeProxyList} / {@link normalizeTrustedIp} build the merged
12
+ * trust list the trust-proxy route persists.
13
+ *
14
+ * Pure: no imports, no I/O — the check scripts can drive it directly.
15
+ */
16
+
17
+ /**
18
+ * The recognized shape of the gateway's `lastForwardedSource`. Everything
19
+ * else — missing field (an older gateway), junk values — reads as "no
20
+ * detection".
21
+ */
22
+ export interface ForwardedSourceInput {
23
+ ip?: unknown
24
+ at?: unknown
25
+ }
26
+
27
+ export interface DetectedForwardedSource {
28
+ ip: string
29
+ at: number
30
+ }
31
+
32
+ /**
33
+ * Extract the untrusted-proxy detection from one `/lan-gate/status` body.
34
+ * Returns null when the gateway predates the field or the field is
35
+ * malformed — a missing detection is the common case, never an error.
36
+ */
37
+ export function detectForwardedSource(gatewayStatus: unknown): DetectedForwardedSource | null {
38
+ if (gatewayStatus === null || typeof gatewayStatus !== 'object') return null
39
+ const source = (gatewayStatus as { lastForwardedSource?: unknown }).lastForwardedSource
40
+ if (source === null || typeof source !== 'object') return null
41
+ const { ip, at } = source as ForwardedSourceInput
42
+ if (typeof ip !== 'string' || ip === '') return null
43
+ if (typeof at !== 'number' || !Number.isFinite(at)) return null
44
+ return { ip, at }
45
+ }
46
+
47
+ /**
48
+ * One trust-list entry: an IP literal, normalized the way the gateway
49
+ * normalizes socket peers (`::ffff:` IPv4-in-IPv6 unwrapped). Refuses
50
+ * anything that is not a plausible IPv4/IPv6 literal — the value goes
51
+ * into persisted config and later into an exact string comparison
52
+ * against socket peers, where a hostname or a CIDR would silently never
53
+ * match.
54
+ */
55
+ export function normalizeTrustedIp(raw: unknown): string | undefined {
56
+ if (typeof raw !== 'string') return undefined
57
+ const value = raw.trim()
58
+ if (value === '') return undefined
59
+ // IPv4-in-IPv6 form the gateway itself strips from socket peers.
60
+ const unwrapped = value.toLowerCase().startsWith('::ffff:') ? value.slice(7) : value
61
+ if (/^\d{1,3}(?:\.\d{1,3}){3}$/.test(unwrapped)) {
62
+ return unwrapped.split('.').every((part) => Number(part) <= 255) ? unwrapped : undefined
63
+ }
64
+ // IPv6: hex groups and colons only, at least one colon, no zone index.
65
+ if (/^[0-9a-f:]+$/i.test(unwrapped) && unwrapped.includes(':') && !unwrapped.includes('%')) {
66
+ return unwrapped.toLowerCase()
67
+ }
68
+ return undefined
69
+ }
70
+
71
+ /** Split the comma-separated trustedProxies value into entries. */
72
+ export function parseProxyList(value: unknown): string[] {
73
+ if (typeof value !== 'string') return []
74
+ return value.split(',').map((entry) => entry.trim()).filter((entry) => entry !== '')
75
+ }
76
+
77
+ /**
78
+ * Append one IP to the comma-separated list, preserving order, skipping
79
+ * duplicates (the trust-proxy route answers "already trusted" instead of
80
+ * rewriting). Normalization is the caller's job — the route normalizes
81
+ * before merging.
82
+ */
83
+ export function mergeProxyList(current: unknown, ip: string): string {
84
+ const entries = parseProxyList(current)
85
+ if (entries.includes(ip)) return entries.join(',')
86
+ return [...entries, ip].join(',')
87
+ }