@linxin666/dsh-remote-web-ui 0.3.24 → 0.4.0
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 +13 -10
- package/README.zh.md +13 -10
- package/icon.svg +3 -0
- package/lib/client.js +263 -129
- package/lib/client.js.map +1 -1
- package/lib/index.js +565 -256
- package/lib/types/client/FenceNotice.d.ts.map +1 -1
- package/lib/types/client/FenceNotice.js +0 -3
- package/lib/types/client/PluginSettingsCard.d.ts +4 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +3 -2
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +14 -9
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +13 -4
- package/lib/types/client/RemoteSettingsCard.d.ts +6 -6
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +14 -8
- package/lib/types/client/UpdateEntry.js +2 -2
- package/lib/types/client/UpdatePanel.js +2 -2
- package/lib/types/client/deep-link.js +2 -2
- package/lib/types/client/index.d.ts +16 -8
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +47 -18
- package/lib/types/client/locales.d.ts +0 -2
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +6 -8
- package/lib/types/client/mobile-adapt.d.ts.map +1 -1
- package/lib/types/client/mobile-adapt.js +103 -22
- package/lib/types/client/pair-api.d.ts +32 -3
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/client/pair-api.js +44 -6
- package/lib/types/client/plugin-card-seat.d.ts +23 -16
- package/lib/types/client/plugin-card-seat.d.ts.map +1 -1
- package/lib/types/client/plugin-card-seat.js +16 -14
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +18 -5
- package/lib/types/client/settings-form.d.ts +17 -17
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +30 -27
- package/lib/types/http.d.ts +18 -1
- package/lib/types/http.d.ts.map +1 -1
- package/lib/types/http.js +22 -1
- package/lib/types/index.d.ts +19 -11
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +91 -76
- package/lib/types/inner-auth.d.ts +3 -2
- package/lib/types/inner-auth.d.ts.map +1 -1
- package/lib/types/inner-auth.js +3 -2
- package/lib/types/pair-grant.d.ts +66 -0
- package/lib/types/pair-grant.d.ts.map +1 -0
- package/lib/types/pair-grant.js +95 -0
- package/lib/types/pairing.d.ts +44 -12
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +69 -20
- package/lib/types/pairing.persist.test.js +26 -0
- package/lib/types/posture.d.ts +11 -3
- package/lib/types/posture.d.ts.map +1 -1
- package/lib/types/posture.js +8 -3
- package/lib/types/remote-api.d.ts +10 -13
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +34 -34
- package/lib/types/remote-channel-boot.d.ts +6 -1
- package/lib/types/remote-channel-boot.d.ts.map +1 -1
- package/lib/types/remote-channel-boot.js +16 -8
- package/lib/types/remote-channel-rules.d.ts +14 -0
- package/lib/types/remote-channel-rules.d.ts.map +1 -1
- package/lib/types/remote-channel-rules.js +8 -0
- package/lib/types/remote-methods.d.ts +1 -1
- package/lib/types/remote-methods.d.ts.map +1 -1
- package/lib/types/remote-methods.js +1 -0
- package/lib/types/routes.d.ts +47 -5
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +160 -58
- package/lib/types/tunnel.d.ts +21 -6
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +58 -11
- package/lib/types/update.d.ts +4 -0
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +74 -26
- package/package.json +23 -20
- package/src/client/FenceNotice.tsx +0 -2
- package/src/client/PluginSettingsCard.tsx +7 -2
- package/src/client/RemoteEntry.tsx +14 -9
- package/src/client/RemotePanel.tsx +17 -7
- package/src/client/RemoteSettingsCard.tsx +16 -9
- package/src/client/UpdateEntry.tsx +2 -2
- package/src/client/UpdatePanel.tsx +3 -3
- package/src/client/deep-link.ts +2 -2
- package/src/client/index.ts +61 -27
- package/src/client/locales.ts +6 -8
- package/src/client/mobile-adapt.ts +97 -20
- package/src/client/pair-api.ts +47 -7
- package/src/client/plugin-card-seat.ts +24 -17
- package/src/client/remote-channel.ts +19 -4
- package/src/client/remote.module.css +38 -0
- package/src/client/settings-form.ts +34 -31
- package/src/http.ts +23 -1
- package/src/index.ts +102 -79
- package/src/inner-auth.ts +3 -2
- package/src/pair-grant.ts +131 -0
- package/src/pairing.persist.test.ts +36 -0
- package/src/pairing.ts +75 -24
- package/src/posture.ts +12 -4
- package/src/remote-api.ts +34 -41
- package/src/remote-channel-boot.ts +16 -8
- package/src/remote-channel-rules.ts +17 -0
- package/src/remote-methods.ts +1 -0
- package/src/routes.ts +165 -57
- package/src/tunnel.ts +56 -11
- package/src/update.ts +74 -24
|
@@ -62,7 +62,11 @@ export function remoteChannelRequired(
|
|
|
62
62
|
* @returns true for localhost, IPv6 loopback, or any 127/8 literal.
|
|
63
63
|
*/
|
|
64
64
|
export function isLoopbackHostname(hostname: string): boolean {
|
|
65
|
-
|
|
65
|
+
// WHATWG location.hostname keeps IPv6 literals bracketed ("[::1]"); the
|
|
66
|
+
// host-side shared predicate accepts that spelling, so this browser copy must
|
|
67
|
+
// too, or an IPv6-loopback origin is judged remote and rewrites every call
|
|
68
|
+
// onto the gated channel where it can never pair.
|
|
69
|
+
if (hostname === 'localhost' || hostname === '::1' || hostname === '[::1]') return true
|
|
66
70
|
const parts = hostname.split('.')
|
|
67
71
|
return parts.length === 4 && parts[0] === '127' && parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
68
72
|
}
|
|
@@ -228,8 +232,16 @@ export function installRemoteChannel(window: ChannelWindow, options: RemoteChann
|
|
|
228
232
|
if (device === null) return init
|
|
229
233
|
const headers = init?.headers
|
|
230
234
|
if (typeof Headers !== 'undefined' && headers instanceof Headers) {
|
|
231
|
-
|
|
232
|
-
|
|
235
|
+
// Copy instead of mutating: the instance belongs to the caller, which may
|
|
236
|
+
// reuse it for a request this channel does not rewrite (the device
|
|
237
|
+
// credential must not ride along there).
|
|
238
|
+
try {
|
|
239
|
+
const copy = new Headers(headers)
|
|
240
|
+
copy.set(RULES.deviceHeader, device)
|
|
241
|
+
return { ...init, headers: copy }
|
|
242
|
+
} catch {
|
|
243
|
+
return init
|
|
244
|
+
}
|
|
233
245
|
}
|
|
234
246
|
if (typeof headers === 'object' && headers !== null) {
|
|
235
247
|
return { ...init, headers: { ...(headers as Record<string, string>), [RULES.deviceHeader]: device } } as RequestInit
|
|
@@ -253,7 +265,10 @@ export function installRemoteChannel(window: ChannelWindow, options: RemoteChann
|
|
|
253
265
|
? rewritten.toString()
|
|
254
266
|
: new Request(rewritten, input)
|
|
255
267
|
return Promise.resolve(originalFetch.call(window, next, attach(init))).then(async (response) => {
|
|
256
|
-
|
|
268
|
+
// The clone tees the response body; only the 403 branch inspects it, so
|
|
269
|
+
// every other status skips the copy (a large upload or session dump
|
|
270
|
+
// would otherwise be duplicated per gated request).
|
|
271
|
+
if (response.status === 403 && await isUnpairedDenied(response.clone())) options.onUnpaired?.()
|
|
257
272
|
else options.onPaired?.()
|
|
258
273
|
return response
|
|
259
274
|
})
|
|
@@ -784,6 +784,44 @@
|
|
|
784
784
|
gap: 4px;
|
|
785
785
|
}
|
|
786
786
|
|
|
787
|
+
/* ── Wide foot row ─────────────────────────────────────────────────────────
|
|
788
|
+
|
|
789
|
+
The official Settings trigger owns a whole row while the footer actions
|
|
790
|
+
stack above it, so the trigger's right half sits empty. On the wide column
|
|
791
|
+
the two share one line (the Settings trigger, then the update and remote
|
|
792
|
+
triggers) and every other foot
|
|
793
|
+
child (the usage glance card, another plugin's block) keeps a full row of
|
|
794
|
+
its own above them. The rail (data-sidebar-collapsed on the frame) keeps
|
|
795
|
+
the shell's stacked foot, where the circles stack by design. The shell owns
|
|
796
|
+
the container classes; the semantic suffixes survive its rebuilds. */
|
|
797
|
+
:global([data-dsh-frame]:not([data-sidebar-collapsed])) [class*='footArea'] {
|
|
798
|
+
flex-direction: row;
|
|
799
|
+
flex-wrap: wrap;
|
|
800
|
+
align-items: center;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
:global([data-dsh-frame]:not([data-sidebar-collapsed])) [class*='footArea'] > :not([class*='settingsArea']):not([class*='footerActions']) {
|
|
804
|
+
flex: 1 1 100%;
|
|
805
|
+
min-width: 0;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
:global([data-dsh-frame]:not([data-sidebar-collapsed])) [class*='settingsArea'] {
|
|
809
|
+
order: 1;
|
|
810
|
+
/* Content-sized basis: the trigger's own width forces it onto the line after
|
|
811
|
+
a full-width sibling, then flex-shrink gives the actions their space. */
|
|
812
|
+
flex: 1 1 auto;
|
|
813
|
+
width: auto;
|
|
814
|
+
min-width: 0;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
:global([data-dsh-frame]:not([data-sidebar-collapsed])) [class*='footerActions'] {
|
|
818
|
+
order: 2;
|
|
819
|
+
flex: none;
|
|
820
|
+
width: auto;
|
|
821
|
+
min-width: 0;
|
|
822
|
+
align-items: center;
|
|
823
|
+
}
|
|
824
|
+
|
|
787
825
|
/* ── update panel extras ─────────────────────────────────────────────── */
|
|
788
826
|
|
|
789
827
|
.updateStatus {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* card-store pattern.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import type {
|
|
11
|
+
import type { ConfigForm, ConfigFormSnapshot } from '@deepseek-ai/dsh-client-ui-settings/client'
|
|
12
12
|
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
|
|
13
13
|
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
|
14
14
|
|
|
@@ -114,7 +114,7 @@ interface PlannedWrite {
|
|
|
114
114
|
judge: (() => boolean) | undefined
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
-
/** One durable write inside the save's atomic
|
|
117
|
+
/** One durable write inside the save's atomic form mutation. */
|
|
118
118
|
export interface BatchedWrite {
|
|
119
119
|
/** Field this entry writes. */
|
|
120
120
|
field: string
|
|
@@ -211,34 +211,34 @@ export class CardForm<T> {
|
|
|
211
211
|
private readonly specs: Map<string, FieldSpec>
|
|
212
212
|
private readonly staged = new Map<string, StagedEdit>()
|
|
213
213
|
private readonly listeners = new Set<() => void>()
|
|
214
|
-
/** The
|
|
215
|
-
private readonly
|
|
214
|
+
/** The form subscription installed in the constructor; released by dispose(). */
|
|
215
|
+
private readonly disposeForm: () => void
|
|
216
216
|
private disposed = false
|
|
217
217
|
private saving = false
|
|
218
218
|
private failed = false
|
|
219
219
|
private failedReason: string | undefined
|
|
220
220
|
|
|
221
|
-
/** @param scope - the bound
|
|
221
|
+
/** @param scope - the bound configuration form for this card's namespace. */
|
|
222
222
|
constructor(
|
|
223
|
-
private readonly scope:
|
|
223
|
+
private readonly scope: ConfigForm<T>,
|
|
224
224
|
specs: FieldSpec[],
|
|
225
225
|
) {
|
|
226
226
|
this.specs = new Map(specs.map(spec => [spec.field, spec]))
|
|
227
|
-
this.
|
|
227
|
+
this.disposeForm = scope.subscribe(() => { this.publish() })
|
|
228
228
|
}
|
|
229
229
|
|
|
230
230
|
/**
|
|
231
|
-
* Release the
|
|
231
|
+
* Release the form subscription and every bound store listener. The card
|
|
232
232
|
* must call this on teardown; later calls are no-ops.
|
|
233
233
|
*/
|
|
234
234
|
dispose(): void {
|
|
235
235
|
if (this.disposed) return
|
|
236
236
|
this.disposed = true
|
|
237
|
-
this.
|
|
237
|
+
this.disposeForm()
|
|
238
238
|
this.listeners.clear()
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
-
/** Publish a projection of this form, rebuilt whenever the
|
|
241
|
+
/** Publish a projection of this form, rebuilt whenever the form or a draft changes. */
|
|
242
242
|
bind<S>(project: () => S): SnapshotStore<S> {
|
|
243
243
|
const store = createSnapshotStore(project())
|
|
244
244
|
this.listeners.add(() => { store.set(project()) })
|
|
@@ -295,19 +295,19 @@ export class CardForm<T> {
|
|
|
295
295
|
}
|
|
296
296
|
|
|
297
297
|
/**
|
|
298
|
-
* Write every staged edit in one atomic
|
|
298
|
+
* Write every staged edit in one atomic form mutation, then re-seed from
|
|
299
299
|
* what the Host accepted.
|
|
300
300
|
*
|
|
301
301
|
* The whole batch rides one mutate, so cross-field validate hooks
|
|
302
302
|
* (baseURL+model) judge it as a unit: the Host either applies every write
|
|
303
|
-
* or refuses the batch. The
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
* settled snapshot back
|
|
307
|
-
* fails the whole save. A
|
|
308
|
-
*
|
|
309
|
-
* message. A save that did not land keeps its drafts, so
|
|
310
|
-
* correct them instead of retyping.
|
|
303
|
+
* or refuses the batch. The form contract answers a refusal or a skipped
|
|
304
|
+
* write with `false` (it recovers with a fresh Host view instead of
|
|
305
|
+
* throwing), so the outcome is judged twice: the answer itself, and then the
|
|
306
|
+
* settled snapshot read back one planned write at a time. One missed write
|
|
307
|
+
* fails the whole save. A transport that rejects instead (the dsh-web bridge
|
|
308
|
+
* controller on a dead connection) reports through the same failure path
|
|
309
|
+
* with its rejection message. A save that did not land keeps its drafts, so
|
|
310
|
+
* the user can correct them instead of retyping.
|
|
311
311
|
* @returns settlement after the mutation and the read-back.
|
|
312
312
|
*/
|
|
313
313
|
async save(): Promise<void> {
|
|
@@ -323,30 +323,33 @@ export class CardForm<T> {
|
|
|
323
323
|
this.failed = false
|
|
324
324
|
this.failedReason = undefined
|
|
325
325
|
this.publish()
|
|
326
|
-
// One atomic namespace mutation: the
|
|
327
|
-
//
|
|
326
|
+
// One atomic namespace mutation: the form contract takes ordered path
|
|
327
|
+
// operations, so the whole staged batch is validated, persisted, and
|
|
328
328
|
// recovered together — either every write lands or none does.
|
|
329
329
|
const ops: Array<{ op: 'set'; path: string[]; value: string | number | boolean } | { op: 'unset'; path: string[] }> = valid.map(item => item.op.op === 'set'
|
|
330
330
|
? { op: 'set', path: [item.field], value: (item.op as { value: string | number | boolean }).value }
|
|
331
331
|
: { op: 'unset', path: [item.field] })
|
|
332
332
|
let failedReason: string | undefined
|
|
333
|
+
let accepted = false
|
|
333
334
|
try {
|
|
334
|
-
await this.scope.mutate(ops)
|
|
335
|
+
accepted = await this.scope.mutate(ops)
|
|
335
336
|
} catch (error) {
|
|
336
337
|
failedReason = error instanceof Error ? error.message : String(error)
|
|
337
338
|
}
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
// judge every planned write against the settled
|
|
341
|
-
// atomic, so one missed write fails the
|
|
342
|
-
|
|
339
|
+
// `false` is the contract's refusal/skip answer, and the form still
|
|
340
|
+
// resolves after a refusal (it recovers with a fresh view), so the answer
|
|
341
|
+
// alone is not enough: judge every planned write against the settled
|
|
342
|
+
// snapshot as well. The mutation is atomic, so one missed write fails the
|
|
343
|
+
// whole save and keeps the drafts.
|
|
344
|
+
const landed = accepted && failedReason === undefined && valid.every(item => item.judge())
|
|
343
345
|
for (const [field, before] of pending) {
|
|
344
346
|
if (landed && this.staged.get(field) === before) this.staged.delete(field)
|
|
345
347
|
}
|
|
346
348
|
this.saving = false
|
|
347
349
|
this.failed = !landed
|
|
348
|
-
// A
|
|
349
|
-
// generic failure copy; a rejecting
|
|
350
|
+
// A refused or unlanded batch carries no server reason: the card surfaces
|
|
351
|
+
// its generic failure copy; a rejecting transport (the bridge) adds its
|
|
352
|
+
// message.
|
|
350
353
|
this.failedReason = failedReason
|
|
351
354
|
this.publish()
|
|
352
355
|
}
|
|
@@ -381,7 +384,7 @@ export class CardForm<T> {
|
|
|
381
384
|
*/
|
|
382
385
|
private landedSet(field: string, value: unknown): boolean {
|
|
383
386
|
// A redacted secret never appears in any wire view layer: the Host strips
|
|
384
|
-
// role('secret') fields and reports them through a sidecar the
|
|
387
|
+
// role('secret') fields and reports them through a sidecar the form
|
|
385
388
|
// snapshot does not expose, so there is nothing to compare the draft
|
|
386
389
|
// against. Settling is the only signal the form has; the rest of the
|
|
387
390
|
// batch, when one exists, still carries the atomic verdict by read-back.
|
|
@@ -412,7 +415,7 @@ export class CardForm<T> {
|
|
|
412
415
|
return spec
|
|
413
416
|
}
|
|
414
417
|
|
|
415
|
-
private snapshotOf():
|
|
418
|
+
private snapshotOf(): ConfigFormSnapshot<T> {
|
|
416
419
|
return this.scope.getSnapshot()
|
|
417
420
|
}
|
|
418
421
|
|
package/src/http.ts
CHANGED
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Shared JSON body/response helpers for the host route families: one strict
|
|
4
4
|
* bounded body reader, one lenient bounded body reader, one JSON object
|
|
5
|
-
* narrow,
|
|
5
|
+
* narrow, one JSON writer, and the outbound request-init helper that keeps a
|
|
6
|
+
* host fetch body decodable (withIdentityEncoding). Previously the readers
|
|
7
|
+
* were copy-pasted across the
|
|
6
8
|
* package route files (routes.ts, update-routes.ts, mobile-api.ts, and each
|
|
7
9
|
* family's route module) with drifting contracts: body caps ranging 4 KiB to
|
|
8
10
|
* 1 MiB and four distinct overflow behaviors (reject, undefined, null, throw).
|
|
@@ -88,6 +90,26 @@ export function asJsonObject(value: unknown): Record<string, unknown> | undefine
|
|
|
88
90
|
return isJsonObject(value) ? value : undefined
|
|
89
91
|
}
|
|
90
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Request init that asks a remote origin for an uncompressed body.
|
|
95
|
+
*
|
|
96
|
+
* The DSH host boots `@deepseek-ai/dsh-http-proxy`, whose top-level import of
|
|
97
|
+
* the npm `undici` copy replaces the legacy `undici.globalDispatcher.1` slot
|
|
98
|
+
* Node's built-in `fetch()` reads. That cross-major wrapper drops the
|
|
99
|
+
* `content-encoding` header and automatic decompression, so a plain fetch
|
|
100
|
+
* resolves to raw gzip/brotli/zstd bytes — compressed noise no JSON or text
|
|
101
|
+
* consumer can read. Every host fetch that parses a remote body must request
|
|
102
|
+
* identity encoding. Caller headers survive; `accept-encoding` is forced to
|
|
103
|
+
* `identity` because a caller value has no decoder behind it in this host.
|
|
104
|
+
* @param init - the caller's request init (signal, headers, method, ...).
|
|
105
|
+
* @returns a copy with `accept-encoding: identity` merged in.
|
|
106
|
+
*/
|
|
107
|
+
export function withIdentityEncoding(init: RequestInit = {}): RequestInit {
|
|
108
|
+
const headers = new Headers(init.headers)
|
|
109
|
+
headers.set('accept-encoding', 'identity')
|
|
110
|
+
return { ...init, headers }
|
|
111
|
+
}
|
|
112
|
+
|
|
91
113
|
/**
|
|
92
114
|
* Write one JSON response. Default headers are the family defaults
|
|
93
115
|
* (content-type and referrer-policy); caller headers are appended or
|
package/src/index.ts
CHANGED
|
@@ -36,6 +36,7 @@ import { isHttpUrl, tunnelPlanOf } from './tunnel-plan.ts'
|
|
|
36
36
|
import { loadRelayIdentity, RelayRegistrar, type RelayState } from './relay-registry.ts'
|
|
37
37
|
import { desiredBindHost, desiredBindPort, firewallActionNeeded, pendingRestartOf, type AppliedFirewallState, type StartupFacts } from './lan-bind-plan.ts'
|
|
38
38
|
import { createInnerAuth } from './inner-auth.ts'
|
|
39
|
+
import { withIdentityEncoding } from './http.ts'
|
|
39
40
|
import { TunnelManager, type TunnelInfo } from './tunnel.ts'
|
|
40
41
|
import { PublicBaseKeeper } from './public-base.ts'
|
|
41
42
|
import {
|
|
@@ -84,9 +85,12 @@ export const name = 'remote-web-ui'
|
|
|
84
85
|
export const inject = ['webServer', 'typertGateway', 'connection']
|
|
85
86
|
|
|
86
87
|
/**
|
|
87
|
-
* Settings namespace of the remote-control capability
|
|
88
|
-
* settings
|
|
89
|
-
*
|
|
88
|
+
* Settings namespace of the remote-control capability. Under the 0.1.7
|
|
89
|
+
* settings model the namespace IS the Host profile entry id, so this is also
|
|
90
|
+
* the id the browser half asks `ctx.configForms` for (the family binder
|
|
91
|
+
* resolves the same value to the owning entry). Spelled here rather than
|
|
92
|
+
* imported: the browser half spells the same value and must not depend on a
|
|
93
|
+
* Host package.
|
|
90
94
|
*/
|
|
91
95
|
export const REMOTE_WEB_UI_SETTINGS_NAMESPACE = 'remote-web-ui' as SettingsNamespace
|
|
92
96
|
|
|
@@ -100,22 +104,28 @@ export interface Config {
|
|
|
100
104
|
maxDevices?: number
|
|
101
105
|
/**
|
|
102
106
|
* Idle sessions older than this (ms) are deleted from memory and disk.
|
|
103
|
-
* Default is
|
|
107
|
+
* Default is 30 days; a leftover cookie no longer authorizes after expiry.
|
|
104
108
|
*/
|
|
105
109
|
idleExpireMs?: number
|
|
106
110
|
/** Cookie name carrying the paired device id. */
|
|
107
111
|
cookieName?: string
|
|
108
112
|
/**
|
|
109
113
|
* When true (default), a desktop Web GUI opened at a non-loopback origin
|
|
110
|
-
* rides the gated `/remote/api` channel
|
|
111
|
-
* cookie — the QR is the only way into remote desktop, and
|
|
112
|
-
* cut the
|
|
114
|
+
* rides the gated `/remote/api` channel, whose requests must carry a live
|
|
115
|
+
* paired-device cookie — the QR is the only way into remote desktop, and
|
|
116
|
+
* stop()/revoke() cut the channel and the pairing cookie off immediately.
|
|
117
|
+
* This policy selects the transport; it never loosens the channel, whose
|
|
118
|
+
* gate is unconditional (issue #1665). Scope
|
|
113
119
|
* note for this cohort: direct /api is governed by the harness fence +
|
|
114
120
|
* browser-auth cookie (the api/gate seam has no emitter on 0.1.2-alpha.2),
|
|
115
121
|
* so a harness browser credential a device has already redeemed is not
|
|
116
122
|
* invalidated by stop() — see the README security model. Set false to keep
|
|
117
123
|
* the desktop on plain `/api` (only useful when that origin is already
|
|
118
|
-
* trusted for `/api`).
|
|
124
|
+
* trusted for `/api`). Turning the policy off does NOT open the channel:
|
|
125
|
+
* the /remote proxy attaches the process's own browser-auth credential only
|
|
126
|
+
* when the request itself presents a live paired-device credential, so an
|
|
127
|
+
* unpaired LAN/tunnel caller is forwarded without it and the inner route
|
|
128
|
+
* answers 401.
|
|
119
129
|
*/
|
|
120
130
|
requirePairingForLan?: boolean
|
|
121
131
|
/**
|
|
@@ -220,6 +230,13 @@ export const Config: z<Config> = z.object({
|
|
|
220
230
|
/** Presence sweep cadence (a stale device flips to disconnected within two sweeps). */
|
|
221
231
|
const SWEEP_INTERVAL_MS = 10_000
|
|
222
232
|
|
|
233
|
+
/**
|
|
234
|
+
* How long one update-status probe answer is reused. The probe fans out one
|
|
235
|
+
* registry GET per family package plus a GitHub call, and the sidebar update
|
|
236
|
+
* entry asks for the status on every GUI page load.
|
|
237
|
+
*/
|
|
238
|
+
const UPDATE_STATUS_TTL_MS = 60_000
|
|
239
|
+
|
|
223
240
|
/**
|
|
224
241
|
* Fully resolved config: every field non-optional except `publicBaseUrl`,
|
|
225
242
|
* which legitimately resolves to `undefined` when unset (the schema keeps it
|
|
@@ -237,9 +254,8 @@ type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl' | 'trustedHosts' | '
|
|
|
237
254
|
|
|
238
255
|
/**
|
|
239
256
|
* The single mapping from resolved plugin config to the pairing service
|
|
240
|
-
* config. Both the constructed service and every
|
|
241
|
-
* it, so no field can be silently dropped
|
|
242
|
-
* pushes a new value into the running service.
|
|
257
|
+
* config. Both the constructed service and every later apply of this row
|
|
258
|
+
* reuse it, so no field can be silently dropped.
|
|
243
259
|
*/
|
|
244
260
|
export function pairingConfigOf(resolved: Pick<
|
|
245
261
|
ResolvedConfig,
|
|
@@ -305,30 +321,12 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
305
321
|
profile: config?.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
|
|
306
322
|
enabled: config?.enabled ?? DEFAULTS.enabled,
|
|
307
323
|
}
|
|
308
|
-
// The
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
return {
|
|
315
|
-
tokenTtlMs: value.tokenTtlMs ?? DEFAULTS.tokenTtlMs,
|
|
316
|
-
offlineAfterMs: value.offlineAfterMs ?? DEFAULTS.offlineAfterMs,
|
|
317
|
-
maxDevices: value.maxDevices ?? DEFAULTS.maxDevices,
|
|
318
|
-
idleExpireMs: value.idleExpireMs ?? DEFAULTS.idleExpireMs,
|
|
319
|
-
cookieName: value.cookieName ?? DEFAULTS.cookieName,
|
|
320
|
-
requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
|
|
321
|
-
publicBaseUrl: value.publicBaseUrl ?? envPublicBase,
|
|
322
|
-
trustedHosts: value.trustedHosts,
|
|
323
|
-
devicesFile: value.devicesFile ?? DEFAULTS.devicesFile,
|
|
324
|
-
autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
|
|
325
|
-
tunnelToken: value.tunnelToken,
|
|
326
|
-
relay: value.relay ?? DEFAULTS.relay,
|
|
327
|
-
lanBind: value.lanBind,
|
|
328
|
-
profile: value.profile ?? process.env.DSH_PROFILE ?? DEFAULTS.profile,
|
|
329
|
-
enabled: value.enabled ?? DEFAULTS.enabled,
|
|
330
|
-
}
|
|
331
|
-
}
|
|
324
|
+
// The effective configuration this activation runs on: under the 0.1.7
|
|
325
|
+
// settings model the plugin's own Config IS its settings document, so the
|
|
326
|
+
// Host serves the settings page from this schema and reloads the row after a
|
|
327
|
+
// save — a live edit re-enters apply() with the new value instead of being
|
|
328
|
+
// pushed into a running instance.
|
|
329
|
+
const resolve = (): ResolvedConfig => resolved
|
|
332
330
|
const service = new PairingService(pairingConfigOf(resolved))
|
|
333
331
|
|
|
334
332
|
// ── auto tunnel ─────────────────────────────────────────────────────────
|
|
@@ -442,13 +440,14 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
442
440
|
service.setLanBases(lanBases)
|
|
443
441
|
}
|
|
444
442
|
|
|
445
|
-
// Push
|
|
443
|
+
// Push the effective configuration into the service and gate. The service
|
|
446
444
|
// config object is read per operation (token mint, touch, sweep), and the
|
|
447
|
-
// gate re-reads its fence flag per request, so a
|
|
448
|
-
// without a restart
|
|
449
|
-
// sweep timer are dropped and all
|
|
450
|
-
// gate listener stays mounted so a
|
|
451
|
-
// (now vetoing every non-loopback
|
|
445
|
+
// gate re-reads its fence flag per request, so a saved edit takes effect
|
|
446
|
+
// without a restart (the Host reloads this row, and apply re-runs). When
|
|
447
|
+
// `enabled` is off, the pairing routes and sweep timer are dropped and all
|
|
448
|
+
// device/token state is revoked, but the gate listener stays mounted so a
|
|
449
|
+
// LAN-exposed /api stays behind pairing (now vetoing every non-loopback
|
|
450
|
+
// request) instead of opening the fence.
|
|
452
451
|
let disposeRoutes: (() => void) | undefined
|
|
453
452
|
let disposeSweep: (() => void) | undefined
|
|
454
453
|
// ── remote update ────────────────────────────────────────────────────────
|
|
@@ -478,16 +477,32 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
478
477
|
releaseNotesCache.set(version, { at: Date.now(), notes })
|
|
479
478
|
return notes
|
|
480
479
|
}
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
480
|
+
// The status probe fans out one registry GET per family package (plus a
|
|
481
|
+
// GitHub release-notes call), and the sidebar update entry asks for it on
|
|
482
|
+
// every GUI page load — so the answer is memoized briefly and concurrent
|
|
483
|
+
// callers share one probe. A completed update invalidates it.
|
|
484
|
+
let updateStatusCache: { at: number; value: Awaited<ReturnType<typeof checkUpdates>> } | undefined
|
|
485
|
+
let updateStatusInFlight: Promise<Awaited<ReturnType<typeof checkUpdates>>> | undefined
|
|
486
|
+
const checkStatusCached = (): Promise<Awaited<ReturnType<typeof checkUpdates>>> => {
|
|
487
|
+
const cached = updateStatusCache
|
|
488
|
+
if (cached !== undefined && Date.now() - cached.at < UPDATE_STATUS_TTL_MS) return Promise.resolve(cached.value)
|
|
489
|
+
if (updateStatusInFlight !== undefined) return updateStatusInFlight
|
|
490
|
+
updateStatusInFlight = checkUpdates({
|
|
486
491
|
anchorManifestPath: resolveAnchorPath(),
|
|
487
492
|
resolve: hostResolve,
|
|
488
493
|
fetchLatest: name => fetchLatestVersion(name, fetch),
|
|
489
494
|
fetchReleaseNotes: fetchReleaseNotesCached,
|
|
490
|
-
})
|
|
495
|
+
}).then((value) => {
|
|
496
|
+
updateStatusCache = { at: Date.now(), value }
|
|
497
|
+
return value
|
|
498
|
+
}).finally(() => { updateStatusInFlight = undefined })
|
|
499
|
+
return updateStatusInFlight
|
|
500
|
+
}
|
|
501
|
+
const updateRoutes = makeUpdateRoutes({
|
|
502
|
+
// Control endpoints are host-surface only: a LAN/phone origin must never
|
|
503
|
+
// trigger a real install on this machine.
|
|
504
|
+
fence: request => isTrustedApiRequest(request, []),
|
|
505
|
+
check: () => checkStatusCached(),
|
|
491
506
|
run: async (): Promise<UpdateRunResult> => {
|
|
492
507
|
const target = resolveUpdateTarget({ anchorManifestPath: resolveAnchorPath() })
|
|
493
508
|
if ('error' in target) {
|
|
@@ -504,7 +519,7 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
504
519
|
// 11 minimumReleaseAge gate can silently keep the installed versions
|
|
505
520
|
// (same-day releases), which a plain exit-0 check would report as
|
|
506
521
|
// success — the user then restarts and nothing changed.
|
|
507
|
-
|
|
522
|
+
const result = await runUpdateVerified({
|
|
508
523
|
run: { profileDir: target.profileDir, packages: target.packages },
|
|
509
524
|
check: {
|
|
510
525
|
anchorManifestPath: resolveAnchorPath(),
|
|
@@ -513,6 +528,9 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
513
528
|
fetchReleaseNotes: fetchReleaseNotesCached,
|
|
514
529
|
},
|
|
515
530
|
})
|
|
531
|
+
// The install moved (or failed) the versions the cached status reported.
|
|
532
|
+
updateStatusCache = undefined
|
|
533
|
+
return result
|
|
516
534
|
},
|
|
517
535
|
})
|
|
518
536
|
// LAN-bind facts for the settings card, re-read per request so a hot
|
|
@@ -580,10 +598,16 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
580
598
|
if (appShellCache !== undefined && Date.now() - appShellCache.at < APP_SHELL_TTL_MS) return appShellCache.html
|
|
581
599
|
const cookie = await innerAuth.ready()
|
|
582
600
|
try {
|
|
583
|
-
const response = await fetch(`http://127.0.0.1:${String(ctx.webServer.port)}/`, {
|
|
601
|
+
const response = await fetch(`http://127.0.0.1:${String(ctx.webServer.port)}/`, withIdentityEncoding({
|
|
584
602
|
headers: cookie !== undefined ? { cookie } : undefined,
|
|
585
|
-
})
|
|
586
|
-
if (!response.ok)
|
|
603
|
+
}))
|
|
604
|
+
if (!response.ok) {
|
|
605
|
+
// A stale credential (secret rotation, 30-day TTL) must not wedge every
|
|
606
|
+
// later landing: drop it so the next attempt re-redeems, exactly as the
|
|
607
|
+
// proxy path does on an upstream 401.
|
|
608
|
+
if (response.status === 401 || response.status === 403) innerAuth.invalidate()
|
|
609
|
+
return undefined
|
|
610
|
+
}
|
|
587
611
|
const html = await response.text()
|
|
588
612
|
appShellCache = { at: Date.now(), html }
|
|
589
613
|
return html
|
|
@@ -618,15 +642,15 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
618
642
|
return list
|
|
619
643
|
},
|
|
620
644
|
}),
|
|
621
|
-
// The remote desktop channel:
|
|
622
|
-
// re-issues fenced paths to loopback (see remote-api.ts). The
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
//
|
|
645
|
+
// The remote desktop channel: a paired-credential-gated `/remote` prefix
|
|
646
|
+
// that re-issues fenced paths to loopback (see remote-api.ts). The gate is
|
|
647
|
+
// unconditional — requirePairingForLan governs the plain /api surface and
|
|
648
|
+
// the desktop's client-side rewrite only; it is never an authorization
|
|
649
|
+
// input here, because this channel attaches the process's own browser
|
|
650
|
+
// credential to everything it forwards (issue #1665).
|
|
626
651
|
...makeRemoteApiRoutes({
|
|
627
652
|
service,
|
|
628
653
|
port: ctx.webServer.port,
|
|
629
|
-
requirePairingForLan: () => resolve().requirePairingForLan,
|
|
630
654
|
auth: innerAuth,
|
|
631
655
|
}),
|
|
632
656
|
...updateRoutes,
|
|
@@ -634,7 +658,6 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
634
658
|
const upgrades = makeRemoteApiUpgradeRoutes({
|
|
635
659
|
service,
|
|
636
660
|
port: ctx.webServer.port,
|
|
637
|
-
requirePairingForLan: () => resolve().requirePairingForLan,
|
|
638
661
|
auth: innerAuth,
|
|
639
662
|
})
|
|
640
663
|
const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled)
|
|
@@ -718,6 +741,9 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
718
741
|
console.warn('remote-web-ui: LAN-exposed bind — pairing gates the /remote channel; direct /api stays under the harness fence + browser auth (stop() does not revoke an already-redeemed browser credential)')
|
|
719
742
|
}
|
|
720
743
|
|
|
744
|
+
// Apply the effective configuration to the running surfaces once per
|
|
745
|
+
// activation: the service tunables, the LAN-bind block/firewall, the tunnel
|
|
746
|
+
// plan, the pairing routes, the presence sweep, and the posture probe.
|
|
721
747
|
const sync = (): void => {
|
|
722
748
|
const value = resolve()
|
|
723
749
|
service.config = pairingConfigOf(value)
|
|
@@ -805,6 +831,10 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
805
831
|
? plan.targetUrl
|
|
806
832
|
: { kind: 'quick', targetUrl: plan.targetUrl, originHostHeader })
|
|
807
833
|
} else if (plan.mode === 'named') {
|
|
834
|
+
// The hostname is fixed and known before the process runs, so publish it
|
|
835
|
+
// now: a named tunnel that fails to start must not leave the previous
|
|
836
|
+
// mode's ephemeral host as the QR and fence base.
|
|
837
|
+
publicBase.markRunning(plan.publicUrl)
|
|
808
838
|
tunnel.start({ kind: 'named', token: plan.token, publicUrl: plan.publicUrl })
|
|
809
839
|
} else {
|
|
810
840
|
tunnel.stop()
|
|
@@ -824,7 +854,17 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
824
854
|
}
|
|
825
855
|
}
|
|
826
856
|
const enabled = value.enabled
|
|
827
|
-
if (!enabled)
|
|
857
|
+
if (!enabled) {
|
|
858
|
+
service.stop()
|
|
859
|
+
// The tunnel branch above follows the plan, not the master switch, so an
|
|
860
|
+
// explicit "off" must also drop the public ingress it started: otherwise
|
|
861
|
+
// the cloudflared child keeps a live public URL (and the relay keeps its
|
|
862
|
+
// stable row) while the panel says remote control is stopped. The relay
|
|
863
|
+
// row is deliberately kept so re-enabling reuses the same origin.
|
|
864
|
+
tunnel.stop()
|
|
865
|
+
disposeRelayRegistrar()
|
|
866
|
+
publicBase.reset()
|
|
867
|
+
}
|
|
828
868
|
if (disposeRoutes === undefined && enabled) {
|
|
829
869
|
disposeRoutes = ctx.effect(
|
|
830
870
|
() => {
|
|
@@ -877,25 +917,8 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
877
917
|
table.push({ kind: 'script', placement: 'head', text: REMOTE_CHANNEL_BOOT_SCRIPT })
|
|
878
918
|
}), 'remote-web-ui: remote channel boot patch')
|
|
879
919
|
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
settingsCtx.settings.installSection(ctx, REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, config ?? {}, {
|
|
884
|
-
setSource: (source) => {
|
|
885
|
-
current = source
|
|
886
|
-
sync()
|
|
887
|
-
},
|
|
888
|
-
onChange: sync,
|
|
889
|
-
})
|
|
890
|
-
} else if (typeof settingsCtx.settings?.register === 'function') {
|
|
891
|
-
const scope = settingsCtx.settings.register(REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, { base: config ?? {} })
|
|
892
|
-
current = () => scope?.get?.() ?? (config ?? {})
|
|
893
|
-
scope?.watch?.(() => { sync() })
|
|
894
|
-
sync()
|
|
895
|
-
}
|
|
896
|
-
} catch {
|
|
897
|
-
// Defensive fallback against settings registration differences
|
|
898
|
-
}
|
|
899
|
-
})
|
|
920
|
+
// No settings registration: the Host derives this plugin's settings page
|
|
921
|
+
// from the exported `Config` schema and reloads the row after a save, so
|
|
922
|
+
// every write re-enters apply() with the new config (see `resolve`).
|
|
900
923
|
sync()
|
|
901
924
|
}
|
package/src/inner-auth.ts
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* therefore redeems its own launch token (the same one-time exchange every
|
|
8
8
|
* paired device's first navigation performs) and attaches the resulting
|
|
9
9
|
* cookie to inner requests. The credential is only ever exercised behind the
|
|
10
|
-
* pairing gate:
|
|
11
|
-
*
|
|
10
|
+
* pairing gate: the `/remote` channel always requires a live paired-device
|
|
11
|
+
* credential before this module is consulted, regardless of the
|
|
12
|
+
* requirePairingForLan LAN policy (issue #1665).
|
|
12
13
|
*/
|
|
13
14
|
|
|
14
15
|
/** Browser-auth credential the proxy attaches to loopback-bound requests. */
|