@linxin666/dsh-remote-web-ui 0.2.3 → 0.2.5
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 +148 -77
- package/README.zh.md +32 -26
- package/lib/client.js +610 -74
- package/lib/client.js.map +1 -1
- package/lib/index.js +676 -35
- package/lib/mobile.js +10 -7
- package/lib/mobile.js.map +1 -1
- package/lib/types/client/FenceNotice.d.ts +21 -0
- package/lib/types/client/FenceNotice.d.ts.map +1 -0
- package/lib/types/client/FenceNotice.js +10 -0
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +18 -11
- package/lib/types/client/RemotePanel.d.ts +10 -4
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +7 -6
- package/lib/types/client/RemoteSettingsCard.d.ts +4 -0
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +3 -1
- package/lib/types/client/device-name.d.ts +3 -0
- package/lib/types/client/device-name.d.ts.map +1 -0
- package/lib/types/client/device-name.js +30 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +48 -0
- package/lib/types/client/locales.d.ts +51 -1
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +83 -33
- package/lib/types/client/pair-api.d.ts +35 -0
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/client/pair-api.js +39 -0
- package/lib/types/client/remote-channel.d.ts +93 -0
- package/lib/types/client/remote-channel.d.ts.map +1 -0
- package/lib/types/client/remote-channel.js +246 -0
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +9 -6
- package/lib/types/dsh-home.d.ts +17 -0
- package/lib/types/dsh-home.d.ts.map +1 -0
- package/lib/types/dsh-home.js +34 -0
- package/lib/types/gate.d.ts.map +1 -1
- package/lib/types/gate.js +5 -2
- package/lib/types/index.d.ts +19 -12
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +73 -5
- package/lib/types/loopback-proxy.d.ts +28 -0
- package/lib/types/loopback-proxy.d.ts.map +1 -0
- package/lib/types/loopback-proxy.js +112 -0
- package/lib/types/mobile/views/SessionListView.d.ts.map +1 -1
- package/lib/types/mobile/views/SessionListView.js +6 -1
- package/lib/types/mobile-api.d.ts.map +1 -1
- package/lib/types/mobile-api.js +6 -2
- package/lib/types/mobile-routes.js +1 -1
- package/lib/types/pairing.d.ts +63 -11
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +137 -20
- package/lib/types/pairing.persist.test.js +60 -5
- package/lib/types/posture.d.ts +75 -0
- package/lib/types/posture.d.ts.map +1 -0
- package/lib/types/posture.js +116 -0
- package/lib/types/remote-api.d.ts +60 -0
- package/lib/types/remote-api.d.ts.map +1 -0
- package/lib/types/remote-api.js +181 -0
- package/lib/types/remote-methods.d.ts +33 -0
- package/lib/types/remote-methods.d.ts.map +1 -0
- package/lib/types/remote-methods.js +54 -0
- package/lib/types/routes.d.ts +4 -0
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +33 -4
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +10 -2
- package/package.json +13 -13
- package/src/client/FenceNotice.tsx +43 -0
- package/src/client/RemoteEntry.tsx +19 -9
- package/src/client/RemotePanel.tsx +83 -10
- package/src/client/RemoteSettingsCard.tsx +16 -0
- package/src/client/device-name.ts +31 -0
- package/src/client/index.ts +46 -0
- package/src/client/locales.ts +83 -33
- package/src/client/pair-api.ts +60 -0
- package/src/client/remote-channel.ts +269 -0
- package/src/client/remote.module.css +240 -16
- package/src/client/settings-form.ts +8 -6
- package/src/dsh-home.ts +36 -0
- package/src/gate.ts +5 -2
- package/src/index.ts +89 -17
- package/src/loopback-proxy.ts +126 -0
- package/src/mobile/views/SessionListView.tsx +6 -1
- package/src/mobile-api.ts +6 -2
- package/src/mobile-routes.ts +1 -1
- package/src/pairing.persist.test.ts +64 -5
- package/src/pairing.ts +167 -20
- package/src/posture.ts +154 -0
- package/src/remote-api.ts +217 -0
- package/src/remote-methods.ts +62 -0
- package/src/routes.ts +33 -4
- package/src/update.ts +14 -1
package/src/index.ts
CHANGED
|
@@ -9,18 +9,22 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { createRequire } from 'node:module'
|
|
12
|
-
import {
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { setInterval as nodeSetInterval, setTimeout as nodeSetTimeout } from 'node:timers'
|
|
13
14
|
import type { IncomingMessage } from 'node:http'
|
|
14
15
|
import type { Context } from '@deepseek-ai/cordis'
|
|
15
16
|
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
16
17
|
import z from 'schemastery'
|
|
17
18
|
import type {} from '@deepseek-ai/dsh-host-webserver'
|
|
18
|
-
import { PairingService, type PairingConfig } from './pairing.ts'
|
|
19
|
+
import { DEFAULT_IDLE_EXPIRE_MS, PairingService, type PairingConfig } from './pairing.ts'
|
|
20
|
+
import { dshHome } from './dsh-home.ts'
|
|
19
21
|
import { isPairedDeviceRequest, makeGateListener } from './gate.ts'
|
|
20
22
|
import { RemoteWebUiPairing } from './pairing-access.ts'
|
|
21
23
|
import { isTrustedApiRequest, makeRoutes } from './routes.ts'
|
|
22
24
|
import { makeMobileRoutes } from './mobile-routes.ts'
|
|
23
25
|
import { makeMobileApiRoutes } from './mobile-api.ts'
|
|
26
|
+
import { makeRemoteApiRoutes, makeRemoteApiUpgradeRoutes } from './remote-api.ts'
|
|
27
|
+
import { anyExposed, claimPostureKey, postureTargets, probePosture, releasePostureKey } from './posture.ts'
|
|
24
28
|
import { lanIPv4Addresses } from './lan.ts'
|
|
25
29
|
import { TunnelManager, type TunnelInfo } from './tunnel.ts'
|
|
26
30
|
import {
|
|
@@ -72,13 +76,19 @@ export interface Config {
|
|
|
72
76
|
offlineAfterMs?: number
|
|
73
77
|
/** Hard cap on paired device sessions (oldest evicted when full). */
|
|
74
78
|
maxDevices?: number
|
|
79
|
+
/**
|
|
80
|
+
* Idle sessions older than this (ms) are deleted from memory and disk.
|
|
81
|
+
* Default is 7 days; a leftover cookie no longer authorizes after expiry.
|
|
82
|
+
*/
|
|
83
|
+
idleExpireMs?: number
|
|
75
84
|
/** Cookie name carrying the paired device id. */
|
|
76
85
|
cookieName?: string
|
|
77
86
|
/**
|
|
78
|
-
* When true (default),
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
87
|
+
* When true (default), a desktop Web GUI opened at a non-loopback origin
|
|
88
|
+
* rides the gated `/remote/api` channel and must carry a live paired-device
|
|
89
|
+
* cookie — the QR is the only way into remote desktop, and stop() cuts
|
|
90
|
+
* paired devices off. Set false to keep the desktop on plain `/api`
|
|
91
|
+
* (only useful when that origin is already trusted for `/api`).
|
|
82
92
|
*/
|
|
83
93
|
requirePairingForLan?: boolean
|
|
84
94
|
/**
|
|
@@ -92,17 +102,16 @@ export interface Config {
|
|
|
92
102
|
publicBaseUrl?: string
|
|
93
103
|
/**
|
|
94
104
|
* Absolute path to a JSON file where paired device sessions are persisted.
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* (previous behavior).
|
|
105
|
+
* Defaults to `$DSH_HOME/remote-web-ui-devices.json` so a paired device
|
|
106
|
+
* keeps its session across `dsh web` restarts (the cookie already lives
|
|
107
|
+
* 365 days). Override to another absolute path when needed.
|
|
99
108
|
*/
|
|
100
109
|
devicesFile?: string
|
|
101
110
|
/**
|
|
102
111
|
* When true, the plugin runs its own Cloudflare quick tunnel (the
|
|
103
112
|
* cloudflared binary ships with the package — no user-side install) and
|
|
104
|
-
* feeds the minted public URL into
|
|
105
|
-
* fence dynamically, so phones anywhere can pair without any manual
|
|
113
|
+
* feeds the minted public URL into the QR base and the phone-facing
|
|
114
|
+
* pairing fence dynamically, so phones anywhere can pair without any manual
|
|
106
115
|
* tunnel setup. The manual `publicBaseUrl` is ignored while this is on.
|
|
107
116
|
*/
|
|
108
117
|
autoTunnel?: boolean
|
|
@@ -121,6 +130,7 @@ export const Config: z<Config> = z.object({
|
|
|
121
130
|
tokenTtlMs: z.number().step(1).min(60_000).default(10 * 60_000),
|
|
122
131
|
offlineAfterMs: z.number().step(1).min(5_000).default(25_000),
|
|
123
132
|
maxDevices: z.number().step(1).min(1).max(64).default(4),
|
|
133
|
+
idleExpireMs: z.number().step(1).min(60_000).default(DEFAULT_IDLE_EXPIRE_MS),
|
|
124
134
|
cookieName: z.string().min(1).default('dsh_pair'),
|
|
125
135
|
requirePairingForLan: z.boolean().default(true),
|
|
126
136
|
publicBaseUrl: z.string(),
|
|
@@ -140,7 +150,7 @@ const SWEEP_INTERVAL_MS = 10_000
|
|
|
140
150
|
*/
|
|
141
151
|
type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl' | 'devicesFile'>> & {
|
|
142
152
|
publicBaseUrl: string | undefined
|
|
143
|
-
devicesFile: string
|
|
153
|
+
devicesFile: string
|
|
144
154
|
}
|
|
145
155
|
|
|
146
156
|
/**
|
|
@@ -151,26 +161,33 @@ type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl' | 'devicesFile'>> &
|
|
|
151
161
|
*/
|
|
152
162
|
export function pairingConfigOf(resolved: Pick<
|
|
153
163
|
ResolvedConfig,
|
|
154
|
-
'tokenTtlMs' | 'offlineAfterMs' | 'maxDevices' | 'cookieName' | 'devicesFile'
|
|
164
|
+
'tokenTtlMs' | 'offlineAfterMs' | 'maxDevices' | 'idleExpireMs' | 'cookieName' | 'devicesFile'
|
|
155
165
|
>): PairingConfig {
|
|
156
166
|
return {
|
|
157
167
|
tokenTtlMs: resolved.tokenTtlMs,
|
|
158
168
|
offlineAfterMs: resolved.offlineAfterMs,
|
|
159
169
|
maxDevices: resolved.maxDevices,
|
|
170
|
+
idleExpireMs: resolved.idleExpireMs,
|
|
160
171
|
cookieName: resolved.cookieName,
|
|
161
172
|
devicesFile: resolved.devicesFile,
|
|
162
173
|
}
|
|
163
174
|
}
|
|
164
175
|
|
|
176
|
+
/** Default paired-session store: `$DSH_HOME/remote-web-ui-devices.json`. */
|
|
177
|
+
export function defaultDevicesFile(home: string = dshHome()): string {
|
|
178
|
+
return join(home, 'remote-web-ui-devices.json')
|
|
179
|
+
}
|
|
180
|
+
|
|
165
181
|
/** Schema defaults, re-read for hand-built test contexts (the loader applies them normally). */
|
|
166
182
|
const DEFAULTS: ResolvedConfig = {
|
|
167
183
|
tokenTtlMs: 10 * 60_000,
|
|
168
184
|
offlineAfterMs: 25_000,
|
|
169
185
|
maxDevices: 4,
|
|
186
|
+
idleExpireMs: DEFAULT_IDLE_EXPIRE_MS,
|
|
170
187
|
cookieName: 'dsh_pair',
|
|
171
188
|
requirePairingForLan: true,
|
|
172
189
|
publicBaseUrl: undefined,
|
|
173
|
-
devicesFile:
|
|
190
|
+
devicesFile: defaultDevicesFile(),
|
|
174
191
|
autoTunnel: false,
|
|
175
192
|
mobileEnterToSend: true,
|
|
176
193
|
enabled: true,
|
|
@@ -188,10 +205,11 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
188
205
|
tokenTtlMs: config?.tokenTtlMs ?? DEFAULTS.tokenTtlMs,
|
|
189
206
|
offlineAfterMs: config?.offlineAfterMs ?? DEFAULTS.offlineAfterMs,
|
|
190
207
|
maxDevices: config?.maxDevices ?? DEFAULTS.maxDevices,
|
|
208
|
+
idleExpireMs: config?.idleExpireMs ?? DEFAULTS.idleExpireMs,
|
|
191
209
|
cookieName: config?.cookieName ?? DEFAULTS.cookieName,
|
|
192
210
|
requirePairingForLan: config?.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
|
|
193
211
|
publicBaseUrl: config?.publicBaseUrl,
|
|
194
|
-
devicesFile: config?.devicesFile,
|
|
212
|
+
devicesFile: config?.devicesFile ?? DEFAULTS.devicesFile,
|
|
195
213
|
autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
|
|
196
214
|
mobileEnterToSend: config?.mobileEnterToSend ?? DEFAULTS.mobileEnterToSend,
|
|
197
215
|
enabled: config?.enabled ?? DEFAULTS.enabled,
|
|
@@ -206,6 +224,7 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
206
224
|
tokenTtlMs: value.tokenTtlMs ?? DEFAULTS.tokenTtlMs,
|
|
207
225
|
offlineAfterMs: value.offlineAfterMs ?? DEFAULTS.offlineAfterMs,
|
|
208
226
|
maxDevices: value.maxDevices ?? DEFAULTS.maxDevices,
|
|
227
|
+
idleExpireMs: value.idleExpireMs ?? DEFAULTS.idleExpireMs,
|
|
209
228
|
cookieName: value.cookieName ?? DEFAULTS.cookieName,
|
|
210
229
|
requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
|
|
211
230
|
publicBaseUrl: value.publicBaseUrl,
|
|
@@ -230,6 +249,7 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
230
249
|
if (info.phase === 'running' && info.url !== undefined) {
|
|
231
250
|
service.setPublicBaseUrl(info.url)
|
|
232
251
|
service.setTunnelStatus({ state: 'running', url: info.url })
|
|
252
|
+
runPostureProbe()
|
|
233
253
|
} else if (info.phase === 'starting') {
|
|
234
254
|
// A restart mints a NEW hostname: the previous URL dies with the old
|
|
235
255
|
// process, so clear it now rather than advertising a dead link.
|
|
@@ -338,10 +358,56 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
338
358
|
...(apiProxy !== undefined
|
|
339
359
|
? makeMobileApiRoutes({ service, apiProxy, mobileEnterToSend: () => resolve().mobileEnterToSend })
|
|
340
360
|
: []),
|
|
361
|
+
// The remote desktop channel: paired-cookie-gated `/remote` prefix that
|
|
362
|
+
// re-issues fenced paths to loopback (see remote-api.ts).
|
|
363
|
+
...makeRemoteApiRoutes({ service, port: ctx.webServer.port }),
|
|
341
364
|
...updateRoutes,
|
|
342
365
|
]
|
|
366
|
+
const upgrades = makeRemoteApiUpgradeRoutes({ service, port: ctx.webServer.port })
|
|
343
367
|
const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled)
|
|
344
368
|
ctx.effect(() => ctx.on('api/gate', gate), 'remote-web-ui: api gate')
|
|
369
|
+
|
|
370
|
+
// ── posture probe ─────────────────────────────────────────────────────────
|
|
371
|
+
// Guardrail for the one seam this plugin cannot mount a gate into: the
|
|
372
|
+
// connection plugin's /api Host fence. Forged-Host probes against every
|
|
373
|
+
// advertised origin (public base + LAN bases) make a re-opened /api (a
|
|
374
|
+
// re-added --trusted-host, or the SDK's LAN auto-trust under 0.0.0.0)
|
|
375
|
+
// visible on the panel and the log instead of silently trusted.
|
|
376
|
+
let postureKey: string | undefined
|
|
377
|
+
let postureWasExposed = false
|
|
378
|
+
const runPostureProbe = (): void => {
|
|
379
|
+
if (!resolve().enabled) return
|
|
380
|
+
const targets = postureTargets(service.publicBaseUrl, service.lanAddresses, ctx.webServer.port)
|
|
381
|
+
if (targets.length === 0) {
|
|
382
|
+
postureKey = undefined
|
|
383
|
+
service.setPosture(undefined)
|
|
384
|
+
return
|
|
385
|
+
}
|
|
386
|
+
const key = targets.join('|')
|
|
387
|
+
const claim = claimPostureKey(postureKey, key)
|
|
388
|
+
if (!claim.run) return
|
|
389
|
+
postureKey = claim.next
|
|
390
|
+
void probePosture({ port: ctx.webServer.port, targets }).then((snapshot) => {
|
|
391
|
+
service.setPosture(snapshot)
|
|
392
|
+
const exposedHosts = snapshot.hosts.filter(host => host.exposed).map(host => host.host)
|
|
393
|
+
const exposed = exposedHosts.length > 0
|
|
394
|
+
if (exposed && !postureWasExposed) {
|
|
395
|
+
console.error(`remote-web-ui: CRITICAL — the /api fence is OPEN for [${exposedHosts.join(', ')}]: unpaired clients reach the full host API. Remove --trusted-host for these hosts (pairing covers them) or bind loopback.`)
|
|
396
|
+
} else if (!exposed && postureWasExposed) {
|
|
397
|
+
console.log('remote-web-ui: the /api posture probe is clean again (every advertised origin refused with 403).')
|
|
398
|
+
}
|
|
399
|
+
postureWasExposed = exposed
|
|
400
|
+
}).catch(() => {
|
|
401
|
+
// Keep the previous snapshot; drop the in-flight key so the same
|
|
402
|
+
// targets retry instead of sticking on a failed round.
|
|
403
|
+
postureKey = releasePostureKey(postureKey, key)
|
|
404
|
+
})
|
|
405
|
+
}
|
|
406
|
+
// The first round waits for the connection plugin's /api route: a probe
|
|
407
|
+
// before it mounts would read the SPA fallback and false-positive.
|
|
408
|
+
const initialPostureTimer = nodeSetTimeout(() => { runPostureProbe() }, 5_000)
|
|
409
|
+
initialPostureTimer.unref()
|
|
410
|
+
ctx.effect(() => () => { clearTimeout(initialPostureTimer) }, 'remote-web-ui: posture probe boot')
|
|
345
411
|
// Sibling plugins (aionui-panel, …) look this up by name. Absent when this
|
|
346
412
|
// plugin is not installed; stop() / enabled=false still refuse cookies.
|
|
347
413
|
new RemoteWebUiPairing(ctx, (request) => {
|
|
@@ -376,7 +442,10 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
376
442
|
if (disposeRoutes === undefined && enabled) {
|
|
377
443
|
disposeRoutes = ctx.effect(
|
|
378
444
|
() => {
|
|
379
|
-
const disposers =
|
|
445
|
+
const disposers = [
|
|
446
|
+
...routes.map(route => ctx.webServer.register(route)),
|
|
447
|
+
...upgrades.map(route => ctx.webServer.registerUpgrade(route)),
|
|
448
|
+
]
|
|
380
449
|
return () => { for (const dispose of disposers) dispose() }
|
|
381
450
|
},
|
|
382
451
|
'remote-web-ui: pairing routes',
|
|
@@ -398,6 +467,9 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
398
467
|
disposeSweep()
|
|
399
468
|
disposeSweep = undefined
|
|
400
469
|
}
|
|
470
|
+
// Settings changed the reachable posture (manual publicBaseUrl, bind):
|
|
471
|
+
// re-probe unless the target set is unchanged.
|
|
472
|
+
runPostureProbe()
|
|
401
473
|
}
|
|
402
474
|
installSettingsSection(ctx, REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, config ?? {}, {
|
|
403
475
|
setSource: (source) => {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loopback-shaped reverse proxy used by the remote desktop channel: after
|
|
3
|
+
* the pairing cookie gate, traffic is re-issued to 127.0.0.1 so sibling
|
|
4
|
+
* plugin fences (socket + Host loopback) accept it. Origin, cookies, and
|
|
5
|
+
* caller-controlled Sec-Fetch markers are dropped. HTTP requests receive a
|
|
6
|
+
* synthetic same-origin marker after the pairing gate so sibling loopback
|
|
7
|
+
* routes that require a browser tripwire accept the authenticated proxy.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node:http'
|
|
11
|
+
import { request as httpRequest } from 'node:http'
|
|
12
|
+
import { connect } from 'node:net'
|
|
13
|
+
import type { Duplex } from 'node:stream'
|
|
14
|
+
import { writeJson } from './http.ts'
|
|
15
|
+
|
|
16
|
+
/** WebSocket handshake headers forwarded to the loopback upstream. */
|
|
17
|
+
const WS_FORWARD_HEADERS = [
|
|
18
|
+
'sec-websocket-key',
|
|
19
|
+
'sec-websocket-version',
|
|
20
|
+
'sec-websocket-extensions',
|
|
21
|
+
'sec-websocket-protocol',
|
|
22
|
+
] as const
|
|
23
|
+
|
|
24
|
+
/** Response headers copied from the loopback upstream (no hop-by-hop). */
|
|
25
|
+
const HTTP_FORWARD_RESPONSE_HEADERS = [
|
|
26
|
+
'content-type',
|
|
27
|
+
'content-length',
|
|
28
|
+
'content-disposition',
|
|
29
|
+
'cache-control',
|
|
30
|
+
'etag',
|
|
31
|
+
'last-modified',
|
|
32
|
+
] as const
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Pipe one HTTP request to loopback and stream the response back.
|
|
36
|
+
* @param req - the already-gated outer request.
|
|
37
|
+
* @param res - the outer response.
|
|
38
|
+
* @param port - local webServer port.
|
|
39
|
+
* @param upstreamPath - path + query on 127.0.0.1 (must start with `/`).
|
|
40
|
+
*/
|
|
41
|
+
export function proxyLoopbackHttp(
|
|
42
|
+
req: IncomingMessage,
|
|
43
|
+
res: ServerResponse,
|
|
44
|
+
port: number,
|
|
45
|
+
upstreamPath: string,
|
|
46
|
+
): void {
|
|
47
|
+
const headers: Record<string, string> = {
|
|
48
|
+
host: `127.0.0.1:${String(port)}`,
|
|
49
|
+
'sec-fetch-site': 'same-origin',
|
|
50
|
+
}
|
|
51
|
+
const contentType = req.headers['content-type']
|
|
52
|
+
if (typeof contentType === 'string') headers['content-type'] = contentType
|
|
53
|
+
const contentLength = req.headers['content-length']
|
|
54
|
+
if (typeof contentLength === 'string') headers['content-length'] = contentLength
|
|
55
|
+
const accept = req.headers.accept
|
|
56
|
+
if (typeof accept === 'string') headers.accept = accept
|
|
57
|
+
|
|
58
|
+
const upstream = httpRequest({
|
|
59
|
+
host: '127.0.0.1',
|
|
60
|
+
port,
|
|
61
|
+
path: upstreamPath,
|
|
62
|
+
method: req.method,
|
|
63
|
+
headers,
|
|
64
|
+
}, (upstreamRes) => {
|
|
65
|
+
const out: OutgoingHttpHeaders = {}
|
|
66
|
+
for (const name of HTTP_FORWARD_RESPONSE_HEADERS) {
|
|
67
|
+
const value = upstreamRes.headers[name]
|
|
68
|
+
if (value !== undefined) out[name] = value
|
|
69
|
+
}
|
|
70
|
+
res.writeHead(upstreamRes.statusCode ?? 502, out)
|
|
71
|
+
upstreamRes.pipe(res)
|
|
72
|
+
})
|
|
73
|
+
upstream.on('error', () => {
|
|
74
|
+
if (!res.headersSent) {
|
|
75
|
+
writeJson(res, 502, { ok: false, error: { code: 'upstream-failure', message: 'upstream request failed' } })
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
res.destroy()
|
|
79
|
+
})
|
|
80
|
+
req.pipe(upstream)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Rebuild a WebSocket handshake as loopback-shaped and pipe both directions.
|
|
85
|
+
* @param req - the already-gated upgrade request.
|
|
86
|
+
* @param socket - the client duplex.
|
|
87
|
+
* @param head - bytes already read past the handshake.
|
|
88
|
+
* @param port - local webServer port.
|
|
89
|
+
* @param upstreamPath - path + query on 127.0.0.1.
|
|
90
|
+
*/
|
|
91
|
+
export function proxyLoopbackUpgrade(
|
|
92
|
+
req: IncomingMessage,
|
|
93
|
+
socket: Duplex,
|
|
94
|
+
head: Buffer,
|
|
95
|
+
port: number,
|
|
96
|
+
upstreamPath: string,
|
|
97
|
+
): void {
|
|
98
|
+
const lines = [
|
|
99
|
+
`GET ${upstreamPath} HTTP/1.1`,
|
|
100
|
+
`Host: 127.0.0.1:${String(port)}`,
|
|
101
|
+
'Upgrade: websocket',
|
|
102
|
+
'Connection: Upgrade',
|
|
103
|
+
]
|
|
104
|
+
for (const name of WS_FORWARD_HEADERS) {
|
|
105
|
+
const value = req.headers[name]
|
|
106
|
+
if (value === undefined) continue
|
|
107
|
+
lines.push(`${name}: ${Array.isArray(value) ? value.join(', ') : value}`)
|
|
108
|
+
}
|
|
109
|
+
const handshake = `${lines.join('\r\n')}\r\n\r\n`
|
|
110
|
+
|
|
111
|
+
const upstream = connect(port, '127.0.0.1')
|
|
112
|
+
const tearDown = (): void => {
|
|
113
|
+
upstream.destroy()
|
|
114
|
+
socket.destroy()
|
|
115
|
+
}
|
|
116
|
+
upstream.on('error', tearDown)
|
|
117
|
+
socket.on('error', tearDown)
|
|
118
|
+
upstream.on('close', () => { socket.destroy() })
|
|
119
|
+
socket.on('close', () => { upstream.destroy() })
|
|
120
|
+
upstream.on('connect', () => {
|
|
121
|
+
upstream.write(handshake)
|
|
122
|
+
if (head.length > 0) upstream.write(head)
|
|
123
|
+
socket.pipe(upstream)
|
|
124
|
+
upstream.pipe(socket)
|
|
125
|
+
})
|
|
126
|
+
}
|
|
@@ -51,6 +51,10 @@ export function SessionListView({ workspace, onBack, onPick }: SessionListViewPr
|
|
|
51
51
|
const [presetsLoading, setPresetsLoading] = useState(true)
|
|
52
52
|
const cursorRef = useRef<string | undefined>(undefined)
|
|
53
53
|
const busyRef = useRef(false)
|
|
54
|
+
// The freshest owned-id set: the mount effect re-reads the workspace roster
|
|
55
|
+
// so a session created since this row was captured still shows; loadMore
|
|
56
|
+
// filters later pages through this ref instead of the stale prop.
|
|
57
|
+
const workspaceRef = useRef(workspace)
|
|
54
58
|
|
|
55
59
|
// First page on mount. The workspace roster is re-read alongside so the
|
|
56
60
|
// owned-id set is fresh: a session created (or attached) since this
|
|
@@ -64,6 +68,7 @@ export function SessionListView({ workspace, onBack, onPick }: SessionListViewPr
|
|
|
64
68
|
if (cancelled) return
|
|
65
69
|
const fresh = workspaces.find(candidate => candidate.workspaceId === workspace.workspaceId)
|
|
66
70
|
const current = fresh ?? workspace
|
|
71
|
+
workspaceRef.current = current
|
|
67
72
|
setRows(ownedItems(page.items, current))
|
|
68
73
|
cursorRef.current = page.nextCursor
|
|
69
74
|
setHasMore(page.hasMore)
|
|
@@ -112,7 +117,7 @@ export function SessionListView({ workspace, onBack, onPick }: SessionListViewPr
|
|
|
112
117
|
setLoading(false)
|
|
113
118
|
cursorRef.current = page.nextCursor
|
|
114
119
|
setHasMore(page.hasMore)
|
|
115
|
-
setRows(previous => [...previous, ...ownedItems(page.items,
|
|
120
|
+
setRows(previous => [...previous, ...ownedItems(page.items, workspaceRef.current)])
|
|
116
121
|
},
|
|
117
122
|
(reason: unknown) => {
|
|
118
123
|
busyRef.current = false
|
package/src/mobile-api.ts
CHANGED
|
@@ -33,7 +33,8 @@ import { readCookie } from './gate.ts'
|
|
|
33
33
|
* note the paired-device cookie also passes the global api/gate for the full
|
|
34
34
|
* ApiProxy surface (gate.ts), so a paired phone is a full-control credential:
|
|
35
35
|
* the allowlist only constrains this /m/api proxy, not the cookie's reach.
|
|
36
|
-
* stop() revokes every device;
|
|
36
|
+
* stop() revokes every device; the loopback panel can also revoke one
|
|
37
|
+
* device at a time.
|
|
37
38
|
*/
|
|
38
39
|
const MOBILE_ALLOWLIST = new Set([
|
|
39
40
|
'workspace.list',
|
|
@@ -259,7 +260,10 @@ async function dispatch(apiProxy: ApiProxy, method: string, payload: unknown, rp
|
|
|
259
260
|
const request: RpcRequest<unknown> = { rpcId: RpcId(rpcId), payload }
|
|
260
261
|
if (method === 'session.list') {
|
|
261
262
|
const full = await apiProxy.sessions.list(request as never)
|
|
262
|
-
|
|
263
|
+
// The error path must carry the same 'server-response' envelope the
|
|
264
|
+
// success path builds, or the phone's callUnary throws a transport error
|
|
265
|
+
// and masks the real business error.
|
|
266
|
+
if (!full.result.ok) return { type: 'server-response' as const, rpcId, result: full.result }
|
|
263
267
|
const items = full.result.value.items as Array<{ updatedAt: number; sessionId: string }>
|
|
264
268
|
const cursor = (payload as { cursor?: string } | undefined)?.cursor
|
|
265
269
|
// Every call pages (the first call with no cursor IS the first page):
|
package/src/mobile-routes.ts
CHANGED
|
@@ -42,7 +42,7 @@ function pageHtml(bundleUrl: string): string {
|
|
|
42
42
|
'<meta name="apple-mobile-web-app-title" content="DSH Remote">',
|
|
43
43
|
'<link rel="manifest" href="/m/manifest.webmanifest">',
|
|
44
44
|
'<link rel="apple-touch-icon" href="/m/apple-touch-icon.png">',
|
|
45
|
-
'<title
|
|
45
|
+
'<title>远程访问</title>',
|
|
46
46
|
'</head>',
|
|
47
47
|
'<body>',
|
|
48
48
|
'<div id="root"></div>',
|
|
@@ -1,20 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PairingService device-session persistence: with `devicesFile` set, sessions
|
|
3
|
-
* written on accept/stop survive a process restart (a new
|
|
4
|
-
* instance)
|
|
5
|
-
*
|
|
3
|
+
* written on accept/stop/revoke/sweep survive a process restart (a new
|
|
4
|
+
* PairingService instance). lastSeenAt is flushed on sweep, not on every
|
|
5
|
+
* touch; idle sessions older than idleExpireMs are dropped on load.
|
|
6
6
|
*/
|
|
7
7
|
import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
8
8
|
import { tmpdir } from 'node:os'
|
|
9
9
|
import { join } from 'node:path'
|
|
10
10
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
11
11
|
import { pairingConfigOf } from './index.ts'
|
|
12
|
-
import { PairingService, type PairingClock, type PairingConfig } from './pairing.ts'
|
|
12
|
+
import { DEFAULT_IDLE_EXPIRE_MS, PairingService, type PairingClock, type PairingConfig } from './pairing.ts'
|
|
13
13
|
|
|
14
14
|
const BASE_CONFIG: Omit<PairingConfig, 'devicesFile'> = {
|
|
15
15
|
tokenTtlMs: 60_000,
|
|
16
16
|
offlineAfterMs: 25_000,
|
|
17
17
|
maxDevices: 4,
|
|
18
|
+
idleExpireMs: DEFAULT_IDLE_EXPIRE_MS,
|
|
18
19
|
cookieName: 'dsh_pair',
|
|
19
20
|
}
|
|
20
21
|
|
|
@@ -53,7 +54,11 @@ describe('PairingService device persistence', () => {
|
|
|
53
54
|
const deviceId = pairDevice(first)
|
|
54
55
|
expect(readFileSync(file, 'utf8')).toContain(deviceId)
|
|
55
56
|
// Device ids are session credentials: the persisted file is 0600.
|
|
56
|
-
|
|
57
|
+
// win32: NTFS has no POSIX mode bits and Node ignores the writeFileSync
|
|
58
|
+
// mode option there, so the mode assertion only applies on POSIX (#772).
|
|
59
|
+
if (process.platform !== 'win32') {
|
|
60
|
+
expect(statSync(file).mode & 0o777).toBe(0o600)
|
|
61
|
+
}
|
|
57
62
|
|
|
58
63
|
// Simulated restart: a brand-new service instance reading the same file.
|
|
59
64
|
const second = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())
|
|
@@ -66,6 +71,7 @@ describe('PairingService device persistence', () => {
|
|
|
66
71
|
tokenTtlMs: 60_000,
|
|
67
72
|
offlineAfterMs: 25_000,
|
|
68
73
|
maxDevices: 4,
|
|
74
|
+
idleExpireMs: DEFAULT_IDLE_EXPIRE_MS,
|
|
69
75
|
cookieName: 'dsh_pair',
|
|
70
76
|
devicesFile: file,
|
|
71
77
|
})
|
|
@@ -81,6 +87,7 @@ describe('PairingService device persistence', () => {
|
|
|
81
87
|
tokenTtlMs: 60_000,
|
|
82
88
|
offlineAfterMs: 25_000,
|
|
83
89
|
maxDevices: 4,
|
|
90
|
+
idleExpireMs: DEFAULT_IDLE_EXPIRE_MS,
|
|
84
91
|
cookieName: 'dsh_pair',
|
|
85
92
|
devicesFile: file,
|
|
86
93
|
})
|
|
@@ -142,4 +149,56 @@ describe('PairingService device persistence', () => {
|
|
|
142
149
|
const ghost = join(dir, 'does-not-exist.json')
|
|
143
150
|
expect(() => new PairingService({ ...BASE_CONFIG, devicesFile: ghost }, makeClock())).not.toThrow()
|
|
144
151
|
})
|
|
152
|
+
|
|
153
|
+
it('does not persist lastSeenAt on touch; sweep flushes it', () => {
|
|
154
|
+
const file = join(dir, 'devices.json')
|
|
155
|
+
const now = { value: 1_000_000 }
|
|
156
|
+
let n = 0
|
|
157
|
+
const clock: PairingClock = {
|
|
158
|
+
now: () => now.value,
|
|
159
|
+
randomToken: () => `tok${(n++).toString().padStart(6, '0')}`,
|
|
160
|
+
}
|
|
161
|
+
const first = new PairingService({ ...BASE_CONFIG, devicesFile: file }, clock)
|
|
162
|
+
const deviceId = pairDevice(first)
|
|
163
|
+
const afterAccept = JSON.parse(readFileSync(file, 'utf8')) as Record<string, { lastSeenAt: number }>
|
|
164
|
+
expect(afterAccept[deviceId]?.lastSeenAt).toBe(1_000_000)
|
|
165
|
+
now.value = 1_500_000
|
|
166
|
+
expect(first.touchDevice(deviceId)).toBe(true)
|
|
167
|
+
const afterTouch = JSON.parse(readFileSync(file, 'utf8')) as Record<string, { lastSeenAt: number }>
|
|
168
|
+
expect(afterTouch[deviceId]?.lastSeenAt).toBe(1_000_000)
|
|
169
|
+
first.sweep()
|
|
170
|
+
const afterSweep = JSON.parse(readFileSync(file, 'utf8')) as Record<string, { lastSeenAt: number }>
|
|
171
|
+
expect(afterSweep[deviceId]?.lastSeenAt).toBe(1_500_000)
|
|
172
|
+
|
|
173
|
+
const second = new PairingService({ ...BASE_CONFIG, devicesFile: file }, clock)
|
|
174
|
+
expect(second.hasDevice(deviceId)).toBe(true)
|
|
175
|
+
expect(second.snapshot().devices[0]?.lastSeenAt).toBe(1_500_000)
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
it('drops idle sessions on load instead of restoring them', () => {
|
|
179
|
+
const file = join(dir, 'devices.json')
|
|
180
|
+
writeFileSync(file, JSON.stringify({
|
|
181
|
+
stale: { createdAt: 1, lastSeenAt: 1 },
|
|
182
|
+
}))
|
|
183
|
+
const clock: PairingClock = {
|
|
184
|
+
now: () => 1 + DEFAULT_IDLE_EXPIRE_MS + 1,
|
|
185
|
+
randomToken: () => 'tok',
|
|
186
|
+
}
|
|
187
|
+
const service = new PairingService({ ...BASE_CONFIG, devicesFile: file }, clock)
|
|
188
|
+
expect(service.hasDevice('stale')).toBe(false)
|
|
189
|
+
const saved = JSON.parse(readFileSync(file, 'utf8')) as Record<string, unknown>
|
|
190
|
+
expect(saved).toEqual({})
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('persists a User-Agent captured at accept', () => {
|
|
194
|
+
const file = join(dir, 'devices.json')
|
|
195
|
+
const service = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())
|
|
196
|
+
service.setPublicBaseUrl('https://pairing.example.trycloudflare.com')
|
|
197
|
+
const { token } = service.issue()
|
|
198
|
+
const result = service.accept(token, 'Mozilla/5.0 TestPhone')
|
|
199
|
+
expect(result.ok).toBe(true)
|
|
200
|
+
const saved = JSON.parse(readFileSync(file, 'utf8')) as Record<string, { userAgent?: string }>
|
|
201
|
+
const deviceId = result.ok ? result.deviceId : ''
|
|
202
|
+
expect(saved[deviceId]?.userAgent).toBe('Mozilla/5.0 TestPhone')
|
|
203
|
+
})
|
|
145
204
|
})
|