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.
- package/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/docs/interface.md +1 -1
- package/docs/remote-access.md +20 -0
- package/lan-gate.mjs +11 -1
- package/lib/adapters/dsh-better-sidebar/index.js +6 -2
- package/lib/adapters/dsh-better-sidebar/index.js.map +1 -1
- package/lib/adapters/dsh-better-sidebar/methods.js +3 -0
- package/lib/adapters/dsh-better-sidebar/methods.js.map +1 -1
- package/lib/admin-routes.js +33 -2
- package/lib/admin-routes.js.map +1 -1
- package/lib/client.js +3289 -6
- package/lib/config.js +70 -0
- package/lib/config.js.map +1 -1
- package/lib/index.js +232 -2
- package/lib/index.js.map +1 -1
- package/lib/intercept.js +43 -3
- package/lib/intercept.js.map +1 -1
- package/lib/lan-gate-server.cjs +29 -3
- package/lib/merge-streams.js +42 -12
- package/lib/merge-streams.js.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts +1 -1
- package/lib/types/adapters/dsh-better-sidebar/index.d.ts.map +1 -1
- package/lib/types/adapters/dsh-better-sidebar/methods.d.ts.map +1 -1
- package/lib/types/admin-routes.d.ts +13 -0
- package/lib/types/admin-routes.d.ts.map +1 -1
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +204 -1
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/wizard/GatewayWizard.d.ts +29 -0
- package/lib/types/client/wizard/GatewayWizard.d.ts.map +1 -0
- package/lib/types/client/wizard/WizardCard.d.ts +30 -0
- package/lib/types/client/wizard/WizardCard.d.ts.map +1 -0
- package/lib/types/client/wizard/cards/MigrateCard.d.ts +15 -0
- package/lib/types/client/wizard/cards/MigrateCard.d.ts.map +1 -0
- package/lib/types/client/wizard/cards/PortForwardCard.d.ts +15 -0
- package/lib/types/client/wizard/cards/PortForwardCard.d.ts.map +1 -0
- package/lib/types/client/wizard/cards/ProxyCard.d.ts +19 -0
- package/lib/types/client/wizard/cards/ProxyCard.d.ts.map +1 -0
- package/lib/types/client/wizard/cards/TokenCard.d.ts +20 -0
- package/lib/types/client/wizard/cards/TokenCard.d.ts.map +1 -0
- package/lib/types/client/wizard/register-wizard.d.ts +51 -0
- package/lib/types/client/wizard/register-wizard.d.ts.map +1 -0
- package/lib/types/client/wizard/wizard-css.d.ts +10 -0
- package/lib/types/client/wizard/wizard-css.d.ts.map +1 -0
- package/lib/types/client-data/qr.d.ts +20 -0
- package/lib/types/client-data/qr.d.ts.map +1 -0
- package/lib/types/client-data/settings-form.d.ts +7 -2
- package/lib/types/client-data/settings-form.d.ts.map +1 -1
- package/lib/types/client-data/wizard.d.ts +247 -0
- package/lib/types/client-data/wizard.d.ts.map +1 -0
- package/lib/types/config.d.ts +27 -0
- package/lib/types/config.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/intercept.d.ts.map +1 -1
- package/lib/types/merge-streams.d.ts.map +1 -1
- package/lib/types/wizard/binary-io.d.ts +111 -0
- package/lib/types/wizard/binary-io.d.ts.map +1 -0
- package/lib/types/wizard/binary.d.ts +118 -0
- package/lib/types/wizard/binary.d.ts.map +1 -0
- package/lib/types/wizard/caddy-process.d.ts +101 -0
- package/lib/types/wizard/caddy-process.d.ts.map +1 -0
- package/lib/types/wizard/caddy.d.ts +53 -0
- package/lib/types/wizard/caddy.d.ts.map +1 -0
- package/lib/types/wizard/cloudflare.d.ts +112 -0
- package/lib/types/wizard/cloudflare.d.ts.map +1 -0
- package/lib/types/wizard/cloudflared.d.ts +71 -0
- package/lib/types/wizard/cloudflared.d.ts.map +1 -0
- package/lib/types/wizard/probe.d.ts +66 -0
- package/lib/types/wizard/probe.d.ts.map +1 -0
- package/lib/types/wizard/proxy-snippets.d.ts +60 -0
- package/lib/types/wizard/proxy-snippets.d.ts.map +1 -0
- package/lib/types/wizard/routes.d.ts +152 -0
- package/lib/types/wizard/routes.d.ts.map +1 -0
- package/lib/types/wizard/source-detect.d.ts +53 -0
- package/lib/types/wizard/source-detect.d.ts.map +1 -0
- package/lib/types/wizard/status.d.ts +115 -0
- package/lib/types/wizard/status.d.ts.map +1 -0
- package/lib/types/wizard/token-store.d.ts +27 -0
- package/lib/types/wizard/token-store.d.ts.map +1 -0
- package/lib/types/wizard/tunnel-orchestrator.d.ts +90 -0
- package/lib/types/wizard/tunnel-orchestrator.d.ts.map +1 -0
- package/lib/types/wizard/verify.d.ts +97 -0
- package/lib/types/wizard/verify.d.ts.map +1 -0
- package/lib/wizard/binary-io.js +386 -0
- package/lib/wizard/binary-io.js.map +1 -0
- package/lib/wizard/binary.js +138 -0
- package/lib/wizard/binary.js.map +1 -0
- package/lib/wizard/caddy-process.js +162 -0
- package/lib/wizard/caddy-process.js.map +1 -0
- package/lib/wizard/caddy.js +80 -0
- package/lib/wizard/caddy.js.map +1 -0
- package/lib/wizard/cloudflare.js +334 -0
- package/lib/wizard/cloudflare.js.map +1 -0
- package/lib/wizard/cloudflared.js +158 -0
- package/lib/wizard/cloudflared.js.map +1 -0
- package/lib/wizard/probe.js +118 -0
- package/lib/wizard/probe.js.map +1 -0
- package/lib/wizard/proxy-snippets.js +112 -0
- package/lib/wizard/proxy-snippets.js.map +1 -0
- package/lib/wizard/routes.js +462 -0
- package/lib/wizard/routes.js.map +1 -0
- package/lib/wizard/source-detect.js +77 -0
- package/lib/wizard/source-detect.js.map +1 -0
- package/lib/wizard/status.js +94 -0
- package/lib/wizard/status.js.map +1 -0
- package/lib/wizard/token-store.js +71 -0
- package/lib/wizard/token-store.js.map +1 -0
- package/lib/wizard/tunnel-orchestrator.js +155 -0
- package/lib/wizard/tunnel-orchestrator.js.map +1 -0
- package/lib/wizard/verify.js +171 -0
- package/lib/wizard/verify.js.map +1 -0
- package/package.json +37 -37
- package/scripts/check-settings-form.mjs +5 -4
- package/scripts/check-wizard-ui.mjs +690 -0
- package/src/adapters/dsh-better-sidebar/index.ts +6 -2
- package/src/adapters/dsh-better-sidebar/methods.ts +3 -0
- package/src/admin-routes.ts +44 -2
- package/src/client/index.tsx +9 -0
- package/src/client/locales.ts +410 -2
- package/src/client/wizard/GatewayWizard.tsx +2220 -0
- package/src/client/wizard/WizardCard.tsx +81 -0
- package/src/client/wizard/cards/MigrateCard.tsx +42 -0
- package/src/client/wizard/cards/PortForwardCard.tsx +37 -0
- package/src/client/wizard/cards/ProxyCard.tsx +56 -0
- package/src/client/wizard/cards/TokenCard.tsx +81 -0
- package/src/client/wizard/register-wizard.ts +99 -0
- package/src/client/wizard/wizard-css.ts +948 -0
- package/src/client-data/qr.ts +284 -0
- package/src/client-data/settings-form.ts +12 -3
- package/src/client-data/wizard.ts +432 -0
- package/src/config.ts +87 -0
- package/src/index.ts +227 -2
- package/src/intercept.ts +43 -3
- package/src/merge-streams.ts +41 -12
- package/src/wizard/binary-io.ts +449 -0
- package/src/wizard/binary.ts +177 -0
- package/src/wizard/caddy-process.ts +228 -0
- package/src/wizard/caddy.ts +97 -0
- package/src/wizard/cloudflare.ts +413 -0
- package/src/wizard/cloudflared.ts +196 -0
- package/src/wizard/probe.ts +161 -0
- package/src/wizard/proxy-snippets.ts +128 -0
- package/src/wizard/routes.ts +537 -0
- package/src/wizard/source-detect.ts +87 -0
- package/src/wizard/status.ts +180 -0
- package/src/wizard/token-store.ts +73 -0
- package/src/wizard/tunnel-orchestrator.ts +197 -0
- 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
|
+
}
|