@linxin666/dsh-remote-web-ui 0.3.12 → 0.3.13
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.i18n.yaml +2 -2
- package/README.md +24 -4
- package/README.zh.md +24 -4
- package/lib/client.js +77 -8
- package/lib/client.js.map +1 -1
- package/lib/index.js +409 -44
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +1 -0
- package/lib/types/client/RemotePanel.d.ts +3 -1
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +1 -1
- package/lib/types/client/RemoteSettingsCard.d.ts +16 -0
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +6 -2
- package/lib/types/client/locales.d.ts +12 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +16 -4
- package/lib/types/client/pair-api.d.ts +10 -0
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/index.d.ts +28 -2
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +138 -23
- package/lib/types/pairing.d.ts +17 -0
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +13 -0
- package/lib/types/relay-registry.d.ts +97 -0
- package/lib/types/relay-registry.d.ts.map +1 -0
- package/lib/types/relay-registry.js +191 -0
- package/lib/types/tunnel-plan.d.ts +36 -0
- package/lib/types/tunnel-plan.d.ts.map +1 -0
- package/lib/types/tunnel-plan.js +43 -0
- package/lib/types/tunnel.d.ts +66 -13
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +93 -21
- package/package.json +1 -1
- package/src/client/RemoteEntry.tsx +2 -1
- package/src/client/RemotePanel.tsx +11 -0
- package/src/client/RemoteSettingsCard.tsx +43 -1
- package/src/client/locales.ts +16 -4
- package/src/client/pair-api.ts +11 -0
- package/src/client/settings-card.module.css +14 -8
- package/src/index.ts +153 -25
- package/src/pairing.ts +30 -0
- package/src/relay-registry.ts +232 -0
- package/src/tunnel-plan.ts +51 -0
- package/src/tunnel.ts +120 -24
package/src/index.ts
CHANGED
|
@@ -32,6 +32,8 @@ import { claimPostureKey, postureTargets, probePosture, releasePostureKey } from
|
|
|
32
32
|
import { lanIPv4Addresses } from './lan.ts'
|
|
33
33
|
import { ensureFirewallRule, firewallSummary, removeFirewallRule } from './firewall.ts'
|
|
34
34
|
import { lanBindState, writeLanBind } from './lan-bind.ts'
|
|
35
|
+
import { isHttpUrl, tunnelPlanOf } from './tunnel-plan.ts'
|
|
36
|
+
import { loadRelayIdentity, RelayRegistrar, type RelayState } from './relay-registry.ts'
|
|
35
37
|
import { desiredBindHost, desiredBindPort, firewallActionNeeded, pendingRestartOf, type AppliedFirewallState, type StartupFacts } from './lan-bind-plan.ts'
|
|
36
38
|
import { createInnerAuth } from './inner-auth.ts'
|
|
37
39
|
import { TunnelManager, type TunnelInfo } from './tunnel.ts'
|
|
@@ -136,9 +138,34 @@ export interface Config {
|
|
|
136
138
|
* cloudflared binary ships with the package — no user-side install) and
|
|
137
139
|
* feeds the minted public URL into the QR base and the phone-facing
|
|
138
140
|
* pairing fence dynamically, so phones anywhere can pair without any manual
|
|
139
|
-
* tunnel setup. The
|
|
141
|
+
* tunnel setup. The minted hostname changes on every start, so a paired
|
|
142
|
+
* phone must re-pair after each restart. The manual `publicBaseUrl` and
|
|
143
|
+
* `tunnelToken` are ignored while this is on.
|
|
140
144
|
*/
|
|
141
145
|
autoTunnel?: boolean
|
|
146
|
+
/**
|
|
147
|
+
* Cloudflare named-tunnel token (`cloudflared tunnel run --token <t>`).
|
|
148
|
+
* When set (and `autoTunnel` is off), the plugin runs the named tunnel
|
|
149
|
+
* itself — same binary, same lifecycle management — toward the fixed
|
|
150
|
+
* public hostname configured in the Cloudflare dashboard. Because that
|
|
151
|
+
* hostname never changes, a paired phone keeps its bookmark and its
|
|
152
|
+
* pairing cookie across `dsh web` restarts: pair once, never again.
|
|
153
|
+
* Requires `publicBaseUrl` to name that same hostname (the token does not
|
|
154
|
+
* carry it); without a valid `publicBaseUrl` the tunnel stays off and a
|
|
155
|
+
* warning explains what is missing. Treated as a secret: the settings
|
|
156
|
+
* surface stores it redacted.
|
|
157
|
+
*/
|
|
158
|
+
tunnelToken?: string
|
|
159
|
+
/**
|
|
160
|
+
* Stable-origin relay: when on (default), the quick tunnel is fronted by a
|
|
161
|
+
* fixed `https://<id>.dsh-market.com` subdomain (the dsh-market worker's
|
|
162
|
+
* registry), so the phone's bookmark and pairing cookie survive `dsh web`
|
|
163
|
+
* restarts without any user setup. Traffic transits the dsh-market edge
|
|
164
|
+
* (the same trust point as the quick tunnel itself); turn off to fall back
|
|
165
|
+
* to the raw ephemeral quick URL. Named tunnels keep their dashboard
|
|
166
|
+
* hostname and never touch the relay.
|
|
167
|
+
*/
|
|
168
|
+
relay?: boolean
|
|
142
169
|
/**
|
|
143
170
|
* LAN bind toggle. When the user flips it (true or false) the plugin
|
|
144
171
|
* writes the managed webserver block into the profile patch — true pins
|
|
@@ -174,6 +201,8 @@ export const Config: z<Config> = z.object({
|
|
|
174
201
|
publicBaseUrl: z.string(),
|
|
175
202
|
devicesFile: z.string(),
|
|
176
203
|
autoTunnel: z.boolean().default(false),
|
|
204
|
+
tunnelToken: z.string().role('secret'),
|
|
205
|
+
relay: z.boolean().default(true),
|
|
177
206
|
lanBind: z.boolean(),
|
|
178
207
|
profile: z.string().pattern(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
|
|
179
208
|
enabled: z.boolean().default(true),
|
|
@@ -187,11 +216,12 @@ const SWEEP_INTERVAL_MS = 10_000
|
|
|
187
216
|
* which legitimately resolves to `undefined` when unset (the schema keeps it
|
|
188
217
|
* optional, so `Required` alone would over-narrow it to `string`).
|
|
189
218
|
*/
|
|
190
|
-
type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl' | 'devicesFile' | 'lanBind' | 'profile'>> & {
|
|
219
|
+
type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl' | 'devicesFile' | 'lanBind' | 'profile' | 'tunnelToken'>> & {
|
|
191
220
|
publicBaseUrl: string | undefined
|
|
192
221
|
devicesFile: string
|
|
193
222
|
/** undefined until the user flips the toggle once; undefined never writes the patch. */
|
|
194
223
|
lanBind: boolean | undefined
|
|
224
|
+
tunnelToken: string | undefined
|
|
195
225
|
profile: string
|
|
196
226
|
}
|
|
197
227
|
|
|
@@ -231,6 +261,8 @@ const DEFAULTS: ResolvedConfig = {
|
|
|
231
261
|
publicBaseUrl: undefined,
|
|
232
262
|
devicesFile: defaultDevicesFile(),
|
|
233
263
|
autoTunnel: false,
|
|
264
|
+
tunnelToken: undefined,
|
|
265
|
+
relay: true,
|
|
234
266
|
lanBind: undefined,
|
|
235
267
|
profile: process.env.DSH_PROFILE ?? 'web',
|
|
236
268
|
enabled: true,
|
|
@@ -254,6 +286,8 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
254
286
|
publicBaseUrl: config?.publicBaseUrl,
|
|
255
287
|
devicesFile: config?.devicesFile ?? DEFAULTS.devicesFile,
|
|
256
288
|
autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
|
|
289
|
+
tunnelToken: config?.tunnelToken,
|
|
290
|
+
relay: config?.relay ?? DEFAULTS.relay,
|
|
257
291
|
lanBind: config?.lanBind,
|
|
258
292
|
profile: config?.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
|
|
259
293
|
enabled: config?.enabled ?? DEFAULTS.enabled,
|
|
@@ -274,6 +308,8 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
274
308
|
publicBaseUrl: value.publicBaseUrl,
|
|
275
309
|
devicesFile: value.devicesFile ?? DEFAULTS.devicesFile,
|
|
276
310
|
autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
|
|
311
|
+
tunnelToken: value.tunnelToken,
|
|
312
|
+
relay: value.relay ?? DEFAULTS.relay,
|
|
277
313
|
lanBind: value.lanBind,
|
|
278
314
|
profile: value.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
|
|
279
315
|
enabled: value.enabled ?? DEFAULTS.enabled,
|
|
@@ -288,24 +324,89 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
288
324
|
// mutation is needed here (a distributable plugin must not change the
|
|
289
325
|
// harness's connection plugin).
|
|
290
326
|
const tunnel = new TunnelManager()
|
|
291
|
-
|
|
327
|
+
// Relay registry: one stable `<id>.dsh-market.com` subdomain per profile
|
|
328
|
+
// in front of the quick tunnel. The QR base prefers the relay origin once
|
|
329
|
+
// it is registered; until then (or when the registry is unreachable) the
|
|
330
|
+
// raw quick URL is used, exactly as before. Named tunnels keep their fixed
|
|
331
|
+
// dashboard hostname and never touch the relay.
|
|
332
|
+
let relayRegistrar: RelayRegistrar | undefined
|
|
333
|
+
let relayUrl: string | undefined
|
|
334
|
+
let rawTunnelUrl: string | undefined
|
|
335
|
+
/** The tunnel target the registrar last announced (dedupes sync re-runs). */
|
|
336
|
+
let relayAnnouncedFor: string | undefined
|
|
337
|
+
const setPublicBase = (): void => {
|
|
338
|
+
service.setPublicBaseUrl(relayUrl ?? rawTunnelUrl)
|
|
339
|
+
}
|
|
340
|
+
const disposeRelayRegistrar = (unregister: boolean = false): void => {
|
|
341
|
+
const registrar = relayRegistrar
|
|
342
|
+
relayRegistrar = undefined
|
|
343
|
+
relayUrl = undefined
|
|
344
|
+
relayAnnouncedFor = undefined
|
|
345
|
+
if (registrar === undefined) return
|
|
346
|
+
// Toggle-off removes the registry row so the stable origin stops
|
|
347
|
+
// proxying; teardown/mode changes keep the row (the phone then sees the
|
|
348
|
+
// relay's offline page instead of a dangling claim, and re-enabling
|
|
349
|
+
// reuses the same identity).
|
|
350
|
+
if (unregister) void registrar.unregister().catch(() => undefined)
|
|
351
|
+
registrar.dispose()
|
|
352
|
+
}
|
|
353
|
+
const ensureRelayRegistrar = (): RelayRegistrar | undefined => {
|
|
354
|
+
if (resolve().relay === false) return undefined
|
|
355
|
+
if (relayRegistrar === undefined) {
|
|
356
|
+
try {
|
|
357
|
+
const identity = loadRelayIdentity(resolve().profile)
|
|
358
|
+
relayRegistrar = new RelayRegistrar(identity, (state: RelayState) => {
|
|
359
|
+
service.setRelayStatus(state.state === 'off' ? undefined : state)
|
|
360
|
+
if (state.state === 'running') {
|
|
361
|
+
relayUrl = state.url
|
|
362
|
+
} else if (state.state === 'off') {
|
|
363
|
+
relayUrl = undefined
|
|
364
|
+
} else if (state.state === 'failed') {
|
|
365
|
+
// Keep the last relay URL on failures: the phone origin only
|
|
366
|
+
// breaks when the mapping itself goes stale, not when one
|
|
367
|
+
// refresh call fails. The registrar retries with backoff.
|
|
368
|
+
console.warn(`remote-web-ui: relay registration failed (${state.error}) — the stable origin may serve its offline page until the retry lands`)
|
|
369
|
+
}
|
|
370
|
+
setPublicBase()
|
|
371
|
+
})
|
|
372
|
+
} catch (error) {
|
|
373
|
+
console.warn(`remote-web-ui: relay registry unavailable (${error instanceof Error ? error.message : String(error)}) — the quick URL is the QR base`)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return relayRegistrar
|
|
377
|
+
}
|
|
378
|
+
const announceRelay = (registrar: RelayRegistrar, target: string): void => {
|
|
379
|
+
if (relayAnnouncedFor === target) return
|
|
380
|
+
relayAnnouncedFor = target
|
|
381
|
+
void registrar.announce(target)
|
|
382
|
+
}
|
|
383
|
+
// 'off' until a sync pass turns a mode on; the phase listener only feeds
|
|
384
|
+
// the public base while a plugin-managed tunnel (quick or named) runs.
|
|
385
|
+
let tunnelMode: 'off' | 'quick' | 'named' = resolved.autoTunnel ? 'quick' : 'off'
|
|
292
386
|
tunnel.onPhase((info: TunnelInfo) => {
|
|
293
|
-
if (
|
|
387
|
+
if (tunnelMode === 'off') return
|
|
294
388
|
if (info.phase === 'running' && info.url !== undefined) {
|
|
295
|
-
|
|
389
|
+
rawTunnelUrl = info.url
|
|
390
|
+
setPublicBase()
|
|
296
391
|
service.setTunnelStatus({ state: 'running', url: info.url })
|
|
392
|
+
const registrar = tunnelMode === 'quick' ? ensureRelayRegistrar() : undefined
|
|
393
|
+
if (registrar !== undefined) announceRelay(registrar, info.url)
|
|
297
394
|
runPostureProbe()
|
|
298
395
|
} else if (info.phase === 'starting') {
|
|
299
396
|
// A restart mints a NEW hostname: the previous URL dies with the old
|
|
300
397
|
// process, so clear it now rather than advertising a dead link.
|
|
301
|
-
|
|
398
|
+
rawTunnelUrl = undefined
|
|
399
|
+
relayUrl = undefined
|
|
400
|
+
setPublicBase()
|
|
302
401
|
service.setTunnelStatus({ state: 'starting' })
|
|
303
402
|
} else if (info.phase === 'failed') {
|
|
304
|
-
|
|
403
|
+
rawTunnelUrl = undefined
|
|
404
|
+
setPublicBase()
|
|
305
405
|
service.setTunnelStatus(info.error === undefined ? { state: 'failed' } : { state: 'failed', error: info.error })
|
|
306
406
|
}
|
|
307
407
|
})
|
|
308
408
|
ctx.effect(() => () => {
|
|
409
|
+
disposeRelayRegistrar()
|
|
309
410
|
tunnel.dispose()
|
|
310
411
|
}, 'remote-web-ui: auto tunnel')
|
|
311
412
|
// The bind facts are known by now (webServer is an inject edge): the LAN
|
|
@@ -639,17 +740,54 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
639
740
|
}
|
|
640
741
|
}
|
|
641
742
|
}
|
|
642
|
-
// The
|
|
643
|
-
// lands in the service through the tunnel's phase listener
|
|
644
|
-
//
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
743
|
+
// The plugin-managed tunnels own the public base while one runs: the URL
|
|
744
|
+
// lands in the service through the tunnel's phase listener (the minted
|
|
745
|
+
// quick URL, or the named tunnel's fixed public hostname). The manual
|
|
746
|
+
// publicBaseUrl applies only when no tunnel runs.
|
|
747
|
+
const plan = tunnelPlanOf(value, ctx.webServer.port)
|
|
748
|
+
tunnelMode = plan.mode
|
|
749
|
+
if (plan.mode !== 'quick') {
|
|
750
|
+
// The relay only fronts the quick tunnel; named mode owns its fixed
|
|
751
|
+
// dashboard hostname and the off mode has no public base at all.
|
|
752
|
+
disposeRelayRegistrar()
|
|
753
|
+
if (plan.mode !== 'named') setPublicBase()
|
|
754
|
+
} else if (value.relay === false) {
|
|
755
|
+
// The relay toggle is off: no stable origin, the raw quick URL is the
|
|
756
|
+
// QR base exactly as before the relay existed.
|
|
757
|
+
disposeRelayRegistrar(true)
|
|
758
|
+
setPublicBase()
|
|
759
|
+
} else if (rawTunnelUrl !== undefined) {
|
|
760
|
+
// The relay just turned on (or the registrar is new) while the tunnel
|
|
761
|
+
// already runs: announce now — no phase event will fire for an
|
|
762
|
+
// unchanged target.
|
|
763
|
+
const registrar = ensureRelayRegistrar()
|
|
764
|
+
if (registrar !== undefined) announceRelay(registrar, rawTunnelUrl)
|
|
765
|
+
}
|
|
766
|
+
if (plan.mode === 'quick') {
|
|
767
|
+
for (const ignored of plan.ignored) {
|
|
768
|
+
console.warn(`remote-web-ui: autoTunnel is on — ignoring the configured ${ignored}`)
|
|
649
769
|
}
|
|
650
|
-
|
|
770
|
+
// With the relay on, the connector stamps the stable origin as the
|
|
771
|
+
// origin-side Host (`--http-host-header`): the plugin fence and the
|
|
772
|
+
// harness browser-auth are Host-bound to the relay origin, and the
|
|
773
|
+
// Workers relay cannot control the origin-side Host (fetch forces it
|
|
774
|
+
// to the URL authority). The target identity change is also what
|
|
775
|
+
// restarts a running tunnel on a relay toggle.
|
|
776
|
+
const registrar = ensureRelayRegistrar()
|
|
777
|
+
const originHostHeader = registrar === undefined ? undefined : new URL(registrar.baseUrl).host
|
|
778
|
+
tunnel.start(originHostHeader === undefined
|
|
779
|
+
? plan.targetUrl
|
|
780
|
+
: { kind: 'quick', targetUrl: plan.targetUrl, originHostHeader })
|
|
781
|
+
} else if (plan.mode === 'named') {
|
|
782
|
+
tunnel.start({ kind: 'named', token: plan.token, publicUrl: plan.publicUrl })
|
|
651
783
|
} else {
|
|
652
784
|
tunnel.stop()
|
|
785
|
+
// A named-tunnel token without a usable public hostname cannot serve
|
|
786
|
+
// the QR: stay off and say exactly what is missing instead of running
|
|
787
|
+
// a tunnel nothing points at.
|
|
788
|
+
if (value.tunnelToken !== undefined && value.tunnelToken !== '') {
|
|
789
|
+
console.warn('remote-web-ui: tunnelToken is set but publicBaseUrl is missing or not a valid URL — fill the fixed public hostname of the named tunnel (e.g. https://dsh.example.com) to run it')
|
|
790
|
+
}
|
|
653
791
|
// A malformed public base is ignored with a warning — LAN-only behavior
|
|
654
792
|
// stays intact rather than silently minting unusable QR links.
|
|
655
793
|
if (value.publicBaseUrl !== undefined && !isHttpUrl(value.publicBaseUrl)) {
|
|
@@ -724,13 +862,3 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
724
862
|
})
|
|
725
863
|
sync()
|
|
726
864
|
}
|
|
727
|
-
|
|
728
|
-
/** Whether a configured public base is a parseable http(s) URL with a host. */
|
|
729
|
-
function isHttpUrl(value: string): boolean {
|
|
730
|
-
try {
|
|
731
|
-
const url = new URL(value)
|
|
732
|
-
return (url.protocol === 'http:' || url.protocol === 'https:') && url.hostname !== ''
|
|
733
|
-
} catch {
|
|
734
|
-
return false
|
|
735
|
-
}
|
|
736
|
-
}
|
package/src/pairing.ts
CHANGED
|
@@ -103,6 +103,19 @@ export interface TunnelStatus {
|
|
|
103
103
|
error?: string
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* One relay-registry status frame (undefined while the stable-origin relay
|
|
108
|
+
* is not in play: autoTunnel off, or the relay toggle disabled).
|
|
109
|
+
*/
|
|
110
|
+
export interface RelayStatus {
|
|
111
|
+
/** registering: a sync is in flight; running: the mapping is accepted. */
|
|
112
|
+
state: 'off' | 'registering' | 'running' | 'failed'
|
|
113
|
+
/** The stable relay origin (`https://<id>.dsh-market.com`). */
|
|
114
|
+
url?: string
|
|
115
|
+
/** Human-readable failure detail of the last sync attempt. */
|
|
116
|
+
error?: string
|
|
117
|
+
}
|
|
118
|
+
|
|
106
119
|
/** One snapshot frame pushed to desktop status streams. */
|
|
107
120
|
export interface PairingSnapshot {
|
|
108
121
|
phase: PairingPhase
|
|
@@ -114,6 +127,8 @@ export interface PairingSnapshot {
|
|
|
114
127
|
publicUrl?: string
|
|
115
128
|
/** Auto-tunnel status, while the auto-tunnel feature is active. */
|
|
116
129
|
tunnel?: TunnelStatus
|
|
130
|
+
/** Relay-registry status, while the stable-origin relay is in play. */
|
|
131
|
+
relay?: RelayStatus
|
|
117
132
|
/** Latest /api posture probe (undefined until the first round completes). */
|
|
118
133
|
posture?: PostureSnapshot
|
|
119
134
|
/** Opaque (non-secret) id of the active token (undefined when stopped/lan-required). */
|
|
@@ -202,6 +217,7 @@ export class PairingService {
|
|
|
202
217
|
private publicBase: string | undefined
|
|
203
218
|
/** Auto-tunnel status, while the auto-tunnel feature is active. */
|
|
204
219
|
private tunnelStatus: TunnelStatus | undefined
|
|
220
|
+
private relayStatus: RelayStatus | undefined
|
|
205
221
|
private posture: PostureSnapshot | undefined
|
|
206
222
|
/** True when lastSeenAt changed since the last persist (flushed on sweep). */
|
|
207
223
|
private dirty = false
|
|
@@ -339,6 +355,12 @@ export class PairingService {
|
|
|
339
355
|
this.notify()
|
|
340
356
|
}
|
|
341
357
|
|
|
358
|
+
/** Set or clear the relay-registry status frame (undefined when not in play). */
|
|
359
|
+
setRelayStatus(status: RelayStatus | undefined): void {
|
|
360
|
+
this.relayStatus = status
|
|
361
|
+
this.notify()
|
|
362
|
+
}
|
|
363
|
+
|
|
342
364
|
/** Set the latest /api posture probe result (see posture.ts). */
|
|
343
365
|
setPosture(snapshot: PostureSnapshot | undefined): void {
|
|
344
366
|
this.posture = snapshot
|
|
@@ -497,6 +519,7 @@ export class PairingService {
|
|
|
497
519
|
lanAddresses: [...this.lanBases.keys()],
|
|
498
520
|
...(this.publicBase !== undefined ? { publicUrl: this.publicBase } : {}),
|
|
499
521
|
...(this.tunnelStatus !== undefined ? { tunnel: this.tunnelStatus } : {}),
|
|
522
|
+
...(this.relayStatus !== undefined ? { relay: this.relayStatus } : {}),
|
|
500
523
|
...(this.posture !== undefined ? { posture: this.posture } : {}),
|
|
501
524
|
...(token !== undefined ? { tokenId: token.record.id, tokenExpiresAt: token.record.expiresAt } : {}),
|
|
502
525
|
deviceCount: this.devices.size,
|
|
@@ -588,6 +611,7 @@ function snapshotsEqual(a: PairingSnapshot, b: PairingSnapshot): boolean {
|
|
|
588
611
|
&& sameStrings(a.lanAddresses, b.lanAddresses)
|
|
589
612
|
&& a.publicUrl === b.publicUrl
|
|
590
613
|
&& tunnelEqual(a.tunnel, b.tunnel)
|
|
614
|
+
&& relayEqual(a.relay, b.relay)
|
|
591
615
|
&& a.tokenId === b.tokenId
|
|
592
616
|
&& a.tokenExpiresAt === b.tokenExpiresAt
|
|
593
617
|
&& a.deviceCount === b.deviceCount
|
|
@@ -614,6 +638,12 @@ function tunnelEqual(a: TunnelStatus | undefined, b: TunnelStatus | undefined):
|
|
|
614
638
|
&& a.state === b.state && a.url === b.url && a.error === b.error)
|
|
615
639
|
}
|
|
616
640
|
|
|
641
|
+
/** Relay frame equality (same shape as the tunnel frame). */
|
|
642
|
+
function relayEqual(a: RelayStatus | undefined, b: RelayStatus | undefined): boolean {
|
|
643
|
+
return a === b || (a !== undefined && b !== undefined
|
|
644
|
+
&& a.state === b.state && a.url === b.url && a.error === b.error)
|
|
645
|
+
}
|
|
646
|
+
|
|
617
647
|
/** Element-wise string list equality (interface order is meaningful). */
|
|
618
648
|
function sameStrings(a: readonly string[], b: readonly string[]): boolean {
|
|
619
649
|
return a.length === b.length && a.every((value, index) => value === b[index])
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay registry client: a stable `https://<id>.dsh-market.com` origin in
|
|
3
|
+
* front of the plugin-managed quick tunnel, so a paired phone keeps one
|
|
4
|
+
* bookmark and one cookie context across `dsh web` restarts.
|
|
5
|
+
*
|
|
6
|
+
* The plugin mints an id + secret on first run and persists them per profile
|
|
7
|
+
* under `$DSH_HOME/remote-web-ui-registry/`; every tunnel (re)start
|
|
8
|
+
* re-registers the current quick URL with the market worker. Registration is
|
|
9
|
+
* best-effort: a registry outage never blocks the tunnel — the QR just falls
|
|
10
|
+
* back to the raw quick URL for that session (the pre-relay behavior).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { randomBytes } from 'node:crypto'
|
|
14
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
15
|
+
import { dirname, join } from 'node:path'
|
|
16
|
+
import { dshHome } from './dsh-home.ts'
|
|
17
|
+
|
|
18
|
+
/** The market worker's registration endpoints (dsh-market.com edge API). */
|
|
19
|
+
export const RELAY_REGISTER_URL = 'https://dsh-market.com/api/relay/register'
|
|
20
|
+
export const RELAY_UNREGISTER_URL = 'https://dsh-market.com/api/relay/unregister'
|
|
21
|
+
/**
|
|
22
|
+
* The public origin template: one stable single-label subdomain per
|
|
23
|
+
* registration id. Single label on purpose — Universal SSL covers exactly
|
|
24
|
+
* one subdomain level, so a two-level hostname would get no certificate.
|
|
25
|
+
*/
|
|
26
|
+
export const RELAY_BASE_SUFFIX = '.dsh-market.com'
|
|
27
|
+
|
|
28
|
+
export const RELAY_ID_RE = /^[a-z0-9]{16}$/
|
|
29
|
+
export const RELAY_SECRET_RE = /^[A-Za-z0-9_-]{43}$/
|
|
30
|
+
|
|
31
|
+
/** Persisted per-profile relay identity. */
|
|
32
|
+
export interface RelayIdentity {
|
|
33
|
+
id: string
|
|
34
|
+
secret: string
|
|
35
|
+
/**
|
|
36
|
+
* True only for an identity minted in this process that has never been
|
|
37
|
+
* accepted by the registry: the first register carries `new_secret` so
|
|
38
|
+
* the endpoint may create the row; refreshes authenticate with `secret`
|
|
39
|
+
* alone and must not re-claim an id that already exists.
|
|
40
|
+
*/
|
|
41
|
+
fresh?: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The observable relay lifecycle the settings card renders. */
|
|
45
|
+
export type RelayState =
|
|
46
|
+
| { state: 'off' }
|
|
47
|
+
| { state: 'registering' }
|
|
48
|
+
| { state: 'running'; url: string }
|
|
49
|
+
| { state: 'failed'; error: string }
|
|
50
|
+
|
|
51
|
+
/** Registry storage layout: one file per profile under the relay directory. */
|
|
52
|
+
export function relayIdentityFile(profile: string, home: string = dshHome()): string {
|
|
53
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(profile)) {
|
|
54
|
+
throw new Error(`relay registry: profile ${JSON.stringify(profile)} is not a safe path segment`)
|
|
55
|
+
}
|
|
56
|
+
return join(home, 'remote-web-ui-registry', `${profile}.json`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Generate one identity pair (id: 8-byte hex slug; secret: 32-byte base64url). */
|
|
60
|
+
export function generateRelayIdentity(random: (size: number) => Buffer = randomBytes): RelayIdentity {
|
|
61
|
+
return {
|
|
62
|
+
id: random(8).toString('hex'),
|
|
63
|
+
secret: random(32).toString('base64url'),
|
|
64
|
+
fresh: true,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Load (or lazily mint) the per-profile relay identity. The file is written
|
|
70
|
+
* owner-only on first mint; a corrupt file is reminted (the phone then
|
|
71
|
+
* re-pairs once — the same cost as losing the devices file).
|
|
72
|
+
*/
|
|
73
|
+
export function loadRelayIdentity(profile: string, home: string = dshHome(), random: (size: number) => Buffer = randomBytes): RelayIdentity {
|
|
74
|
+
const file = relayIdentityFile(profile, home)
|
|
75
|
+
try {
|
|
76
|
+
if (existsSync(file)) {
|
|
77
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8')) as Partial<RelayIdentity>
|
|
78
|
+
if (typeof parsed.id === 'string' && RELAY_ID_RE.test(parsed.id)
|
|
79
|
+
&& typeof parsed.secret === 'string' && RELAY_SECRET_RE.test(parsed.secret)) {
|
|
80
|
+
// A persisted identity has certainly been registered before.
|
|
81
|
+
return { id: parsed.id, secret: parsed.secret }
|
|
82
|
+
}
|
|
83
|
+
console.warn(`relay registry: ${file} is malformed — reminting the identity (paired devices must scan the QR once more)`)
|
|
84
|
+
}
|
|
85
|
+
} catch (error) {
|
|
86
|
+
console.warn(`relay registry: could not read ${file} (${error instanceof Error ? error.message : String(error)}) — reminting`)
|
|
87
|
+
}
|
|
88
|
+
const identity = generateRelayIdentity(random)
|
|
89
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
90
|
+
writeFileSync(file, JSON.stringify({ id: identity.id, secret: identity.secret }, null, 2) + '\n', { mode: 0o600 })
|
|
91
|
+
return identity
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Build the registration request body for one identity + tunnel target. */
|
|
95
|
+
function registrationBody(identity: RelayIdentity, target: string): string {
|
|
96
|
+
return JSON.stringify({
|
|
97
|
+
id: identity.id,
|
|
98
|
+
secret: identity.secret,
|
|
99
|
+
new_secret: identity.fresh === true ? identity.secret : undefined,
|
|
100
|
+
target,
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Best-effort removal of the mapping (used on disable; errors are swallowed). */
|
|
105
|
+
export async function unregisterRelay(
|
|
106
|
+
identity: RelayIdentity,
|
|
107
|
+
endpoint: string = RELAY_UNREGISTER_URL,
|
|
108
|
+
fetchFn: typeof fetch = fetch,
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
await fetchFn(endpoint, {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: { 'content-type': 'application/json' },
|
|
113
|
+
body: JSON.stringify({ id: identity.id, secret: identity.secret }),
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The stable relay base for one identity — the origin the QR and the phone
|
|
119
|
+
* bookmark use while (or after) the registration is accepted.
|
|
120
|
+
*/
|
|
121
|
+
export function relayBaseOf(identity: RelayIdentity): string {
|
|
122
|
+
return `https://${identity.id}${RELAY_BASE_SUFFIX}`
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Registration loop with capped exponential backoff. Re-registration happens
|
|
127
|
+
* on every tunnel start (the quick URL can change after a crash-restart);
|
|
128
|
+
* failures retry with backoff and surface the last error to the card.
|
|
129
|
+
*/
|
|
130
|
+
export class RelayRegistrar {
|
|
131
|
+
private timer: NodeJS.Timeout | undefined
|
|
132
|
+
private attempts = 0
|
|
133
|
+
private lastError: string | undefined
|
|
134
|
+
private disposed = false
|
|
135
|
+
|
|
136
|
+
constructor(
|
|
137
|
+
private readonly identity: RelayIdentity,
|
|
138
|
+
private readonly setState: (state: RelayState) => void,
|
|
139
|
+
private readonly options: {
|
|
140
|
+
fetchFn?: typeof fetch
|
|
141
|
+
registerUrl?: string
|
|
142
|
+
unregisterUrl?: string
|
|
143
|
+
baseDelayMs?: number
|
|
144
|
+
maxDelayMs?: number
|
|
145
|
+
timerFn?: (fn: () => void, ms: number) => NodeJS.Timeout
|
|
146
|
+
} = {},
|
|
147
|
+
) {}
|
|
148
|
+
|
|
149
|
+
/** The stable relay base for the QR while registered. */
|
|
150
|
+
get baseUrl(): string {
|
|
151
|
+
return relayBaseOf(this.identity)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The last registration failure detail (undefined while healthy). */
|
|
155
|
+
get error(): string | undefined {
|
|
156
|
+
return this.lastError
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Push one tunnel target; resolves the stable base once accepted. */
|
|
160
|
+
async announce(target: string): Promise<string | undefined> {
|
|
161
|
+
// A disposed registrar stays disposed: a pending retry timer must not
|
|
162
|
+
// resurrect it (re-enabling creates a fresh registrar instead).
|
|
163
|
+
if (this.disposed) return undefined
|
|
164
|
+
if (this.timer !== undefined) {
|
|
165
|
+
clearTimeout(this.timer)
|
|
166
|
+
this.timer = undefined
|
|
167
|
+
}
|
|
168
|
+
this.setState({ state: 'registering' })
|
|
169
|
+
try {
|
|
170
|
+
const fetchFn = this.options.fetchFn ?? fetch
|
|
171
|
+
const response = await fetchFn(this.options.registerUrl ?? RELAY_REGISTER_URL, {
|
|
172
|
+
method: 'PUT',
|
|
173
|
+
headers: { 'content-type': 'application/json' },
|
|
174
|
+
body: registrationBody(this.identity, target),
|
|
175
|
+
})
|
|
176
|
+
if (!response.ok) {
|
|
177
|
+
const detail = await response.json().catch(() => undefined)
|
|
178
|
+
const error = detail && typeof detail.error === 'string' ? detail.error : `HTTP ${String(response.status)}`
|
|
179
|
+
throw new Error(error)
|
|
180
|
+
}
|
|
181
|
+
// The row exists now: never claim a fresh id twice.
|
|
182
|
+
this.identity.fresh = false
|
|
183
|
+
this.attempts = 0
|
|
184
|
+
this.lastError = undefined
|
|
185
|
+
this.setState({ state: 'running', url: this.baseUrl })
|
|
186
|
+
return this.baseUrl
|
|
187
|
+
} catch (error) {
|
|
188
|
+
this.lastError = error instanceof Error ? error.message : String(error)
|
|
189
|
+
if (this.lastError === 'invalid-params' && this.identity.fresh !== true) {
|
|
190
|
+
// The registry lost the row (a D1 restore or manual wipe): re-claim
|
|
191
|
+
// the id with a fresh claim on the next retry. A 403 auth-failed
|
|
192
|
+
// means the id is owned by another secret and stays failed.
|
|
193
|
+
this.identity.fresh = true
|
|
194
|
+
}
|
|
195
|
+
this.setState({ state: 'failed', error: this.lastError })
|
|
196
|
+
this.scheduleRetry(target)
|
|
197
|
+
return undefined
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Best-effort removal of the registry row (the relay toggle turned off). */
|
|
202
|
+
async unregister(): Promise<void> {
|
|
203
|
+
await unregisterRelay(this.identity, this.options.unregisterUrl, this.options.fetchFn)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private scheduleRetry(target: string): void {
|
|
207
|
+
if (this.disposed) return
|
|
208
|
+
const base = this.options.baseDelayMs ?? 5_000
|
|
209
|
+
const max = this.options.maxDelayMs ?? 60_000
|
|
210
|
+
const delay = Math.min(base * 2 ** this.attempts, max)
|
|
211
|
+
this.attempts += 1
|
|
212
|
+
const timerFn = this.options.timerFn ?? ((fn: () => void, ms: number) => {
|
|
213
|
+
const timer = setTimeout(fn, ms)
|
|
214
|
+
timer.unref?.()
|
|
215
|
+
return timer
|
|
216
|
+
})
|
|
217
|
+
this.timer = timerFn(() => {
|
|
218
|
+
this.timer = undefined
|
|
219
|
+
void this.announce(target)
|
|
220
|
+
}, delay)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Stop retrying and clear the card state (tunnel stopped or disabled). */
|
|
224
|
+
dispose(): void {
|
|
225
|
+
this.disposed = true
|
|
226
|
+
if (this.timer !== undefined) {
|
|
227
|
+
clearTimeout(this.timer)
|
|
228
|
+
this.timer = undefined
|
|
229
|
+
}
|
|
230
|
+
this.setState({ state: 'off' })
|
|
231
|
+
}
|
|
232
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure planner for the settings-sync tunnel branch: given the resolved
|
|
3
|
+
* settings slice, decide which tunnel mode runs and with what target. Kept
|
|
4
|
+
* free of cordis types so the precedence matrix is unit-testable without a
|
|
5
|
+
* host context.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Whether a configured public base is a parseable http(s) URL with a host. */
|
|
9
|
+
export function isHttpUrl(value: string): boolean {
|
|
10
|
+
try {
|
|
11
|
+
const url = new URL(value)
|
|
12
|
+
return (url.protocol === 'http:' || url.protocol === 'https:') && url.hostname !== ''
|
|
13
|
+
} catch {
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** How the settings sync should drive the tunnel for one resolved value. */
|
|
19
|
+
export type TunnelPlan =
|
|
20
|
+
| { mode: 'off' }
|
|
21
|
+
| { mode: 'quick'; targetUrl: string; ignored: Array<'tunnelToken' | 'publicBaseUrl'> }
|
|
22
|
+
| { mode: 'named'; token: string; publicUrl: string }
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Decide the tunnel mode and target from one resolved settings value.
|
|
26
|
+
* Precedence: the quick tunnel wins when `autoTunnel` is on (anything else
|
|
27
|
+
* configured is reported as ignored); the named tunnel runs only with a
|
|
28
|
+
* token AND a valid public hostname (the token does not carry the hostname,
|
|
29
|
+
* so the QR base and the fence trust would have no source without it);
|
|
30
|
+
* otherwise no tunnel and the manual public base applies.
|
|
31
|
+
* @param value - the resolved settings slice that drives the tunnel.
|
|
32
|
+
* @param port - the local webServer port the tunnel forwards to.
|
|
33
|
+
*/
|
|
34
|
+
export function tunnelPlanOf(
|
|
35
|
+
value: { autoTunnel?: boolean; tunnelToken?: string; publicBaseUrl?: string },
|
|
36
|
+
port: number,
|
|
37
|
+
): TunnelPlan {
|
|
38
|
+
if (value.autoTunnel === true) {
|
|
39
|
+
const ignored: Array<'tunnelToken' | 'publicBaseUrl'> = []
|
|
40
|
+
if (value.tunnelToken !== undefined && value.tunnelToken !== '') ignored.push('tunnelToken')
|
|
41
|
+
if (value.publicBaseUrl !== undefined && value.publicBaseUrl !== '') ignored.push('publicBaseUrl')
|
|
42
|
+
return { mode: 'quick', targetUrl: `http://127.0.0.1:${String(port)}`, ignored }
|
|
43
|
+
}
|
|
44
|
+
if (value.tunnelToken !== undefined && value.tunnelToken !== '') {
|
|
45
|
+
if (value.publicBaseUrl !== undefined && isHttpUrl(value.publicBaseUrl)) {
|
|
46
|
+
return { mode: 'named', token: value.tunnelToken, publicUrl: value.publicBaseUrl }
|
|
47
|
+
}
|
|
48
|
+
return { mode: 'off' }
|
|
49
|
+
}
|
|
50
|
+
return { mode: 'off' }
|
|
51
|
+
}
|