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