dsh-zen-remote 2.2.9 → 2.3.1

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 +3289 -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 +29 -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 +2220 -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,180 @@
1
+ /**
2
+ * Wizard status projection (GW-1): declared ingress config + observed
3
+ * runtime facts → one sanitized JSON shape the browser may see.
4
+ *
5
+ * This module is the SECURITY BOUNDARY for everything the wizard surfaces
6
+ * on `GET /_dsh/zen-remote/admin/wizard/status`. The two properties it must
7
+ * hold, regardless of what the callers hand it:
8
+ *
9
+ * 1. No secret material. The Cloudflare API token and the cloudflared
10
+ * tunnel token never enter this module — the runtime side reports only
11
+ * `*Configured` booleans (the files live 0600 under
12
+ * `<DSH_HOME>/zen-remote-ingress/`, and their contents stay there).
13
+ * 2. No file paths. The runtime side knows where binaries, token files
14
+ * and the Caddyfile live; the projection carries none of it — every
15
+ * output field is built from a whitelist below, and input objects are
16
+ * never spread through.
17
+ *
18
+ * Pure logic on purpose: zero imports, so the check scripts and the tests
19
+ * can drive it through Node's strip-only type stripping, and the wizard
20
+ * routes can call it per request without re-resolving anything.
21
+ *
22
+ * Every input is coerced defensively. The declared side normally arrives
23
+ * from `resolveConfig(...).values` (already legal), but the projection is
24
+ * also the last line of defense for the wire: an off-enum mode answers
25
+ * `'off'`, a junk port answers the 443 default, a non-string domain answers
26
+ * `''` — same transparency discipline as the config resolver, applied at
27
+ * the rendering edge.
28
+ */
29
+
30
+ /** The ingress modes `resolveConfig` can legally produce. */
31
+ export const WIZARD_INGRESS_MODES = ['off', 'caddy', 'external', 'cloudflared'] as const
32
+
33
+ export type WizardIngressMode = (typeof WIZARD_INGRESS_MODES)[number]
34
+
35
+ /**
36
+ * The declared slice of the resolved config. Loose (`unknown`) on purpose:
37
+ * the projection is what guarantees the wire shape, not the caller's typing.
38
+ */
39
+ export interface WizardDeclaredInput {
40
+ ingressMode?: unknown
41
+ ingressDomain?: unknown
42
+ ingressHttpsPort?: unknown
43
+ ingressPublicUrl?: unknown
44
+ }
45
+
46
+ /**
47
+ * One probe / verification memory the wizard back-end keeps. Only these
48
+ * three fields are recognized; everything else on the record (URLs probed,
49
+ * error details, headers) is dropped at the boundary.
50
+ */
51
+ export interface WizardRecordInput {
52
+ result?: unknown
53
+ deviceId?: unknown
54
+ at?: unknown
55
+ }
56
+
57
+ /**
58
+ * The gateway's own detection of an untrusted reverse proxy (GW-5): the
59
+ * socket peer of the last request that carried proxy headers from an IP
60
+ * outside `trustedProxies` — almost certainly the user's own proxy, and
61
+ * the one IP the trust-proxy flow offers to add. Whitelist-projected like
62
+ * everything else.
63
+ */
64
+ export interface WizardForwardedSourceInput {
65
+ ip?: unknown
66
+ at?: unknown
67
+ }
68
+
69
+ /**
70
+ * Runtime facts the wizard back-end observes live. Everything is optional —
71
+ * a composition that has not started any ingress process simply omits the
72
+ * fields. Token presence is reported as booleans only; port as the gateway
73
+ * child's ACTUAL listening port (it may differ from the configured one).
74
+ */
75
+ export interface WizardRuntimeInput {
76
+ cfTokenConfigured?: unknown
77
+ tunnelTokenConfigured?: unknown
78
+ caddyRunning?: unknown
79
+ cloudflaredRunning?: unknown
80
+ gatewayActualPort?: unknown
81
+ lanAddress?: unknown
82
+ lastHostProbe?: WizardRecordInput | null
83
+ lastExternalVerification?: WizardRecordInput | null
84
+ lastForwardedSource?: WizardForwardedSourceInput | null
85
+ }
86
+
87
+ /** One projected probe / verification memory, or null when there is none. */
88
+ export interface WizardStatusRecord {
89
+ result: string
90
+ deviceId: string | null
91
+ at: number | null
92
+ }
93
+
94
+ /** One projected untrusted-proxy detection, or null when none is pending. */
95
+ export interface WizardStatusForwardedSource {
96
+ ip: string
97
+ at: number
98
+ }
99
+
100
+ /** The full sanitized status the wizard status route answers with. */
101
+ export interface WizardStatus {
102
+ ok: true
103
+ mode: WizardIngressMode
104
+ domain: string
105
+ httpsPort: number
106
+ publicUrl: string
107
+ cfTokenConfigured: boolean
108
+ tunnelTokenConfigured: boolean
109
+ caddyRunning: boolean
110
+ cloudflaredRunning: boolean
111
+ gatewayActualPort: number | null
112
+ lanAddress?: string
113
+ lastHostProbe: WizardStatusRecord | null
114
+ lastExternalVerification: WizardStatusRecord | null
115
+ lastForwardedSource: WizardStatusForwardedSource | null
116
+ }
117
+
118
+ function modeOf(value: unknown): WizardIngressMode {
119
+ return typeof value === 'string' && (WIZARD_INGRESS_MODES as readonly string[]).includes(value)
120
+ ? (value as WizardIngressMode)
121
+ : 'off'
122
+ }
123
+
124
+ function stringOf(value: unknown): string {
125
+ return typeof value === 'string' ? value : ''
126
+ }
127
+
128
+ function portOf(value: unknown): number | null {
129
+ return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 65535 ? value : null
130
+ }
131
+
132
+ /**
133
+ * Project one probe / verification memory down to its three wire fields.
134
+ * A record without a string `result` is not a memory — it answers null, so
135
+ * a corrupt runtime entry can never render as a half-valid verdict.
136
+ */
137
+ function recordOf(input: WizardRecordInput | null | undefined): WizardStatusRecord | null {
138
+ if (input === null || input === undefined || typeof input !== 'object') return null
139
+ const { result, deviceId, at } = input
140
+ if (typeof result !== 'string' || result === '') return null
141
+ return {
142
+ result,
143
+ deviceId: typeof deviceId === 'string' && deviceId !== '' ? deviceId : null,
144
+ at: typeof at === 'number' && Number.isFinite(at) ? at : null,
145
+ }
146
+ }
147
+
148
+ /** Project one untrusted-proxy detection down to its two wire fields. */
149
+ function forwardedSourceOf(input: WizardForwardedSourceInput | null | undefined): WizardStatusForwardedSource | null {
150
+ if (input === null || input === undefined || typeof input !== 'object') return null
151
+ const { ip, at } = input
152
+ if (typeof ip !== 'string' || ip === '') return null
153
+ if (typeof at !== 'number' || !Number.isFinite(at)) return null
154
+ return { ip, at }
155
+ }
156
+
157
+ /**
158
+ * Build the sanitized wizard status. Both arguments are optional so a
159
+ * composition can answer before anything is configured; the all-empty
160
+ * answer is the honest "wizard not started" state (`mode: 'off'`, no ports,
161
+ * no tokens configured, no memories).
162
+ */
163
+ export function projectWizardStatus(declared: WizardDeclaredInput = {}, runtime: WizardRuntimeInput = {}): WizardStatus {
164
+ return {
165
+ ok: true,
166
+ mode: modeOf(declared.ingressMode),
167
+ domain: stringOf(declared.ingressDomain),
168
+ httpsPort: portOf(declared.ingressHttpsPort) ?? 443,
169
+ publicUrl: stringOf(declared.ingressPublicUrl),
170
+ cfTokenConfigured: runtime.cfTokenConfigured === true,
171
+ tunnelTokenConfigured: runtime.tunnelTokenConfigured === true,
172
+ caddyRunning: runtime.caddyRunning === true,
173
+ cloudflaredRunning: runtime.cloudflaredRunning === true,
174
+ gatewayActualPort: portOf(runtime.gatewayActualPort),
175
+ ...(typeof runtime.lanAddress === 'string' && runtime.lanAddress !== '' ? { lanAddress: runtime.lanAddress } : {}),
176
+ lastHostProbe: recordOf(runtime.lastHostProbe),
177
+ lastExternalVerification: recordOf(runtime.lastExternalVerification),
178
+ lastForwardedSource: forwardedSourceOf(runtime.lastForwardedSource),
179
+ }
180
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Wizard token storage (GW-13a): the secrets that must never enter the
3
+ * plugin row or any wire response live as 0600 files under
4
+ * `<DSH_HOME>/zen-remote-ingress/`, written through a temp-file rename so
5
+ * a crash mid-write can never leave a half token at the real path. The
6
+ * tunnel-token half lives in cloudflared.ts (it is the cloudflared
7
+ * process's own input); this module owns the shared Cloudflare API token.
8
+ *
9
+ * Reads answer presence or hand the raw string ONLY to in-process callers
10
+ * (the Caddy manager's env) — nothing here is ever logged or serialized
11
+ * into a response.
12
+ */
13
+
14
+ import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
15
+ import { join } from 'node:path'
16
+ import { ingressDir } from './binary.js'
17
+
18
+ /** `<DSH_HOME>/zen-remote-ingress/cf-api-token`. */
19
+ export function cfTokenPath(dshHome: string): string {
20
+ return join(ingressDir(dshHome), 'cf-api-token')
21
+ }
22
+
23
+ /** Atomic 0600 write (temp sibling + rename), with a direct-write fallback
24
+ * on Windows if an existing file or antivirus scan refuses `renameSync` with
25
+ * EPERM/EACCES. */
26
+ export function writeCfTokenFile(dshHome: string, token: string): string {
27
+ const finalPath = cfTokenPath(dshHome)
28
+ const partPath = `${finalPath}.part`
29
+ try {
30
+ mkdirSync(ingressDir(dshHome), { recursive: true })
31
+ writeFileSync(partPath, token, { mode: 0o600 })
32
+ try {
33
+ renameSync(partPath, finalPath)
34
+ } catch {
35
+ writeFileSync(finalPath, token, { mode: 0o600 })
36
+ rmSync(partPath, { force: true })
37
+ }
38
+ } catch (error) {
39
+ rmSync(partPath, { force: true })
40
+ throw error
41
+ }
42
+ return finalPath
43
+ }
44
+
45
+ /** The stored token, or undefined when absent/empty. Callers feed it to the
46
+ * Caddy manager's environment; it must never reach a response or a log. */
47
+ export function readCfTokenFile(dshHome: string): string | undefined {
48
+ try {
49
+ const token = readRaw(dshHome)
50
+ return token.trim() === '' ? undefined : token
51
+ } catch {
52
+ return undefined
53
+ }
54
+ }
55
+
56
+ /** Whether a non-empty token file exists (the projection's boolean only). */
57
+ export function cfTokenConfigured(dshHome: string): boolean {
58
+ try {
59
+ return statSync(cfTokenPath(dshHome)).isFile() && readRaw(dshHome).trim() !== ''
60
+ } catch {
61
+ return false
62
+ }
63
+ }
64
+
65
+ /** Delete the stored token (the DELETE route; callers stop the processes
66
+ * that consume it first). */
67
+ export function deleteCfTokenFile(dshHome: string): void {
68
+ rmSync(cfTokenPath(dshHome), { force: true })
69
+ }
70
+
71
+ function readRaw(dshHome: string): string {
72
+ return readFileSync(cfTokenPath(dshHome), 'utf8')
73
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Tunnel orchestration (GW-13b, 路 C's autopilot): one call drives the
3
+ * whole Cloudflare-side setup the design promises — zone/account lookup
4
+ * (through the ancestor-chain check), tunnel create-or-adopt, ingress
5
+ * configuration against the gateway's ACTUAL port, the proxied CNAME,
6
+ * and the cloudflared child.
7
+ *
8
+ * Rules the review and the design pin down:
9
+ *
10
+ * - **已有同名隧道**:the first setup pass answers `tunnel-conflict`
11
+ * with the same-name list; the UI lets the user pick one and re-runs
12
+ * with `tunnelId`, which adopts the existing tunnel through the token
13
+ * endpoint instead of creating anything.
14
+ * - **模式切换**:starting the tunnel stops the managed Caddy first
15
+ * (`stopCaddy`) — never two ingress processes at once.
16
+ * - The connector token travels from the Cloudflare answer into the
17
+ * 0600 token file and the child's `--token-file`; it never reaches a
18
+ * response or a log.
19
+ * - Every failure is a VALUE with a stable `reason` the UI maps to
20
+ * locale copy; CloudflareError codes map losslessly (`cf-*` → the
21
+ * orchestration's own reasons), unknown ones degrade to `api`.
22
+ */
23
+
24
+ import { hostOf } from '../config.js'
25
+ import { createCloudflareApi, type CloudflareApi, type CloudflareTunnelSummary } from './cloudflare.js'
26
+ import { writeTunnelTokenFile } from './cloudflared.js'
27
+
28
+ /** The tunnel name the wizard mints and looks for. */
29
+ export const TUNNEL_NAME = 'dsh-zen-remote'
30
+
31
+ export interface TunnelOrchestratorOptions {
32
+ dshHome: string
33
+ /** The stored Cloudflare token (token-store); absent → `no-token`. */
34
+ readToken: () => string | undefined
35
+ /** The gateway's ACTUAL port (the ingress service target); may probe
36
+ * asynchronously (the wiring re-reads /lan-gate/status per setup). */
37
+ gatewayPort: () => number | Promise<number>
38
+ /** The client factory; injectable so the tests drive the orchestration
39
+ * against a scripted CloudflareApi without any HTTP. Defaults to
40
+ * createCloudflareApi over the injected fetch. */
41
+ apiFactory?: (token: string) => CloudflareApi
42
+ cloudflared: {
43
+ start: (input: { tunnelToken: string }) => Promise<{ ok: true } | { ok: false; error: string }>
44
+ /** 模式切换:starting the tunnel stops the managed Caddy first. */
45
+ stopCaddy: () => void
46
+ }
47
+ fetchImpl?: typeof fetch
48
+ }
49
+
50
+ export interface TunnelSetupInput {
51
+ /** The public domain (bare hostname; may be a subdomain of the zone). */
52
+ domain: string
53
+ /** Adopt this existing tunnel instead of creating one (the conflict path). */
54
+ tunnelId?: string
55
+ }
56
+
57
+ export type TunnelSetupResult =
58
+ | { ok: true; tunnelId: string; publicUrl: string; tokenFile: string }
59
+ | {
60
+ ok: false
61
+ reason: 'no-token' | 'bad-domain' | 'zone-not-found' | 'tunnel-conflict' | 'dns-conflict' | 'auth' | 'network' | 'api' | 'spawn-failed'
62
+ detail?: string
63
+ /** The same-name tunnels, for `tunnel-conflict`. */
64
+ existing?: CloudflareTunnelSummary[]
65
+ /** The manual-install page, for `spawn-failed`. */
66
+ installUrl?: string
67
+ }
68
+
69
+ /** CloudflareError.code → the orchestration's reason vocabulary. */
70
+ function mapCloudflareError(error: unknown): TunnelSetupResult & { ok: false } {
71
+ if (error !== null && typeof error === 'object' && 'code' in error) {
72
+ const code = (error as { code?: unknown }).code
73
+ const detail = (error as { message?: unknown }).message
74
+ if (code === 'cf-auth-failed') return { ok: false, reason: 'auth', detail: String(detail ?? '') }
75
+ if (code === 'cf-network') return { ok: false, reason: 'network', detail: String(detail ?? '') }
76
+ if (code === 'cf-zone-not-found') return { ok: false, reason: 'zone-not-found' }
77
+ }
78
+ return { ok: false, reason: 'api', detail: String((error as Error)?.message ?? error) }
79
+ }
80
+
81
+ /**
82
+ * Drive the whole tunnel setup. Throws nothing — every outcome is a value.
83
+ */
84
+ export async function setupTunnel(options: TunnelOrchestratorOptions, input: TunnelSetupInput): Promise<TunnelSetupResult> {
85
+ const token = options.readToken()
86
+ if (token === undefined || token.trim() === '') return { ok: false, reason: 'no-token' }
87
+ const host = hostOf(input.domain)
88
+ if (host === undefined) return { ok: false, reason: 'bad-domain' }
89
+
90
+ const api: CloudflareApi = options.apiFactory !== undefined
91
+ ? options.apiFactory(token)
92
+ : createCloudflareApi({ token, fetchImpl: options.fetchImpl })
93
+
94
+ // Zone + account: the ancestor-chain check answers both in one verdict.
95
+ let zoneId: string
96
+ let accountId: string
97
+ try {
98
+ const check = await api.checkDomainOnCloudflare(host)
99
+ if (!check.onCloudflare || check.zoneId === null || check.accountId === null) {
100
+ return { ok: false, reason: 'zone-not-found' }
101
+ }
102
+ zoneId = check.zoneId
103
+ accountId = check.accountId
104
+ } catch (error) {
105
+ return mapCloudflareError(error)
106
+ }
107
+
108
+ // Tunnel: adopt the user's choice, or create a fresh one — but a fresh
109
+ // create refuses when same-name tunnels already exist (the UI offers the
110
+ // list; the re-run carries `tunnelId`).
111
+ let tunnelId: string
112
+ let tunnelToken: string
113
+ try {
114
+ if (input.tunnelId !== undefined && input.tunnelId !== '') {
115
+ tunnelId = input.tunnelId
116
+ tunnelToken = await api.getTunnelToken(accountId, tunnelId)
117
+ } else {
118
+ const existing = await api.listTunnels(accountId)
119
+ const sameName = existing.filter((tunnel) => tunnel.name === TUNNEL_NAME)
120
+ if (sameName.length > 0) return { ok: false, reason: 'tunnel-conflict', existing: sameName }
121
+ const created = await api.createTunnel(accountId, TUNNEL_NAME)
122
+ tunnelId = created.id
123
+ tunnelToken = created.token
124
+ }
125
+ } catch (error) {
126
+ return mapCloudflareError(error)
127
+ }
128
+
129
+ const gatewayPort = await options.gatewayPort()
130
+
131
+ // The ingress mapping: the public hostname serves the local gateway.
132
+ try {
133
+ await api.configureIngress(accountId, tunnelId, host, `http://127.0.0.1:${gatewayPort}`)
134
+ } catch (error) {
135
+ return mapCloudflareError(error)
136
+ }
137
+
138
+ // DNS idempotence next (GW-fix-4/M3): an existing CNAME for this name
139
+ // pointing at the SAME tunnel is a no-op; pointing elsewhere is a real
140
+ // conflict we refuse instead of silently overwriting.
141
+ try {
142
+ const existing = await api.listDnsRecords(zoneId, host)
143
+ const prior = existing.find((record) => record.name === host)
144
+ if (prior !== undefined) {
145
+ const expected = `${tunnelId}.cfargotunnel.com`
146
+ if (prior.content !== expected) {
147
+ return { ok: false, reason: 'dns-conflict', detail: prior.content }
148
+ }
149
+ } else {
150
+ await api.createDnsRecord(zoneId, host, `${tunnelId}.cfargotunnel.com`)
151
+ }
152
+ } catch (error) {
153
+ return mapCloudflareError(error)
154
+ }
155
+
156
+ // L1: the Cloudflare half is DONE — only now stop the managed Caddy and
157
+ // start the tunnel, so a failed API call leaves the old path untouched.
158
+ options.cloudflared.stopCaddy()
159
+
160
+ // Connector secret → 0600 file → child.
161
+ let tokenFile: string
162
+ try {
163
+ tokenFile = writeTunnelTokenFile(options.dshHome, tunnelToken)
164
+ } catch (error) {
165
+ return { ok: false, reason: 'spawn-failed', detail: String((error as Error)?.message ?? error) }
166
+ }
167
+ const started = await options.cloudflared.start({ tunnelToken })
168
+ if (!started.ok) {
169
+ return { ok: false, reason: 'spawn-failed', detail: started.error }
170
+ }
171
+ return { ok: true, tunnelId, publicUrl: `https://${host}`, tokenFile }
172
+ }
173
+
174
+ /**
175
+ * The check-domain probe the migration card's 重新检查 drives: does any
176
+ * ancestor of the domain name a zone on the token's accounts?
177
+ */
178
+ export type TunnelCheckResult =
179
+ | { ok: true; onCloudflare: boolean; zoneId: string | null; accountId: string | null }
180
+ | { ok: false; reason: 'no-token' | 'bad-domain' | 'auth' | 'network' | 'api'; detail?: string }
181
+
182
+ export async function checkDomainOnCloudflare(options: Pick<TunnelOrchestratorOptions, 'readToken' | 'fetchImpl' | 'apiFactory'>, domain: string): Promise<TunnelCheckResult> {
183
+ const token = options.readToken()
184
+ if (token === undefined || token.trim() === '') return { ok: false, reason: 'no-token' }
185
+ const host = hostOf(domain)
186
+ if (host === undefined) return { ok: false, reason: 'bad-domain' }
187
+ const api: CloudflareApi = options.apiFactory !== undefined
188
+ ? options.apiFactory(token)
189
+ : createCloudflareApi({ token, fetchImpl: options.fetchImpl })
190
+ try {
191
+ const check = await api.checkDomainOnCloudflare(host)
192
+ return { ok: true, onCloudflare: check.onCloudflare, zoneId: check.zoneId, accountId: check.accountId }
193
+ } catch (error) {
194
+ const mapped = mapCloudflareError(error)
195
+ return { ok: false, reason: mapped.reason === 'auth' ? 'auth' : mapped.reason === 'network' ? 'network' : 'api', detail: mapped.detail }
196
+ }
197
+ }
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Final-verification orchestration (GW-9): the wizard's last mile, where
3
+ * 「完成」 means a real phone really paired through the real public URL —
4
+ * never a green light from internal checks alone (设计: 不做已联通的假绿灯).
5
+ *
6
+ * The flow, spread over two entry points the UI drives:
7
+ *
8
+ * start({ publicUrl }): runtime ready? → walk the public URL
9
+ * (GW-8's probe; BOTH the pairing page and the admin-403 shape must be
10
+ * right) → snapshot the gateway's device ids (the baseline) → mint a WEB
11
+ * pairing code → remember the session IN MEMORY and answer with the
12
+ * code, the URL and the expiry. The code never touches the row config,
13
+ * and minting it restarts nothing — trustedProxies changes are what
14
+ * restart the gateway, and any such change invalidates the session
15
+ * instead (the config fingerprint below).
16
+ *
17
+ * check(): re-read the gateway status. A device id the baseline did not
18
+ * have is a freshly paired phone; the gateway's lastWebClaim record must
19
+ * name THAT device with secure === true (the proxy passed
20
+ * X-Forwarded-Proto: https through, so the issued cookie carries the
21
+ * Secure flag). All green → pass. The claim recorded an insecure cookie →
22
+ * cookie-insecure. Past the code's expiry → code-expired. Anything else →
23
+ * pending, keep polling.
24
+ *
25
+ * The session binds to a CONFIG FINGERPRINT (ingressMode, domain,
26
+ * httpsPort, publicUrl, the gateway's ACTUAL port, host, trustedProxies):
27
+ * any of those moving between start and check answers stale and discards
28
+ * the session, because the verification proved a URL that no longer exists.
29
+ * The session is deliberately memory-only — a row reload (which a
30
+ * trustedProxies change causes) builds a fresh manager and the session is
31
+ * gone, exactly the same verdict the fingerprint would have produced.
32
+ */
33
+
34
+ import type { ZenRemoteConfig } from '../config.js'
35
+ import { normalizeServerUrl } from '../client-pairing.js'
36
+ import { probeHostPath, type HostPathOutcome } from './probe.js'
37
+
38
+ export interface VerificationManagerOptions {
39
+ /** Loopback base of the gateway child, `http://127.0.0.1:<port>`. */
40
+ gatewayBase: string
41
+ /** Fresh resolved config values per call (volatile discipline). */
42
+ getValues: () => ZenRemoteConfig
43
+ /** The hostPath probe; injectable for tests (defaults to GW-8's real one). */
44
+ probe?: (publicUrl: string) => Promise<HostPathOutcome>
45
+ /** Whether the ingress processes are alive (Caddy / cloudflared). The
46
+ * wiring supplies this once the process managers are mounted; absent
47
+ * means "checked elsewhere" and only the gateway is probed. */
48
+ runtimeReady?: () => boolean | Promise<boolean>
49
+ fetchImpl?: typeof fetch
50
+ /** Per-gateway-call ceiling. Default 5000 ms. */
51
+ timeoutMs?: number
52
+ /** Clock (tests drive expiry). */
53
+ now?: () => number
54
+ }
55
+
56
+ export type VerificationStartResult =
57
+ | { ok: true; pairingCode: string; publicUrl: string; expiresAt: number }
58
+ | {
59
+ ok: false
60
+ reason: 'no-public-url' | 'bad-public-url' | 'runtime-not-ready' | 'gateway-unreachable' | 'probe-failed' | 'pair-refused'
61
+ /** The probe's classifications for `probe-failed`; the gateway's status
62
+ * code for `pair-refused`; the normalize failure reason for
63
+ * `bad-public-url`. */
64
+ detail?: unknown
65
+ }
66
+
67
+ export type VerificationCheckResult =
68
+ | { result: 'not-started' }
69
+ | { result: 'stale' }
70
+ | { result: 'code-expired' }
71
+ | { result: 'pending' }
72
+ | { result: 'cookie-insecure'; deviceId: string }
73
+ | { result: 'pass'; deviceId: string; at: number }
74
+
75
+ /** What the wizard reads off one gateway status answer. */
76
+ interface GatewayStatusView {
77
+ deviceIds: string[]
78
+ port: number | null
79
+ lastWebClaim: { id: string; secure: boolean; at: number } | null
80
+ }
81
+
82
+ /**
83
+ * The config fingerprint a session binds to: any drift between start and
84
+ * check means the verified URL is no longer the deployed URL.
85
+ */
86
+ export function verificationFingerprint(values: ZenRemoteConfig, gatewayPort: number | null): string {
87
+ return JSON.stringify({
88
+ ingressMode: values.ingressMode,
89
+ ingressDomain: values.ingressDomain,
90
+ ingressHttpsPort: values.ingressHttpsPort,
91
+ ingressPublicUrl: values.ingressPublicUrl,
92
+ gatewayPort,
93
+ host: values.host,
94
+ trustedProxies: values.trustedProxies,
95
+ })
96
+ }
97
+
98
+ interface VerificationSession {
99
+ publicUrl: string
100
+ code: string
101
+ expiresAt: number
102
+ /** Device ids the gateway already had when the code was minted. */
103
+ knownDeviceIds: ReadonlySet<string>
104
+ fingerprint: string
105
+ }
106
+
107
+ export interface VerificationManager {
108
+ start(input: { publicUrl: unknown; probeOnly?: boolean }): Promise<VerificationStartResult>
109
+ check(): Promise<VerificationCheckResult>
110
+ /** Whether a verification session is open (the route surfaces it for the
111
+ * UI's 「待外部验证」 state). */
112
+ hasSession(): boolean
113
+ }
114
+
115
+ export function createVerificationManager(options: VerificationManagerOptions): VerificationManager {
116
+ const fetchImpl = options.fetchImpl ?? fetch
117
+ const timeoutMs = options.timeoutMs ?? 5000
118
+ const now = options.now ?? Date.now
119
+ let session: VerificationSession | null = null
120
+
121
+ /** One loopback gateway call AS the local machine. Answer status+body or
122
+ * null on any failure — every caller degrades, none throws. */
123
+ const callGateway = async (method: 'GET' | 'POST', path: string, payload?: unknown): Promise<{ status: number; body: unknown } | null> => {
124
+ try {
125
+ const response = await fetchImpl(`${options.gatewayBase}${path}`, {
126
+ method,
127
+ headers: payload === undefined ? undefined : { 'Content-Type': 'application/json' },
128
+ ...(payload === undefined ? {} : { body: JSON.stringify(payload) }),
129
+ signal: AbortSignal.timeout(timeoutMs),
130
+ })
131
+ const body: unknown = await response.json()
132
+ return { status: response.status, body }
133
+ } catch {
134
+ return null
135
+ }
136
+ }
137
+
138
+ const readStatus = async (): Promise<GatewayStatusView | null> => {
139
+ const reply = await callGateway('GET', '/lan-gate/status')
140
+ if (reply === null || reply.status < 200 || reply.status >= 300) return null
141
+ const body = reply.body as { devices?: unknown; port?: unknown; lastWebClaim?: unknown }
142
+ if (body === null || typeof body !== 'object') return null
143
+ const deviceIds: string[] = Array.isArray(body.devices)
144
+ ? body.devices.map((device) => (device !== null && typeof device === 'object' ? (device as { id?: unknown }).id : undefined))
145
+ .filter((id): id is string => typeof id === 'string')
146
+ : []
147
+ const port = typeof body.port === 'number' ? body.port : null
148
+ const claim = body.lastWebClaim
149
+ const lastWebClaim = claim !== null && typeof claim === 'object'
150
+ ? {
151
+ id: typeof (claim as { id?: unknown }).id === 'string' ? (claim as { id: string }).id : '',
152
+ secure: (claim as { secure?: unknown }).secure === true,
153
+ at: typeof (claim as { at?: unknown }).at === 'number' ? (claim as { at: number }).at : 0,
154
+ }
155
+ : null
156
+ return { deviceIds, port, lastWebClaim }
157
+ }
158
+
159
+ return {
160
+ async start({ publicUrl, probeOnly }) {
161
+ if (typeof publicUrl !== 'string' || publicUrl.trim() === '') return { ok: false, reason: 'no-public-url' }
162
+ // Same admission as the probe: https anywhere, plain http only into
163
+ // the private-network allowances. The URL the phone will open must
164
+ // be one this machine is willing to verify at all.
165
+ const normalized = normalizeServerUrl(publicUrl)
166
+ if (!normalized.ok) return { ok: false, reason: 'bad-public-url', detail: normalized.reason }
167
+
168
+ if ((await options.runtimeReady?.()) === false) return { ok: false, reason: 'runtime-not-ready' }
169
+
170
+ const statusBefore = await readStatus()
171
+ if (statusBefore === null) return { ok: false, reason: 'gateway-unreachable' }
172
+
173
+ const outcome = await (options.probe ?? probeHostPath)(normalized.url)
174
+ if (outcome.kind !== 'reached' || outcome.root !== 'gateway-pairing-page' || outcome.admin !== 'gateway-admin-403') {
175
+ return { ok: false, reason: 'probe-failed', detail: outcome }
176
+ }
177
+
178
+ if (probeOnly === true) {
179
+ return { ok: true, pairingCode: '', publicUrl: normalized.url, expiresAt: 0 }
180
+ }
181
+
182
+ const pair = await callGateway('POST', '/lan-gate/pair', { role: 'web' })
183
+ const pairBody = pair?.body as { ok?: unknown; code?: unknown; expiresAt?: unknown } | undefined
184
+ if (pair === null || pair.status < 200 || pair.status >= 300 || pairBody?.ok !== true || typeof pairBody.code !== 'string' || typeof pairBody.expiresAt !== 'number') {
185
+ return { ok: false, reason: 'pair-refused', detail: pair === null ? null : pair.status }
186
+ }
187
+
188
+ session = {
189
+ publicUrl: normalized.url,
190
+ code: pairBody.code,
191
+ expiresAt: pairBody.expiresAt,
192
+ knownDeviceIds: new Set(statusBefore.deviceIds),
193
+ fingerprint: verificationFingerprint(options.getValues(), statusBefore.port),
194
+ }
195
+ return { ok: true, pairingCode: session.code, publicUrl: session.publicUrl, expiresAt: session.expiresAt }
196
+ },
197
+
198
+ async check() {
199
+ if (session === null) return { result: 'not-started' }
200
+ const status = await readStatus()
201
+ if (status === null) return { result: 'pending' }
202
+ // The verified URL must still BE the deployed URL: a config or
203
+ // gateway-port drift discards the session outright.
204
+ if (verificationFingerprint(options.getValues(), status.port) !== session.fingerprint) {
205
+ session = null
206
+ return { result: 'stale' }
207
+ }
208
+ // New devices first, expiry second: the claim itself proves the code
209
+ // was still valid when it was redeemed, so a poll that lands moments
210
+ // past the expiry must not swallow a pass.
211
+ const newcomers = status.deviceIds.filter((id) => !session!.knownDeviceIds.has(id))
212
+ if (newcomers.length > 0) {
213
+ const deviceId = newcomers[newcomers.length - 1]
214
+ const claim = status.lastWebClaim
215
+ if (claim !== null && claim.id === deviceId) {
216
+ const isHttps = session.publicUrl.startsWith('https://')
217
+ session = null
218
+ // Plain http:// LAN/Tailscale pairing does not sit behind a TLS
219
+ // reverse proxy and intentionally issues a non-Secure cookie.
220
+ if (isHttps && !claim.secure) return { result: 'cookie-insecure', deviceId }
221
+ return { result: 'pass', deviceId, at: claim.at }
222
+ }
223
+ // A claim record that has not surfaced (or names a different,
224
+ // later claim): keep polling rather than bless an unverified cookie.
225
+ return { result: 'pending' }
226
+ }
227
+ if (now() >= session.expiresAt) {
228
+ session = null
229
+ return { result: 'code-expired' }
230
+ }
231
+ return { result: 'pending' }
232
+ },
233
+
234
+ hasSession: () => session !== null,
235
+ }
236
+ }