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,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.
|