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,432 @@
1
+ /**
2
+ * Wizard UI pure logic (GW-10): route constants, wire types, the step
3
+ * state machine, form validation and the derived lights — everything the
4
+ * React components need that a test can drive without a DOM. Mirrors of
5
+ * the host half's shapes (src/wizard/routes.ts / verify.ts /
6
+ * proxy-snippets.ts) live HERE because the browser bundle cannot import
7
+ * those modules (they transitively import node builtins through
8
+ * config.ts); the check script cross-checks the snippet templates against
9
+ * the host's so the two cannot drift.
10
+ *
11
+ * Validation mirrors src/config.ts's `hostOf` for the same reason — the
12
+ * row layer applies the same rule when the fields land there, so a client
13
+ * that accepts something the resolver would silently drop would only set
14
+ * the user up for a mystery.
15
+ */
16
+
17
+ /** Wizard route constants — mirror of src/wizard/routes.ts. */
18
+ export const WIZARD_STATUS_ROUTE = '/_dsh/zen-remote/admin/wizard/status'
19
+ export const WIZARD_VERIFY_ROUTE = '/_dsh/zen-remote/admin/wizard/verify'
20
+ export const WIZARD_CF_TOKEN_ROUTE = '/_dsh/zen-remote/admin/wizard/cf-token'
21
+ export const WIZARD_TUNNEL_TOKEN_ROUTE = '/_dsh/zen-remote/admin/wizard/tunnel-token'
22
+ export const WIZARD_CADDY_START_ROUTE = '/_dsh/zen-remote/admin/wizard/caddy/start'
23
+ export const WIZARD_CADDY_STOP_ROUTE = '/_dsh/zen-remote/admin/wizard/caddy/stop'
24
+ export const WIZARD_CADDY_STATUS_ROUTE = '/_dsh/zen-remote/admin/wizard/caddy/status'
25
+ export const WIZARD_TUNNEL_CHECK_ROUTE = '/_dsh/zen-remote/admin/wizard/tunnel/check-domain'
26
+ export const WIZARD_TUNNEL_SETUP_ROUTE = '/_dsh/zen-remote/admin/wizard/tunnel/setup'
27
+ export const WIZARD_TUNNEL_STOP_ROUTE = '/_dsh/zen-remote/admin/wizard/tunnel/stop'
28
+ export const WIZARD_TUNNEL_STATUS_ROUTE = '/_dsh/zen-remote/admin/wizard/tunnel/status'
29
+
30
+ /** Wire mirror of src/wizard/routes.ts's CaddyProcessState. */
31
+ export type CaddyState = 'running' | 'stopped' | 'never-started'
32
+
33
+ /** Wire mirror of src/wizard/tunnel-orchestrator.ts's results. */
34
+ export interface TunnelExisting {
35
+ id: string
36
+ name: string
37
+ status: string
38
+ }
39
+
40
+ export type TunnelSetupWire =
41
+ | { ok: true; tunnelId: string; publicUrl: string }
42
+ | { ok: false; reason: string; detail?: string; existing?: TunnelExisting[] }
43
+
44
+ export type TunnelCheckWire =
45
+ | { ok: true; onCloudflare: boolean; zoneId: string | null }
46
+ | { ok: false; reason: string; detail?: string }
47
+
48
+ /** The tunnel setup failure's locale key (plus params). Pure. */
49
+ export function tunnelFailureCopy(reason: string, detail?: string): { key: string; params: Record<string, string> } {
50
+ switch (reason) {
51
+ case 'tunnel-conflict':
52
+ return { key: 'wizard.tunnel.conflict', params: {} }
53
+ case 'zone-not-found':
54
+ return { key: 'wizard.tunnel.zoneNotFound', params: {} }
55
+ case 'dns-conflict':
56
+ return { key: 'wizard.tunnel.dnsConflict', params: { detail: detail ?? '' } }
57
+ case 'auth':
58
+ return { key: 'wizard.tunnel.auth', params: {} }
59
+ case 'network':
60
+ return { key: 'wizard.tunnel.network', params: {} }
61
+ case 'no-token':
62
+ return { key: 'wizard.tunnel.noToken', params: {} }
63
+ case 'bad-domain':
64
+ return { key: 'wizard.tunnel.badDomain', params: {} }
65
+ case 'spawn-failed':
66
+ return { key: 'wizard.tunnel.spawnFailed', params: { detail: detail ?? '' } }
67
+ default:
68
+ return { key: 'wizard.tunnel.api', params: { detail: detail ?? reason } }
69
+ }
70
+ }
71
+
72
+ /** The domain check's copy (the migration card's re-check). Pure. */
73
+ export function tunnelCheckCopy(result: TunnelCheckWire): { key: string; tone: 'ok' | 'error' } {
74
+ if (result.ok) {
75
+ return result.onCloudflare
76
+ ? { key: 'wizard.check.okOnCf', tone: 'ok' }
77
+ : { key: 'wizard.check.okNotOnCf', tone: 'error' }
78
+ }
79
+ return { key: 'wizard.check.failed', tone: 'error' }
80
+ }
81
+
82
+ /** Wire mirror of src/wizard/status.ts's WizardStatus. */
83
+ export interface WizardStatusBody {
84
+ ok: true
85
+ mode: 'off' | 'caddy' | 'external' | 'cloudflared'
86
+ domain: string
87
+ httpsPort: number
88
+ publicUrl: string
89
+ cfTokenConfigured: boolean
90
+ tunnelTokenConfigured: boolean
91
+ caddyRunning: boolean
92
+ cloudflaredRunning: boolean
93
+ gatewayActualPort: number | null
94
+ lanAddress?: string
95
+ lastHostProbe: { result: string; deviceId: string | null; at: number | null } | null
96
+ lastExternalVerification: { result: string; deviceId: string | null; at: number | null } | null
97
+ lastForwardedSource: { ip: string; at: number } | null
98
+ }
99
+
100
+ /** Wire mirror of src/wizard/verify.ts's poll result. */
101
+ export type WizardVerifyPoll =
102
+ | { result: 'not-started' }
103
+ | { result: 'stale' }
104
+ | { result: 'code-expired' }
105
+ | { result: 'pending' }
106
+ | { result: 'cookie-insecure'; deviceId: string }
107
+ | { result: 'pass'; deviceId: string; at: number }
108
+
109
+ /** Wire mirror of src/wizard/verify.ts's start result. */
110
+ export type WizardVerifyStart =
111
+ | { ok: true; pairingCode: string; publicUrl: string; expiresAt: number }
112
+ | { ok: false; reason: 'no-public-url' | 'bad-public-url' | 'runtime-not-ready' | 'gateway-unreachable' | 'probe-failed' | 'pair-refused'; detail?: unknown }
113
+
114
+ // --- the step state machine ----------------------------------------------------
115
+
116
+ /** The deployment role the unified wizard configures: server (`host`) or
117
+ * desktop sub-client (`client`). */
118
+ export type WizardRole = 'host' | 'client'
119
+
120
+ /** The three wizard steps: pick a path, fill its form, verify end to end. */
121
+ export type WizardStep = 'pick' | 'configure' | 'verify'
122
+
123
+ /** The four server-ingress paths (the three public ones plus LAN-only `'lan'`). */
124
+ export type WizardPath = 'caddy' | 'external' | 'cloudflared' | 'lan'
125
+
126
+ /** Where the wizard opens: a configured ingress jumps straight to verify. */
127
+ export function initialStep(status: WizardStatusBody | undefined): WizardStep {
128
+ return status !== undefined && status.mode !== 'off' ? 'verify' : 'pick'
129
+ }
130
+
131
+ /**
132
+ * Whether the inline overview card should render the configured status
133
+ * dashboard instead of the first-time setup prompt.
134
+ */
135
+ export function isWizardConfigured(input: {
136
+ effectiveClient: boolean
137
+ wizardStatus: WizardStatusBody | undefined
138
+ pairedDeviceCount: number
139
+ hasSavedHostConfig: boolean
140
+ tokenConfigured: boolean
141
+ clientServerUrl: string
142
+ clientState: string | undefined
143
+ }): boolean {
144
+ if (input.effectiveClient) {
145
+ return input.tokenConfigured || input.clientServerUrl.trim() !== '' || input.clientState === 'connected'
146
+ }
147
+ if (input.wizardStatus !== undefined && (input.wizardStatus.mode !== 'off' || input.wizardStatus.publicUrl !== '')) {
148
+ return true
149
+ }
150
+ return input.pairedDeviceCount > 0 || input.hasSavedHostConfig
151
+ }
152
+
153
+ export interface WizardFormSeed {
154
+ domain: string
155
+ httpsPort: string
156
+ serverUrl: string
157
+ path: WizardPath | undefined
158
+ }
159
+
160
+ /**
161
+ * The configure-form seed from one wizard status projection (GW-fix-3/M1):
162
+ * the projection (wizard/status) is the ONLY surface that carries the
163
+ * ingress fields — admin/status strips them by design, so the form must
164
+ * seed from here. Empty values leave the form untouched.
165
+ */
166
+ export function formSeedOf(status: WizardStatusBody | undefined): WizardFormSeed {
167
+ if (status === undefined || status.mode === 'off') {
168
+ return { domain: '', httpsPort: '', serverUrl: '', path: undefined }
169
+ }
170
+ return {
171
+ domain: status.domain,
172
+ httpsPort: String(status.httpsPort),
173
+ serverUrl: status.publicUrl,
174
+ path: status.mode,
175
+ }
176
+ }
177
+
178
+ /**
179
+ * The reverse-proxy snippet's upstream port: the gateway's ACTUAL port as
180
+ * the wizard runtime reports it, falling back to the configured default
181
+ * 3088 when no status has landed yet (GW-fix-3/M2).
182
+ */
183
+ export function upstreamPortOf(status: WizardStatusBody | undefined): number {
184
+ return typeof status?.gatewayActualPort === 'number' && status.gatewayActualPort >= 1 && status.gatewayActualPort <= 65535
185
+ ? status.gatewayActualPort
186
+ : 3088
187
+ }
188
+
189
+ /** The step AFTER pick, given the user's path choice. */
190
+ export function stepAfterPick(): WizardStep {
191
+ return 'configure'
192
+ }
193
+
194
+ /** Hostname shape (mirror of src/config.ts hostOf): bare letters/digits/
195
+ * dots/inner hyphens, labels ≤ 63, total ≤ 253, no scheme/path/port/
196
+ * whitespace. IP literals pass. */
197
+ const HOSTNAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/
198
+
199
+ export function isValidHostname(value: string): boolean {
200
+ return value.length <= 253 && HOSTNAME_PATTERN.test(value)
201
+ }
202
+
203
+ /** Port strings: [1, 65535]. */
204
+ export function isValidPort(value: string): boolean {
205
+ if (!/^\d+$/.test(value)) return false
206
+ const parsed = Number(value)
207
+ return Number.isInteger(parsed) && parsed >= 1 && parsed <= 65535
208
+ }
209
+
210
+ /** The public origin a verified domain+port answers on (443 hides the port). */
211
+ export function publicUrlFrom(domain: string, httpsPort: number): string {
212
+ return httpsPort === 443 ? `https://${domain}` : `https://${domain}:${httpsPort}`
213
+ }
214
+
215
+ /** Which configure form is complete enough to advance. */
216
+ export function configureReadiness(
217
+ path: WizardPath,
218
+ form: { domain: string; httpsPort: string; cfToken: string; serverUrl: string },
219
+ ): { ok: true; publicUrl: string; httpsPort: number } | { ok: false; error: 'bad-domain' | 'bad-port' | 'missing-token' | 'bad-server-url' } {
220
+ if (path === 'lan') {
221
+ const trimmed = form.serverUrl.trim().replace(/\/+$/u, '')
222
+ if (trimmed === '') return { ok: true, publicUrl: '', httpsPort: 443 }
223
+ let parsed: URL
224
+ try {
225
+ parsed = new URL(trimmed)
226
+ } catch {
227
+ return { ok: false, error: 'bad-server-url' }
228
+ }
229
+ if ((parsed.protocol !== 'http:' && parsed.protocol !== 'https:') || (parsed.pathname !== '/' && parsed.pathname !== '') || parsed.search !== '' || parsed.hash !== '') {
230
+ return { ok: false, error: 'bad-server-url' }
231
+ }
232
+ return { ok: true, publicUrl: parsed.origin, httpsPort: 443 }
233
+ }
234
+ if (path === 'external') {
235
+ // The user's own reverse proxy: the public origin is the one thing to
236
+ // get right (same admission the probe applies — https, root path).
237
+ const trimmed = form.serverUrl.trim().replace(/\/+$/u, '')
238
+ let parsed: URL
239
+ try {
240
+ parsed = new URL(trimmed)
241
+ } catch {
242
+ return { ok: false, error: 'bad-server-url' }
243
+ }
244
+ if (parsed.protocol !== 'https:' || (parsed.pathname !== '/' && parsed.pathname !== '') || parsed.search !== '' || parsed.hash !== '') {
245
+ return { ok: false, error: 'bad-server-url' }
246
+ }
247
+ return { ok: true, publicUrl: parsed.origin, httpsPort: 443 }
248
+ }
249
+ if (!isValidHostname(form.domain.trim())) return { ok: false, error: 'bad-domain' }
250
+ if (!isValidPort(form.httpsPort.trim())) return { ok: false, error: 'bad-port' }
251
+ const port = Number(form.httpsPort.trim())
252
+ if ((path === 'caddy' || path === 'cloudflared') && form.cfToken.trim() === '') return { ok: false, error: 'missing-token' }
253
+ return { ok: true, publicUrl: publicUrlFrom(form.domain.trim(), port), httpsPort: port }
254
+ }
255
+
256
+ // --- the verification lights -----------------------------------------------------
257
+
258
+ export type LightState = 'pass' | 'pending' | 'fail'
259
+
260
+ export interface WizardLights {
261
+ /** The gateway child answers on this machine. */
262
+ gateway: LightState
263
+ /** The public URL walks back to this gateway. */
264
+ publicUrl: LightState
265
+ /** A phone actually paired through it. */
266
+ pairing: LightState
267
+ }
268
+
269
+ /** Pending-but-started states the lights stay amber on. */
270
+ const VERIFY_PENDING_RESULTS: ReadonlySet<string> = new Set(['not-started', 'pending', 'stale', 'code-expired'])
271
+
272
+ /**
273
+ * The three progress lights, derived from the admin/status reachability
274
+ * plus the verification poll result. Before a session is started the poll
275
+ * answers `not-started`, which is a pending state — the public and pairing
276
+ * lights stay amber (nothing has been tried, so red would just shout);
277
+ * once started, an amber poll keeps them amber, a wrong-shape answer
278
+ * (redirect, someone else's server) turns the public light red, and an
279
+ * insecure cookie turns the pairing light red without ever passing.
280
+ */
281
+ export function deriveWizardLights(input: {
282
+ gatewayReachable: boolean
283
+ poll: WizardVerifyPoll | undefined
284
+ startResult?: WizardVerifyStart | undefined
285
+ }): WizardLights {
286
+ const { poll, startResult } = input
287
+ const startProbeFailed = startResult !== undefined && !startResult.ok
288
+ && (startResult.reason === 'probe-failed' || startResult.reason === 'bad-public-url' || startResult.reason === 'no-public-url')
289
+ const startProbePassed = startResult?.ok === true && poll?.result !== 'stale' && poll?.result !== 'cookie-insecure'
290
+ const publicFail = startProbeFailed || (poll !== undefined && poll.result !== 'pass' && !VERIFY_PENDING_RESULTS.has(poll.result))
291
+ return {
292
+ gateway: input.gatewayReachable ? 'pass' : 'fail',
293
+ publicUrl: (poll?.result === 'pass' || startProbePassed) ? 'pass' : publicFail ? 'fail' : 'pending',
294
+ pairing: poll?.result === 'pass' ? 'pass' : poll?.result === 'cookie-insecure' ? 'fail' : 'pending',
295
+ }
296
+ }
297
+
298
+ export interface ClientWizardLights {
299
+ /** Whether a desktop-client token is saved on this machine. */
300
+ credential: LightState
301
+ /** Whether the client can reach and authenticate with the server. */
302
+ connection: LightState
303
+ /** Whether the session-sync and plugin-bridge interceptors are active. */
304
+ sync: LightState
305
+ }
306
+
307
+ const CLIENT_FAIL_STATES: ReadonlySet<string> = new Set([
308
+ 'unreachable',
309
+ 'revoked',
310
+ 'incompatible',
311
+ 'invalid-url',
312
+ 'unexpected',
313
+ ])
314
+
315
+ /**
316
+ * Derive the three status lights for the desktop sub-client verification
317
+ * step (`role === 'client'`).
318
+ */
319
+ export function deriveClientWizardLights(input: {
320
+ tokenConfigured: boolean
321
+ clientState: string | undefined
322
+ interceptInstalled?: boolean | undefined
323
+ }): ClientWizardLights {
324
+ const connected = input.clientState === 'connected'
325
+ const failed = input.clientState !== undefined && CLIENT_FAIL_STATES.has(input.clientState)
326
+ return {
327
+ credential: (input.tokenConfigured || connected) ? 'pass' : input.clientState === 'revoked' ? 'fail' : 'pending',
328
+ connection: connected ? 'pass' : failed ? 'fail' : 'pending',
329
+ sync: connected ? (input.interceptInstalled === false ? 'fail' : 'pass') : 'pending',
330
+ }
331
+ }
332
+
333
+ /** Pairing-code countdown, whole seconds, never negative. */
334
+ export function pairingSecondsLeft(expiresAt: number, now: number): number {
335
+ return Math.max(0, Math.ceil((expiresAt - now) / 1000))
336
+ }
337
+
338
+ /**
339
+ * The managed-Caddy start-failure copy: maps the wire error code to a
340
+ * locale key (+params / manual-install link). Pure — the check script pins
341
+ * every mapping.
342
+ */
343
+ export function caddyFailureCopy(
344
+ error: string,
345
+ detail?: string,
346
+ installUrl?: string,
347
+ ): { key: string; params: Record<string, string>; installUrl?: string } {
348
+ switch (error) {
349
+ case 'missing-token':
350
+ return { key: 'wizard.caddy.failMissingToken', params: {} }
351
+ case 'already-running':
352
+ return { key: 'wizard.caddy.failAlreadyRunning', params: {} }
353
+ case 'bad-config':
354
+ return { key: 'wizard.caddy.failBadConfig', params: { detail: detail ?? '' } }
355
+ case 'download-failed':
356
+ return { key: 'wizard.caddy.failDownload', params: {}, ...(installUrl !== undefined ? { installUrl } : {}) }
357
+ case 'validate-failed':
358
+ return { key: 'wizard.caddy.failValidate', params: {} }
359
+ default:
360
+ return { key: 'wizard.caddy.failSpawn', params: {} }
361
+ }
362
+ }
363
+
364
+ // --- the reverse-proxy snippet templates (mirror of proxy-snippets.ts) -----------
365
+
366
+ export interface SnippetInput {
367
+ domain: string
368
+ upstreamHost: string
369
+ upstreamPort: number
370
+ }
371
+
372
+ /** Same grammar the row layer and the host half enforce. */
373
+ function valid(input: SnippetInput): { domain: string; upstream: string; port: number } | null {
374
+ if (!isValidHostname(input.domain)) return null
375
+ if (!isValidHostname(input.upstreamHost)) return null
376
+ if (!Number.isInteger(input.upstreamPort) || input.upstreamPort < 1 || input.upstreamPort > 65535) return null
377
+ return { domain: input.domain, upstream: input.upstreamHost, port: input.upstreamPort }
378
+ }
379
+
380
+ export function nginxSnippetText(input: SnippetInput): string | null {
381
+ const parts = valid(input)
382
+ if (parts === null) return null
383
+ return [
384
+ '# 这段放在 http {} 层(若已有同名 map 则跳过)',
385
+ 'map $http_upgrade $connection_upgrade {',
386
+ ' default upgrade;',
387
+ " '' close;",
388
+ '}',
389
+ '',
390
+ 'server {',
391
+ ' listen 443 ssl;',
392
+ ` server_name ${parts.domain};`,
393
+ '',
394
+ ' location / {',
395
+ ` proxy_pass http://${parts.upstream}:${parts.port};`,
396
+ ' proxy_http_version 1.1;',
397
+ ' proxy_set_header Upgrade $http_upgrade;',
398
+ ' proxy_set_header Connection $connection_upgrade;',
399
+ ' proxy_set_header Host $host;',
400
+ ' proxy_set_header X-Real-IP $remote_addr;',
401
+ ' proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;',
402
+ ' proxy_set_header X-Forwarded-Proto $scheme;',
403
+ ' proxy_buffering off;',
404
+ ' client_max_body_size 128m;',
405
+ ' proxy_read_timeout 3600s;',
406
+ ' proxy_send_timeout 3600s;',
407
+ ' }',
408
+ '}',
409
+ '',
410
+ ].join('\n')
411
+ }
412
+
413
+ export function caddySnippetText(input: SnippetInput): string | null {
414
+ const parts = valid(input)
415
+ if (parts === null) return null
416
+ return `${parts.domain} {\n reverse_proxy ${parts.upstream}:${parts.port}\n}\n`
417
+ }
418
+
419
+ /** The Lucky steps (mirror of proxy-snippets.ts luckySteps). */
420
+ export function luckyStepTexts(input: SnippetInput): string[] | null {
421
+ const parts = valid(input)
422
+ if (parts === null) return null
423
+ return [
424
+ '在 Lucky 的「Web 服务」里新建一条规则,监听 443 端口,绑定你的域名。',
425
+ `后端(反代目标)填 http://${parts.upstream}:${parts.port},开启 WebSocket 支持。`,
426
+ '打开「万事大吉」开关(放行全部路径),保存即可生效。',
427
+ ]
428
+ }
429
+
430
+ /** The snippet kinds the reverse-proxy card tabs over, in tab order. */
431
+ export const SNIPPET_KINDS = ['nginx', 'caddy', 'lucky'] as const
432
+ export type SnippetKind = (typeof SNIPPET_KINDS)[number]
package/src/config.ts CHANGED
@@ -83,6 +83,22 @@ export interface ZenRemoteConfig {
83
83
  * false means NO — default `{}` keeps every plugin unconsented. Volatile:
84
84
  * read at compare time, deliberately NOT in RESTART_FIELDS. */
85
85
  pluginBridgeConsent: Record<string, boolean>
86
+ /** Which public-ingress setup the gateway wizard (GW-1) manages.
87
+ * `'off'` no public entry; `'caddy'` the plugin-managed Caddy reverse
88
+ * proxy; `'external'` a user-operated reverse proxy; `'cloudflared'` a
89
+ * Cloudflare Tunnel. Read live per request by the wizard routes; a mode
90
+ * change must NOT reload the row (the ingress processes outlive it). */
91
+ ingressMode: 'off' | 'caddy' | 'external' | 'cloudflared'
92
+ /** Public ingress domain, bare host (e.g. `dsh.example.com`), no scheme,
93
+ * no path. Empty means none yet. */
94
+ ingressDomain: string
95
+ /** Port the managed Caddy listens for HTTPS on. [1, 65535]. */
96
+ ingressHttpsPort: number
97
+ /** ACME account contact for certificate notices; empty means none. */
98
+ ingressAcmeEmail: string
99
+ /** The public origin the final verification reached (scheme + host +
100
+ * optional port, root path only). Empty until verified. */
101
+ ingressPublicUrl: string
86
102
  }
87
103
 
88
104
  function defaultServerName(): string {
@@ -108,6 +124,11 @@ export const DEFAULTS: ZenRemoteConfig = {
108
124
  idleHours: 48,
109
125
  autoShareNewSessions: false,
110
126
  pluginBridgeConsent: {},
127
+ ingressMode: 'off',
128
+ ingressDomain: '',
129
+ ingressHttpsPort: 443,
130
+ ingressAcmeEmail: '',
131
+ ingressPublicUrl: '',
111
132
  }
112
133
 
113
134
  // --- per-field validators -------------------------------------------------
@@ -183,6 +204,50 @@ function serverNameOf(value: unknown): string | undefined {
183
204
  return name === undefined ? undefined : name.slice(0, SERVER_NAME_MAX)
184
205
  }
185
206
 
207
+ /** `ingressAcmeEmail`: the pragmatic mail shape (local part, one `@`, dotted
208
+ * domain, no whitespace) — enough to keep a typo out of the ACME account,
209
+ * not a full RFC 5322 parser. */
210
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
211
+
212
+ function acmeEmail(value: unknown): string | undefined {
213
+ return typeof value === 'string' && EMAIL_PATTERN.test(value) ? value : undefined
214
+ }
215
+
216
+ /**
217
+ * `ingressDomain`: a BARE hostname and nothing else (GW-fix-1/M1) — letters,
218
+ * digits, dots and inner hyphens, labels of at most 63 characters, 253 in
219
+ * total, no scheme, no path, no port, no whitespace, no braces or newlines
220
+ * (the value flows verbatim into generated Caddyfile / DNS material, where
221
+ * any of those would either break the config or smuggle in an injection).
222
+ * IP literals pass — a bare-IP LAN ingress is a legitimate shape. IDN names
223
+ * must arrive punycoded. Exported for the wizard half, which validates the
224
+ * same shape at its own edges (generated config, API payloads).
225
+ */
226
+ const HOSTNAME_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/
227
+
228
+ const HOSTNAME_MAX = 253
229
+
230
+ export function hostOf(value: unknown): string | undefined {
231
+ return typeof value === 'string' && value.length <= HOSTNAME_MAX && HOSTNAME_PATTERN.test(value) ? value : undefined
232
+ }
233
+
234
+ /** `ingressPublicUrl`: an http(s) ORIGIN and nothing else — the design keeps
235
+ * path-based reverse proxying out of scope, so a URL with a path, query,
236
+ * fragment or userinfo never counts as verified. Returned verbatim. */
237
+ function publicOrigin(value: unknown): string | undefined {
238
+ if (typeof value !== 'string' || value === '') return undefined
239
+ let parsed: URL
240
+ try {
241
+ parsed = new URL(value)
242
+ } catch {
243
+ return undefined
244
+ }
245
+ if ((parsed.protocol !== 'https:' && parsed.protocol !== 'http:')) return undefined
246
+ if ((parsed.pathname !== '/' && parsed.pathname !== '') || parsed.search !== '' || parsed.hash !== '') return undefined
247
+ if (parsed.username !== '' || parsed.password !== '') return undefined
248
+ return value
249
+ }
250
+
186
251
  /** `pluginBridgeConsent`: a plain object mapping plugin names to STRICT
187
252
  * booleans. Anything else — null, an array, a non-boolean value (`"yes"`,
188
253
  * `1`; unlike the scalar booleans there is no historic `1`/`"1"` convention
@@ -290,6 +355,15 @@ const FIELDS: { [K in keyof ZenRemoteConfig]: FieldResolver<ZenRemoteConfig[K]>
290
355
  // flat variable to stay compatible with, and a consent decision is a
291
356
  // settings-page action, not something set through the environment.
292
357
  pluginBridgeConsent: { row: consentMap, file: consentMap },
358
+ // Gateway-wizard declared fields (GW-1), same no-env reasoning: they are
359
+ // written by the wizard's own routes on this machine, never through the
360
+ // environment, and none has a historic LAN_GATE_* name. Not in
361
+ // RESTART_FIELDS — the ingress reads them live per request.
362
+ ingressMode: { row: oneOf('off', 'caddy', 'external', 'cloudflared'), file: oneOf('off', 'caddy', 'external', 'cloudflared') },
363
+ ingressDomain: { row: hostOf, file: hostOf },
364
+ ingressHttpsPort: { row: intIn(1, 65535), file: lenientFileNumber(intIn(1, 65535)) },
365
+ ingressAcmeEmail: { row: acmeEmail, file: acmeEmail },
366
+ ingressPublicUrl: { row: publicOrigin, file: publicOrigin },
293
367
  }
294
368
 
295
369
  /**
@@ -374,6 +448,11 @@ export function resolveConfig(
374
448
  idleHours: resolveField('idleHours', safeRow, safeFile, env, sources),
375
449
  autoShareNewSessions: resolveField('autoShareNewSessions', safeRow, safeFile, env, sources),
376
450
  pluginBridgeConsent: resolveField('pluginBridgeConsent', safeRow, safeFile, env, sources),
451
+ ingressMode: resolveField('ingressMode', safeRow, safeFile, env, sources),
452
+ ingressDomain: resolveField('ingressDomain', safeRow, safeFile, env, sources),
453
+ ingressHttpsPort: resolveField('ingressHttpsPort', safeRow, safeFile, env, sources),
454
+ ingressAcmeEmail: resolveField('ingressAcmeEmail', safeRow, safeFile, env, sources),
455
+ ingressPublicUrl: resolveField('ingressPublicUrl', safeRow, safeFile, env, sources),
377
456
  }
378
457
  return { values, sources }
379
458
  }
@@ -464,6 +543,14 @@ export const Config = z.object({
464
543
  // time — a consent flip must apply without a row restart, so it is
465
544
  // deliberately NOT in RESTART_FIELDS.
466
545
  pluginBridgeConsent: z.any().volatile(),
546
+ // Gateway-wizard declared fields (GW-1): the ingress mode/domain/port and
547
+ // the ACME contact and verified origin, all live-read by the wizard routes
548
+ // (never snapshotted) and deliberately NOT in RESTART_FIELDS.
549
+ ingressMode: z.any().volatile(),
550
+ ingressDomain: z.any().volatile(),
551
+ ingressHttpsPort: z.any().volatile(),
552
+ ingressAcmeEmail: z.any().volatile(),
553
+ ingressPublicUrl: z.any().volatile(),
467
554
  // Client-half knobs (T16): where the phone finds the gateway, and the
468
555
  // pairing token it presents. `role('secret')` keeps the token out of every
469
556
  // settings wire surface, like the verifier plugin's API key.