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,413 @@
1
+ /**
2
+ * Cloudflare API v4 client (GW-7) — the whole tunnel path (路 C) talks to
3
+ * this module: account lookup, zone lookup, tunnel create/list, ingress
4
+ * configuration, the CNAME that points the public hostname at the tunnel,
5
+ * and the composed "is this domain on Cloudflare already" check.
6
+ *
7
+ * Transport rules the ticket pins down:
8
+ *
9
+ * - Plain `fetch`, no third-party dependency; the token rides the
10
+ * `Authorization: Bearer` header and is NEVER logged, echoed into an
11
+ * error, or written into any returned structure.
12
+ * - Every failure is a typed {@link CloudflareError} whose `code` the UI
13
+ * classifies on (`cf-auth-failed`, `cf-zone-not-found`,
14
+ * `cf-tunnel-conflict`, `cf-dns-conflict`, `cf-network`, and the
15
+ * catch-all `cf-api`). Raw response bodies never pass through — the
16
+ * message carries only the API's short error summary, truncated,
17
+ * which is what the design's 「兜底显示原始错误码」 wants.
18
+ * - The client is stateless: a factory binds the token (plus injectable
19
+ * `fetchImpl` / `baseUrl` for the mock-server tests); every method
20
+ * takes the ids it needs. The module-level `cf*` wrappers are one-call
21
+ * conveniences over the factory.
22
+ *
23
+ * Conflict classification matches the well-known numeric codes where they
24
+ * exist and falls back to the API's error text (`already exists`
25
+ * wordings), so an upstream renumber degrades to the generic envelope
26
+ * instead of a wrong verdict.
27
+ */
28
+
29
+ import { hostOf } from '../config.js'
30
+
31
+ export const CLOUDFLARE_API_BASE = 'https://api.cloudflare.com/client/v4'
32
+
33
+ export type CloudflareErrorCode =
34
+ | 'cf-auth-failed'
35
+ | 'cf-zone-not-found'
36
+ | 'cf-tunnel-conflict'
37
+ | 'cf-dns-conflict'
38
+ | 'cf-network'
39
+ | 'cf-api'
40
+
41
+ /**
42
+ * One refused API call. Fields are assigned in the constructor body (the
43
+ * check scripts' strip-only type stripping rejects parameter properties).
44
+ */
45
+ export class CloudflareError extends Error {
46
+ readonly code: CloudflareErrorCode
47
+ readonly status: number | null
48
+ /** The API's own numeric error code(s), when the envelope carried any. */
49
+ readonly apiCodes: readonly number[]
50
+
51
+ constructor(code: CloudflareErrorCode, message: string, status: number | null = null, apiCodes: readonly number[] = [], options?: { cause?: unknown }) {
52
+ super(message, options)
53
+ this.name = 'CloudflareError'
54
+ this.code = code
55
+ this.status = status
56
+ this.apiCodes = apiCodes
57
+ }
58
+ }
59
+
60
+ /** Tunnel name conflict: Cloudflare rejects duplicate names (documented
61
+ * code 10405; deployments also word it in the message text). The text
62
+ * fallback names its SUBJECT — "already exists" alone matches the DNS
63
+ * wording just as well, so the two classifiers must stay scoped. */
64
+ function isTunnelConflict(apiCodes: readonly number[], text: string): boolean {
65
+ return apiCodes.includes(10405) || (/tunnel/i.test(text) && /already exists/i.test(text))
66
+ }
67
+
68
+ /** DNS record conflict: the documented codes are 81053 / 81057. */
69
+ function isDnsConflict(apiCodes: readonly number[], text: string): boolean {
70
+ return apiCodes.includes(81053) || apiCodes.includes(81057) || (/record/i.test(text) && /already exists/i.test(text))
71
+ }
72
+
73
+ /** The API's error summary: message strings only, joined, truncated —
74
+ * never the raw body, never the request's token. */
75
+ function summarize(errors: readonly unknown[], fallback: string): { text: string; codes: number[] } {
76
+ const codes: number[] = []
77
+ const parts: string[] = []
78
+ for (const entry of errors) {
79
+ if (entry !== null && typeof entry === 'object') {
80
+ const record = entry as { code?: unknown; message?: unknown }
81
+ if (typeof record.code === 'number') codes.push(record.code)
82
+ if (typeof record.message === 'string' && record.message !== '') parts.push(record.message)
83
+ }
84
+ }
85
+ return { text: (parts.join('; ') || fallback).slice(0, 200), codes }
86
+ }
87
+
88
+ export interface CloudflareApiOptions {
89
+ token: string
90
+ fetchImpl?: typeof fetch
91
+ baseUrl?: string
92
+ }
93
+
94
+ export interface CloudflareTunnelSummary {
95
+ id: string
96
+ name: string
97
+ status: string
98
+ }
99
+
100
+ /** One DNS record summary (the id/content the tunnel setup compares). */
101
+ export interface CloudflareDnsRecord {
102
+ id: string
103
+ name: string
104
+ content: string
105
+ }
106
+
107
+ export interface CloudflareDomainCheck {
108
+ onCloudflare: boolean
109
+ zoneId: string | null
110
+ accountId: string | null
111
+ }
112
+
113
+ export interface CloudflareApi {
114
+ /** Every account id the token can see. */
115
+ listAccounts(): Promise<string[]>
116
+ /** The zone id whose name EXACTLY equals `domain`, within one account. */
117
+ getZone(accountId: string, domain: string): Promise<string>
118
+ /** One new named tunnel; `token` is the connector secret cloudflared runs with. */
119
+ createTunnel(accountId: string, name: string): Promise<{ id: string; token: string }>
120
+ /** The account's live tunnels (deleted ones filtered server-side). */
121
+ listTunnels(accountId: string): Promise<CloudflareTunnelSummary[]>
122
+ /** The connector token of an EXISTING tunnel (the re-use path of setup). */
123
+ getTunnelToken(accountId: string, tunnelId: string): Promise<string>
124
+ /** Map `domain` → `serviceUrl` in the tunnel's ingress, 404 fallback last. */
125
+ configureIngress(accountId: string, tunnelId: string, domain: string, serviceUrl: string): Promise<void>
126
+ /** One proxied CNAME `subdomain` → `target`; returns the record id. */
127
+ createDnsRecord(zoneId: string, subdomain: string, target: string): Promise<string>
128
+ /** The CNAME records named `name` in one zone (id/name/content only). */
129
+ listDnsRecords(zoneId: string, name: string): Promise<CloudflareDnsRecord[]>
130
+ /** Composed check: does any ancestor of `domain` name a zone on the
131
+ * token's accounts (zones are apexes — `dsh.example.com` lives in the
132
+ * `example.com` zone)? */
133
+ checkDomainOnCloudflare(domain: string): Promise<CloudflareDomainCheck>
134
+ }
135
+
136
+ /** Tunnel names we mint: short, safe in a URL path. */
137
+ const TUNNEL_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9-]{0,62}$/
138
+
139
+ /** A `service:` URL inside a tunnel ingress: http(s) to host[:port]. */
140
+ const SERVICE_URL_PATTERN = /^https?:\/\/[A-Za-z0-9.[\]:-]+$/
141
+
142
+ /** Path segments are ids we received from the API or names we minted —
143
+ * encoded anyway, so a hostile value cannot change the request shape. */
144
+ const seg = (value: string): string => encodeURIComponent(value)
145
+
146
+ /** Page-size + page ceiling for the two list endpoints: 5 pages of 50 is
147
+ * 250 accounts/tunnels — far past anything a wizard user has, and the hard
148
+ * stop means a broken pagination answer can never loop forever. */
149
+ const PAGE_SIZE = 50
150
+ const PAGE_LIMIT = 5
151
+
152
+ /**
153
+ * The ancestor chain of one hostname, itself first: `dsh.example.com` →
154
+ * `dsh.example.com`, `example.com`, `com`. Cloudflare zones are apex
155
+ * domains, so a wizard lookup for `dsh.example.com` must find the zone
156
+ * named `example.com` (GW-fix-2/H1) — exact-name matching walks this chain
157
+ * level by level.
158
+ */
159
+ export function ancestorHosts(domain: string): string[] {
160
+ const labels = domain.split('.')
161
+ const chain: string[] = []
162
+ for (let index = 0; index < labels.length; index++) chain.push(labels.slice(index).join('.'))
163
+ return chain
164
+ }
165
+
166
+ export function createCloudflareApi(options: CloudflareApiOptions): CloudflareApi {
167
+ const baseUrl = options.baseUrl ?? CLOUDFLARE_API_BASE
168
+ const fetchImpl = options.fetchImpl ?? fetch
169
+ const token = options.token
170
+
171
+ if (typeof token !== 'string' || token === '') {
172
+ throw new CloudflareError('cf-auth-failed', 'no Cloudflare API token')
173
+ }
174
+
175
+ /** One authenticated v4 call: Bearer header, envelope unwrapping, typed
176
+ * failures. The token never appears in any error path. Returns the
177
+ * result PLUS the pagination envelope (the list endpoints page). */
178
+ const call = async (method: 'GET' | 'POST' | 'PUT', path: string, body?: unknown): Promise<{ result: unknown; totalPages: number }> => {
179
+ let response: Response
180
+ try {
181
+ response = await fetchImpl(`${baseUrl}${path}`, {
182
+ method,
183
+ headers: {
184
+ Authorization: `Bearer ${token}`,
185
+ ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
186
+ },
187
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
188
+ signal: AbortSignal.timeout(15_000),
189
+ })
190
+ } catch (cause) {
191
+ throw new CloudflareError('cf-network', `the Cloudflare API call failed before an answer arrived (${path})`, null, [], { cause })
192
+ }
193
+ let envelope: { success?: unknown; errors?: unknown; result?: unknown; result_info?: { total_pages?: unknown } }
194
+ try {
195
+ envelope = await response.json()
196
+ } catch {
197
+ throw new CloudflareError('cf-network', `the Cloudflare API answered unparsable JSON (${path})`, response.status)
198
+ }
199
+ const errors = Array.isArray(envelope?.errors) ? envelope.errors : []
200
+ const summary = summarize(errors, `HTTP ${response.status}`)
201
+ if (response.status === 401 || response.status === 403) {
202
+ throw new CloudflareError('cf-auth-failed', summary.text, response.status, summary.codes)
203
+ }
204
+ if (envelope?.success !== true) {
205
+ let code: CloudflareErrorCode = 'cf-api'
206
+ if (isTunnelConflict(summary.codes, summary.text)) code = 'cf-tunnel-conflict'
207
+ else if (isDnsConflict(summary.codes, summary.text)) code = 'cf-dns-conflict'
208
+ throw new CloudflareError(code, summary.text, response.status, summary.codes)
209
+ }
210
+ // A missing or junk total_pages reads as a single page — old mocks and
211
+ // tolerant callers keep working, pagination simply never continues.
212
+ const totalPages = typeof envelope.result_info?.total_pages === 'number' && envelope.result_info.total_pages >= 1
213
+ ? envelope.result_info.total_pages
214
+ : 1
215
+ return { result: envelope.result, totalPages }
216
+ }
217
+
218
+ const requireString = (value: unknown, what: string): string => {
219
+ if (typeof value !== 'string' || value === '') throw new CloudflareError('cf-api', `the Cloudflare API answered without ${what}`)
220
+ return value
221
+ }
222
+
223
+ const requireId = (value: unknown, what: string): string => {
224
+ if (typeof value !== 'string' || value === '') throw new CloudflareError('cf-api', `${what} is required`)
225
+ return value
226
+ }
227
+
228
+ return {
229
+ async listAccounts() {
230
+ const accounts: string[] = []
231
+ for (let page = 1; page <= PAGE_LIMIT; page++) {
232
+ const { result, totalPages } = await call('GET', `/accounts?per_page=${PAGE_SIZE}&page=${page}`)
233
+ if (!Array.isArray(result)) throw new CloudflareError('cf-api', 'the accounts answer had no list')
234
+ accounts.push(...result.map((account) => requireString((account as { id?: unknown })?.id, 'an account id')))
235
+ if (page >= totalPages || result.length === 0) break
236
+ }
237
+ return accounts
238
+ },
239
+
240
+ async getZone(accountId, domain) {
241
+ const host = hostOf(domain)
242
+ if (host === undefined) throw new CloudflareError('cf-api', 'the domain is not a bare hostname')
243
+ const id = requireId(accountId, 'the account id')
244
+ const { result } = await call('GET', `/zones?name=${seg(host)}&account.id=${seg(id)}`)
245
+ // Exact-match on the zone NAME client-side: the query filter is
246
+ // advisory, the verdict is ours.
247
+ const zones = Array.isArray(result) ? (result as readonly { name?: unknown; id?: unknown }[]) : []
248
+ const zone = zones.find((candidate) => candidate?.name === host)
249
+ if (zone === undefined) throw new CloudflareError('cf-zone-not-found', `no Cloudflare zone named ${host}`)
250
+ return requireString(zone.id, 'a zone id')
251
+ },
252
+
253
+ async createTunnel(accountId, name) {
254
+ const id = requireId(accountId, 'the account id')
255
+ if (typeof name !== 'string' || !TUNNEL_NAME_PATTERN.test(name)) {
256
+ throw new CloudflareError('cf-api', 'the tunnel name is not a safe label')
257
+ }
258
+ // config_src cloudflare makes the create response carry the connector
259
+ // token directly — the second round-trip (GET .../token) is spared.
260
+ const { result } = await call('POST', `/accounts/${seg(id)}/cfd_tunnel`, { name, config_src: 'cloudflare' })
261
+ const record = result as { id?: unknown; token?: unknown }
262
+ return { id: requireString(record?.id, 'a tunnel id'), token: requireString(record?.token, 'a tunnel token') }
263
+ },
264
+
265
+ async getTunnelToken(accountId, tunnelId) {
266
+ const id = requireId(accountId, 'the account id')
267
+ const tunnel = requireId(tunnelId, 'the tunnel id')
268
+ const { result } = await call('GET', `/accounts/${seg(id)}/cfd_tunnel/${seg(tunnel)}/token`)
269
+ const tokenValue = typeof result === 'string'
270
+ ? result
271
+ : (result as { token?: unknown })?.token
272
+ return requireString(tokenValue, 'a tunnel token')
273
+ },
274
+
275
+ async listTunnels(accountId) {
276
+ const id = requireId(accountId, 'the account id')
277
+ const tunnels: CloudflareTunnelSummary[] = []
278
+ for (let page = 1; page <= PAGE_LIMIT; page++) {
279
+ const { result, totalPages } = await call('GET', `/accounts/${seg(id)}/cfd_tunnel?is_deleted=false&per_page=${PAGE_SIZE}&page=${page}`)
280
+ if (!Array.isArray(result)) throw new CloudflareError('cf-api', 'the tunnels answer had no list')
281
+ tunnels.push(...result.map((tunnel) => {
282
+ const record = tunnel as { id?: unknown; name?: unknown; status?: unknown }
283
+ return {
284
+ id: requireString(record?.id, 'a tunnel id'),
285
+ name: typeof record?.name === 'string' ? record.name : '',
286
+ status: typeof record?.status === 'string' ? record.status : '',
287
+ }
288
+ }))
289
+ if (page >= totalPages || result.length === 0) break
290
+ }
291
+ return tunnels
292
+ },
293
+
294
+ async configureIngress(accountId, tunnelId, domain, serviceUrl) {
295
+ const accountIdSafe = requireId(accountId, 'the account id')
296
+ const tunnelIdSafe = requireId(tunnelId, 'the tunnel id')
297
+ const host = hostOf(domain)
298
+ if (host === undefined) throw new CloudflareError('cf-api', 'the domain is not a bare hostname')
299
+ if (typeof serviceUrl !== 'string' || !SERVICE_URL_PATTERN.test(serviceUrl)) {
300
+ throw new CloudflareError('cf-api', 'the service URL is not a plain http(s) origin')
301
+ }
302
+ await call('PUT', `/accounts/${seg(accountIdSafe)}/cfd_tunnel/${seg(tunnelIdSafe)}/configurations`, {
303
+ config: {
304
+ ingress: [
305
+ { hostname: host, service: serviceUrl },
306
+ { service: 'http_status:404' },
307
+ ],
308
+ },
309
+ })
310
+ },
311
+
312
+ async listDnsRecords(zoneId, name) {
313
+ const zone = requireId(zoneId, 'the zone id')
314
+ const recordName = hostOf(name)
315
+ if (recordName === undefined) throw new CloudflareError('cf-api', 'the record name is not a bare hostname')
316
+ const { result } = await call('GET', `/zones/${seg(zone)}/dns_records?type=CNAME&name=${seg(recordName)}&per_page=${50}&page=${1}`)
317
+ if (!Array.isArray(result)) throw new CloudflareError('cf-api', 'the DNS records answer had no list')
318
+ return result.map((record) => {
319
+ const entry = record as { id?: unknown; name?: unknown; content?: unknown }
320
+ return {
321
+ id: requireString(entry?.id, 'a DNS record id'),
322
+ name: typeof entry?.name === 'string' ? entry.name : '',
323
+ content: typeof entry?.content === 'string' ? entry.content : '',
324
+ }
325
+ })
326
+ },
327
+
328
+ async createDnsRecord(zoneId, subdomain, target) {
329
+ const zone = requireId(zoneId, 'the zone id')
330
+ const name = hostOf(subdomain)
331
+ if (name === undefined) throw new CloudflareError('cf-api', 'the record name is not a bare hostname')
332
+ if (typeof target !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9-]{0,62}\.cfargotunnel\.com$/.test(target)) {
333
+ throw new CloudflareError('cf-api', 'the CNAME target is not a tunnel hostname')
334
+ }
335
+ const { result } = await call('POST', `/zones/${seg(zone)}/dns_records`, {
336
+ type: 'CNAME',
337
+ name,
338
+ content: target,
339
+ proxied: true,
340
+ ttl: 1,
341
+ })
342
+ return requireString((result as { id?: unknown })?.id, 'a DNS record id')
343
+ },
344
+
345
+ async checkDomainOnCloudflare(domain) {
346
+ const host = hostOf(domain)
347
+ if (host === undefined) throw new CloudflareError('cf-api', 'the domain is not a bare hostname')
348
+ // Zones are APEX domains: the user's `dsh.example.com` lives in the
349
+ // zone named `example.com`, so the exact-name lookup walks the
350
+ // ancestor chain (GW-fix-2/H1). First hit wins.
351
+ const chain = ancestorHosts(host)
352
+ for (const accountId of await this.listAccounts()) {
353
+ for (const candidate of chain) {
354
+ try {
355
+ const zoneId = await this.getZone(accountId, candidate)
356
+ return { onCloudflare: true, zoneId, accountId }
357
+ } catch (error) {
358
+ if (error instanceof CloudflareError && error.code === 'cf-zone-not-found') continue
359
+ throw error
360
+ }
361
+ }
362
+ }
363
+ return { onCloudflare: false, zoneId: null, accountId: null }
364
+ },
365
+ }
366
+ }
367
+
368
+ // --- module-level one-call wrappers (the ticket's cf* naming) ----------------
369
+
370
+ type CfCallOptions = Partial<Pick<CloudflareApiOptions, 'fetchImpl' | 'baseUrl'>>
371
+
372
+ export function cfListAccounts(token: string, opts?: CfCallOptions): Promise<string[]> {
373
+ return createCloudflareApi({ token, ...opts }).listAccounts()
374
+ }
375
+
376
+ export function cfGetZone(token: string, accountId: string, domain: string, opts?: CfCallOptions): Promise<string> {
377
+ return createCloudflareApi({ token, ...opts }).getZone(accountId, domain)
378
+ }
379
+
380
+ export function cfCreateTunnel(token: string, accountId: string, name: string, opts?: CfCallOptions): Promise<{ id: string; token: string }> {
381
+ return createCloudflareApi({ token, ...opts }).createTunnel(accountId, name)
382
+ }
383
+
384
+ export function cfListTunnels(token: string, accountId: string, opts?: CfCallOptions): Promise<CloudflareTunnelSummary[]> {
385
+ return createCloudflareApi({ token, ...opts }).listTunnels(accountId)
386
+ }
387
+
388
+ export function cfGetTunnelToken(token: string, accountId: string, tunnelId: string, opts?: CfCallOptions): Promise<string> {
389
+ return createCloudflareApi({ token, ...opts }).getTunnelToken(accountId, tunnelId)
390
+ }
391
+
392
+ export function cfConfigureIngress(
393
+ token: string,
394
+ accountId: string,
395
+ tunnelId: string,
396
+ domain: string,
397
+ serviceUrl: string,
398
+ opts?: CfCallOptions,
399
+ ): Promise<void> {
400
+ return createCloudflareApi({ token, ...opts }).configureIngress(accountId, tunnelId, domain, serviceUrl)
401
+ }
402
+
403
+ export function cfCreateDnsRecord(token: string, zoneId: string, subdomain: string, target: string, opts?: CfCallOptions): Promise<string> {
404
+ return createCloudflareApi({ token, ...opts }).createDnsRecord(zoneId, subdomain, target)
405
+ }
406
+
407
+ export function cfListDnsRecords(token: string, zoneId: string, name: string, opts?: CfCallOptions): Promise<CloudflareDnsRecord[]> {
408
+ return createCloudflareApi({ token, ...opts }).listDnsRecords(zoneId, name)
409
+ }
410
+
411
+ export function cfCheckDomainOnCloudflare(token: string, domain: string, opts?: CfCallOptions): Promise<CloudflareDomainCheck> {
412
+ return createCloudflareApi({ token, ...opts }).checkDomainOnCloudflare(domain)
413
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * cloudflared process lifecycle (GW-7, 路 C's last mile). The tunnel's
3
+ * connector runs in the foreground (`tunnel run`) as a child of the DSH
4
+ * host, managed through the same subprocess service as the gateway child
5
+ * and the managed Caddy.
6
+ *
7
+ * The connector secret NEVER touches argv (visible to every process on
8
+ * the machine via ps) and is never an environment variable either — it is
9
+ * written to `<DSH_HOME>/zen-remote-ingress/tunnel-token`, mode 0600,
10
+ * through a temp-file-then-rename so a crash mid-write can never leave a
11
+ * half token at the real path, and handed to cloudflared with
12
+ * `--token-file`.
13
+ *
14
+ * Idempotency and cleanup mirror caddy-process.ts: a live child refuses a
15
+ * second start (stop first to switch tunnels), stop is idempotent, and an
16
+ * exited child clears its own slot. The host wiring hands stop to
17
+ * ctx.effect so the row's disposal kills cloudflared too.
18
+ */
19
+
20
+ import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
21
+ import { join } from 'node:path'
22
+ import { ensureBinary, BinaryDownloadError, type DownloadProgress } from './binary-io.js'
23
+ import { ingressDir, type BinaryPlatform } from './binary.js'
24
+ import type { SubprocessServiceLike, SubprocessHandleLike } from './caddy-process.js'
25
+
26
+ /** Where the connector secret lives (design.md: 不进 Config 的字段). */
27
+ export function tunnelTokenPath(dshHome: string): string {
28
+ return join(ingressDir(dshHome), 'tunnel-token')
29
+ }
30
+
31
+ /** Whether a non-empty tunnel-token file exists (the projection's boolean
32
+ * only — GW-fix-4/M1: the manager's EXISTENCE says nothing about whether a
33
+ * token has been stored). */
34
+ export function tunnelTokenConfigured(dshHome: string): boolean {
35
+ try {
36
+ return statSync(tunnelTokenPath(dshHome)).isFile() && readFileSync(tunnelTokenPath(dshHome), 'utf8').trim() !== ''
37
+ } catch {
38
+ return false
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Persist the tunnel connector secret: 0600, atomic (write the `.part`
44
+ * sibling, then rename over the real path).
45
+ */
46
+ export function writeTunnelTokenFile(dshHome: string, token: string): string {
47
+ const finalPath = tunnelTokenPath(dshHome)
48
+ const partPath = `${finalPath}.part`
49
+ try {
50
+ mkdirSync(ingressDir(dshHome), { recursive: true })
51
+ writeFileSync(partPath, token, { mode: 0o600 })
52
+ try {
53
+ renameSync(partPath, finalPath)
54
+ } catch {
55
+ writeFileSync(finalPath, token, { mode: 0o600 })
56
+ rmSync(partPath, { force: true })
57
+ }
58
+ } catch (error) {
59
+ rmSync(partPath, { force: true })
60
+ throw error
61
+ }
62
+ return finalPath
63
+ }
64
+
65
+ export interface CloudflaredManagerOptions {
66
+ /** The host's subprocess service (ctx.subprocess). */
67
+ subprocess: SubprocessServiceLike
68
+ /** `<DSH_HOME>` — the binary and the token file live under it. */
69
+ dshHome: string
70
+ /** Injected transport for the one-time binary download (tests). */
71
+ fetchImpl?: typeof fetch
72
+ /** Forced platform mapping (tests). */
73
+ platform?: BinaryPlatform
74
+ /** Download progress relay (bytes, declared total or null). */
75
+ onProgress?: DownloadProgress
76
+ /** Stdio byte caps for the child, both streams. Default 64 KiB. */
77
+ maxLogBytes?: number
78
+ }
79
+
80
+ export type CloudflaredStartResult =
81
+ | { ok: true; binaryPath: string; tokenFile: string; alreadyRunning: false }
82
+ | {
83
+ ok: false
84
+ error: 'already-running' | 'missing-token' | 'download-failed' | 'spawn-failed'
85
+ detail?: string
86
+ installUrl?: string
87
+ }
88
+
89
+ export interface CloudflaredManager {
90
+ /** Idempotent front door: a live cloudflared refuses a second start. */
91
+ start(input: { tunnelToken: unknown }): Promise<CloudflaredStartResult>
92
+ /** Idempotent terminate; safe before any start. */
93
+ stop(): void
94
+ /** Whether a child is currently held. */
95
+ isRunning(): boolean
96
+ /** Whether any start has succeeded in this manager's lifetime. */
97
+ hasStarted(): boolean
98
+ }
99
+
100
+ export function createCloudflaredManager(options: CloudflaredManagerOptions): CloudflaredManager {
101
+ const { subprocess, dshHome } = options
102
+ const maxLogBytes = options.maxLogBytes ?? 64 * 1024
103
+ let handle: SubprocessHandleLike | null = null
104
+ let startedOnce = false
105
+
106
+ // GW-fix-2/L3: whitelist env — the host process carries the relay secret
107
+ // and DSH's own credentials, and cloudflared needs none of them (its
108
+ // connector secret comes through the 0600 token file). Include Windows
109
+ // system/profile variables so Go's TLS root-cert store and winsock
110
+ // initialize cleanly on Windows.
111
+ const childEnv = (): NodeJS.ProcessEnv => {
112
+ const env: NodeJS.ProcessEnv = {}
113
+ for (const key of [
114
+ 'PATH', 'HOME', 'TMPDIR', 'LANG', 'TZ',
115
+ 'SystemRoot', 'SYSTEMROOT', 'windir', 'WINDIR',
116
+ 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA', 'PROGRAMDATA', 'TEMP', 'TMP',
117
+ ]) {
118
+ if (process.env[key] !== undefined) env[key] = process.env[key]
119
+ }
120
+ return env
121
+ }
122
+
123
+ const spawnChild = (argv: string[]): SubprocessHandleLike =>
124
+ subprocess.spawn({
125
+ argv,
126
+ cwd: dshHome,
127
+ // GW-fix-2/L3: whitelist env — the token rides the 0600 file, so the
128
+ // child needs nothing from the host's environment beyond the basics.
129
+ env: childEnv(),
130
+ stdio: {
131
+ stdin: 'ignore',
132
+ stdout: { maxBytes: maxLogBytes },
133
+ stderr: { maxBytes: maxLogBytes },
134
+ },
135
+ graceMs: 3000,
136
+ })
137
+
138
+ const clearSlot = (child: SubprocessHandleLike): void => {
139
+ if (handle === child) handle = null
140
+ }
141
+
142
+ return {
143
+ async start(input) {
144
+ if (handle !== null) return { ok: false, error: 'already-running' }
145
+ const tunnelToken = typeof input.tunnelToken === 'string' && input.tunnelToken.trim() !== '' ? input.tunnelToken : undefined
146
+ if (tunnelToken === undefined) return { ok: false, error: 'missing-token' }
147
+
148
+ let binaryPath: string
149
+ try {
150
+ binaryPath = (await ensureBinary(dshHome, 'cloudflared', {
151
+ fetchImpl: options.fetchImpl,
152
+ platform: options.platform,
153
+ onProgress: options.onProgress,
154
+ })).path
155
+ } catch (error) {
156
+ if (error instanceof BinaryDownloadError) {
157
+ return { ok: false, error: 'download-failed', detail: error.message, installUrl: error.installUrl }
158
+ }
159
+ return { ok: false, error: 'download-failed', detail: String((error as Error)?.message ?? error) }
160
+ }
161
+
162
+ let tokenFile: string
163
+ try {
164
+ tokenFile = writeTunnelTokenFile(dshHome, tunnelToken)
165
+ } catch (error) {
166
+ return { ok: false, error: 'spawn-failed', detail: String((error as Error)?.message ?? error) }
167
+ }
168
+
169
+ try {
170
+ const child = spawnChild([binaryPath, 'tunnel', 'run', '--token-file', tokenFile])
171
+ handle = child
172
+ startedOnce = true
173
+ void child.done.then(() => clearSlot(child)).catch(() => clearSlot(child))
174
+ return { ok: true, binaryPath, tokenFile, alreadyRunning: false }
175
+ } catch (error) {
176
+ handle = null
177
+ return { ok: false, error: 'spawn-failed', detail: String((error as Error)?.message ?? error) }
178
+ }
179
+ },
180
+
181
+ stop() {
182
+ if (handle === null) return
183
+ const child = handle
184
+ handle = null
185
+ try {
186
+ void child.terminate()
187
+ } catch {
188
+ // A dead child's terminate is a no-op or a throw — either way done.
189
+ }
190
+ },
191
+
192
+ isRunning: () => handle !== null,
193
+ /** Whether any start has succeeded in this manager's lifetime. */
194
+ hasStarted: () => startedOnce,
195
+ }
196
+ }