@linxin666/dsh-remote-web-ui 0.3.13 → 0.3.15
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 +3 -3
- package/README.zh.md +3 -3
- package/lib/client.js +55 -21
- package/lib/client.js.map +1 -1
- package/lib/index.js +174 -39
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +11 -1
- package/lib/types/client/RemotePanel.d.ts +5 -1
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +2 -2
- package/lib/types/client/locales.d.ts +8 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +8 -0
- package/lib/types/client/mobile-adapt.d.ts.map +1 -1
- package/lib/types/client/mobile-adapt.js +7 -12
- package/lib/types/client/remote-channel.d.ts +0 -2
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +0 -4
- package/lib/types/index.d.ts +9 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +54 -27
- package/lib/types/loopback-proxy.d.ts.map +1 -1
- package/lib/types/loopback-proxy.js +16 -0
- package/lib/types/remote-api.d.ts +1 -1
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +1 -1
- package/lib/types/remote-channel-boot.d.ts.map +1 -1
- package/lib/types/remote-channel-boot.js +0 -1
- package/lib/types/remote-channel-rules.d.ts +0 -1
- package/lib/types/remote-channel-rules.d.ts.map +1 -1
- package/lib/types/remote-channel-rules.js +0 -1
- package/lib/types/remote-methods.d.ts +1 -4
- package/lib/types/remote-methods.d.ts.map +1 -1
- package/lib/types/remote-methods.js +1 -5
- package/lib/types/routes.d.ts +26 -2
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +150 -5
- package/package.json +18 -18
- package/src/client/RemoteEntry.tsx +12 -0
- package/src/client/RemotePanel.tsx +37 -2
- package/src/client/locales.ts +8 -0
- package/src/client/mobile-adapt.ts +5 -10
- package/src/client/remote-channel.ts +0 -3
- package/src/index.ts +59 -26
- package/src/loopback-proxy.ts +15 -0
- package/src/remote-api.ts +0 -1
- package/src/remote-channel-boot.ts +0 -1
- package/src/remote-channel-rules.ts +0 -2
- package/src/remote-methods.ts +1 -6
- package/src/routes.ts +141 -7
package/src/loopback-proxy.ts
CHANGED
|
@@ -93,6 +93,17 @@ function pipeLoopbackHttp(
|
|
|
93
93
|
}
|
|
94
94
|
res.writeHead(upstreamRes.statusCode ?? 502, out)
|
|
95
95
|
upstreamRes.pipe(res)
|
|
96
|
+
// Inner leg died mid-response (reset, truncation): tear the outer leg
|
|
97
|
+
// down instead of letting the truncated response raise an unhandled
|
|
98
|
+
// 'error' with no listener.
|
|
99
|
+
upstreamRes.on('error', () => { res.destroy() })
|
|
100
|
+
// Outer leg died before the response finished (phone left mid-request):
|
|
101
|
+
// reset the inner request so the loopback server stops working on a call
|
|
102
|
+
// nobody will read. Guarded so a normal completion never destroys a
|
|
103
|
+
// keep-alive socket the agent would reuse.
|
|
104
|
+
res.on('close', () => {
|
|
105
|
+
if (!upstreamRes.readableEnded) upstream.destroy()
|
|
106
|
+
})
|
|
96
107
|
})
|
|
97
108
|
upstream.on('error', () => {
|
|
98
109
|
if (!res.headersSent) {
|
|
@@ -101,6 +112,10 @@ function pipeLoopbackHttp(
|
|
|
101
112
|
}
|
|
102
113
|
res.destroy()
|
|
103
114
|
})
|
|
115
|
+
// The outer request aborted mid-body: reset the inner request instead of
|
|
116
|
+
// feeding it a truncated body that still gets processed (mirror of the
|
|
117
|
+
// upgrade path's two-leg teardown).
|
|
118
|
+
req.on('error', () => { upstream.destroy() })
|
|
104
119
|
req.pipe(upstream)
|
|
105
120
|
}
|
|
106
121
|
|
package/src/remote-api.ts
CHANGED
|
@@ -59,7 +59,6 @@ export function buildRemoteChannelBootScript(rules: RemoteChannelRules = REMOTE_
|
|
|
59
59
|
'function sf(p){' +
|
|
60
60
|
'if(p.indexOf(R.pairPrefix)===0)return false;' +
|
|
61
61
|
'if(p.indexOf(R.updatePrefix)===0)return false;' +
|
|
62
|
-
'if(p===R.desktopLauncherPrefix||p.indexOf(R.desktopLauncherPrefix+"/")===0)return false;' +
|
|
63
62
|
'if(p===R.settingsBridgePrefix||p.indexOf(R.settingsBridgePrefix+"/")===0)return false;' +
|
|
64
63
|
'if(p.indexOf(R.apiPrefix)===0)return true;' +
|
|
65
64
|
'if(p.indexOf(R.sidebarPrefix)===0||p==="/sidebar")return true;' +
|
|
@@ -22,7 +22,6 @@ export interface RemoteChannelRules {
|
|
|
22
22
|
readonly apiPrefix: string
|
|
23
23
|
readonly pairPrefix: string
|
|
24
24
|
readonly updatePrefix: string
|
|
25
|
-
readonly desktopLauncherPrefix: string
|
|
26
25
|
readonly settingsBridgePrefix: string
|
|
27
26
|
readonly sidebarPrefix: string
|
|
28
27
|
readonly gitPrefix: string
|
|
@@ -42,7 +41,6 @@ export const REMOTE_CHANNEL_RULES: RemoteChannelRules = {
|
|
|
42
41
|
apiPrefix: '/api/',
|
|
43
42
|
pairPrefix: '/api/pair/',
|
|
44
43
|
updatePrefix: '/api/update/',
|
|
45
|
-
desktopLauncherPrefix: '/api/dsh-desktop-launcher',
|
|
46
44
|
settingsBridgePrefix: '/api/dsh-web-ui-settings',
|
|
47
45
|
sidebarPrefix: '/sidebar/',
|
|
48
46
|
gitPrefix: '/git/',
|
package/src/remote-methods.ts
CHANGED
|
@@ -45,9 +45,6 @@ export const REMOTE_UPGRADE_PATHS = [
|
|
|
45
45
|
/** Plugin-manager HTTP prefix: install/remove stay physically local. */
|
|
46
46
|
export const PLUGIN_MANAGER_PATH = '/api/plugin-manager'
|
|
47
47
|
|
|
48
|
-
/** Desktop-launcher HTTP prefix: shortcut create and host shutdown stay physically local. */
|
|
49
|
-
export const DESKTOP_LAUNCHER_PATH = '/api/dsh-desktop-launcher'
|
|
50
|
-
|
|
51
48
|
/** Family settings-bridge HTTP prefix — re-exposed to paired devices (plain settings parity). */
|
|
52
49
|
export const WEB_UI_SETTINGS_BRIDGE_PATH = '/api/dsh-web-ui-settings'
|
|
53
50
|
|
|
@@ -68,14 +65,12 @@ export const REMOTE_DEVICE_QUERY = 'device'
|
|
|
68
65
|
* paired remote desktop may use the full host API (chat, sessions,
|
|
69
66
|
* settings, credentials, presets — it is a full-control credential), but it
|
|
70
67
|
* must not reach the machine-control planes: pairing control itself, the
|
|
71
|
-
* dsh-web self-update installer, plugin install/remove
|
|
72
|
-
* launcher actions (host shutdown, shortcuts).
|
|
68
|
+
* dsh-web self-update installer, and plugin install/remove.
|
|
73
69
|
*/
|
|
74
70
|
export const LOCAL_ONLY_PREFIXES: readonly string[] = [
|
|
75
71
|
'/api/pair',
|
|
76
72
|
'/api/update',
|
|
77
73
|
PLUGIN_MANAGER_PATH,
|
|
78
|
-
DESKTOP_LAUNCHER_PATH,
|
|
79
74
|
] as const
|
|
80
75
|
|
|
81
76
|
/**
|
package/src/routes.ts
CHANGED
|
@@ -66,6 +66,77 @@ export function isTrustedApiRequest(request: IncomingMessage, trustedHosts: read
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Test whether a hostname represents a private local-area network (RFC 1918 / ULA / mDNS / loopback).
|
|
71
|
+
* Supports pairing in container-bridged (Docker) or NAT-proxied topologies where the host
|
|
72
|
+
* machine's LAN IP or proxy domain differs from the container's internal sampled network interface.
|
|
73
|
+
*/
|
|
74
|
+
export function isPrivateOrLocalHostname(hostname: string): boolean {
|
|
75
|
+
const normalized = hostname.toLowerCase().trim()
|
|
76
|
+
if (
|
|
77
|
+
normalized === 'localhost' ||
|
|
78
|
+
normalized.endsWith('.local') ||
|
|
79
|
+
normalized.endsWith('.lan') ||
|
|
80
|
+
normalized.endsWith('.internal') ||
|
|
81
|
+
normalized.endsWith('.home.arpa')
|
|
82
|
+
) {
|
|
83
|
+
return true
|
|
84
|
+
}
|
|
85
|
+
let ip = normalized
|
|
86
|
+
if (ip.startsWith('[') && ip.endsWith(']')) ip = ip.slice(1, -1)
|
|
87
|
+
if (ip === '::1' || ip === '127.0.0.1') return true
|
|
88
|
+
if (ip.startsWith('fc') || ip.startsWith('fd') || ip.startsWith('fe80')) return true
|
|
89
|
+
const parts = ip.split('.').map(Number)
|
|
90
|
+
if (parts.length === 4 && parts.every(n => Number.isInteger(n) && n >= 0 && n <= 255)) {
|
|
91
|
+
const [a, b] = parts
|
|
92
|
+
if (a === 10) return true
|
|
93
|
+
if (a === 127) return true
|
|
94
|
+
if (a === 169 && b === 254) return true
|
|
95
|
+
if (a === 172 && b >= 16 && b <= 31) return true
|
|
96
|
+
if (a === 192 && b === 168) return true
|
|
97
|
+
}
|
|
98
|
+
return false
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Validates that an incoming request is non-cross-site (rejects cross-site fetch metadata
|
|
103
|
+
* and ensures Origin matches Host when present).
|
|
104
|
+
*/
|
|
105
|
+
export function isNonCrossSite(request: IncomingMessage): boolean {
|
|
106
|
+
if (request.headers['sec-fetch-site'] === 'cross-site') return false
|
|
107
|
+
const host = request.headers.host
|
|
108
|
+
if (typeof host !== 'string') return false
|
|
109
|
+
const origin = request.headers.origin
|
|
110
|
+
if (origin === undefined) return true
|
|
111
|
+
try {
|
|
112
|
+
const originUrl = new URL(origin)
|
|
113
|
+
const hostUrl = new URL(`http://${host}`)
|
|
114
|
+
return originUrl.host === hostUrl.host
|
|
115
|
+
} catch {
|
|
116
|
+
return false
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The private-LAN fallback authority of a request: its Host header when the
|
|
122
|
+
* hostname is private/local (RFC 1918 / ULA / mDNS / loopback) and the
|
|
123
|
+
* browser markers are non-cross-site, else undefined. The accept paths and
|
|
124
|
+
* the lanFence fallback use this to serve container-bridged and reverse-proxy
|
|
125
|
+
* topologies whose Host is not among the advertised or configured authorities.
|
|
126
|
+
*/
|
|
127
|
+
function privateLanHostOf(request: IncomingMessage): string | undefined {
|
|
128
|
+
const host = request.headers.host
|
|
129
|
+
if (typeof host !== 'string') return undefined
|
|
130
|
+
let hostName = ''
|
|
131
|
+
try {
|
|
132
|
+
hostName = new URL(`http://${host}`).hostname
|
|
133
|
+
} catch {
|
|
134
|
+
return undefined
|
|
135
|
+
}
|
|
136
|
+
if (hostName === '' || !isPrivateOrLocalHostname(hostName) || !isNonCrossSite(request)) return undefined
|
|
137
|
+
return host
|
|
138
|
+
}
|
|
139
|
+
|
|
69
140
|
/** Cap on pairing request bodies (tokens and workspace ids are tiny). */
|
|
70
141
|
const MAX_BODY_BYTES = 4096
|
|
71
142
|
|
|
@@ -85,6 +156,28 @@ export function acceptLimitKey(socketIp: string, forwarded: string | undefined,
|
|
|
85
156
|
return `${bucket}|${socketIp}`
|
|
86
157
|
}
|
|
87
158
|
|
|
159
|
+
/**
|
|
160
|
+
* Cap on the dynamically trusted hosts (see {@link addBounded}): room for
|
|
161
|
+
* every legitimate router/proxy aliasing shape, small enough that a flood of
|
|
162
|
+
* distinct private Host headers cannot grow the table unboundedly.
|
|
163
|
+
*/
|
|
164
|
+
export const MAX_DYNAMIC_TRUSTED_HOSTS = 64
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* FIFO-bounded Set insert: past `max` entries the oldest one is evicted
|
|
168
|
+
* (a Set iterates in insertion order). The dynamic trusted-host table is fed
|
|
169
|
+
* by caller-controlled Host headers, so it must stay bounded; an evicted
|
|
170
|
+
* legitimate host re-adds itself on that device's next gated request.
|
|
171
|
+
*/
|
|
172
|
+
export function addBounded(set: Set<string>, value: string, max: number): void {
|
|
173
|
+
if (set.has(value)) return
|
|
174
|
+
if (set.size >= max) {
|
|
175
|
+
const oldest = set.values().next().value
|
|
176
|
+
if (oldest !== undefined) set.delete(oldest)
|
|
177
|
+
}
|
|
178
|
+
set.add(value)
|
|
179
|
+
}
|
|
180
|
+
|
|
88
181
|
/**
|
|
89
182
|
* The host authority of a configured public base URL, e.g. `foo.trycloudflare.com`
|
|
90
183
|
* from `https://foo.trycloudflare.com`. Undefined when the URL does not parse —
|
|
@@ -339,8 +432,6 @@ export class PairingEventsStream {
|
|
|
339
432
|
export interface PairRoutesDeps {
|
|
340
433
|
/** The pairing service. */
|
|
341
434
|
service: PairingService
|
|
342
|
-
/** The LAN IP literals the fence accepts (derived from the bind host). */
|
|
343
|
-
lanAddresses: readonly string[]
|
|
344
435
|
/** Current desktop gate policy, re-read for every status response. */
|
|
345
436
|
requirePairingForLan?: boolean | (() => boolean)
|
|
346
437
|
/**
|
|
@@ -359,6 +450,8 @@ export interface PairRoutesDeps {
|
|
|
359
450
|
* cookieless header/query credential). Undefined drops the route (tests).
|
|
360
451
|
*/
|
|
361
452
|
indexDocument?: (deviceId: string) => Promise<string | undefined>
|
|
453
|
+
/** Extra trusted non-loopback hosts (from config or environment). */
|
|
454
|
+
trustedHosts?: readonly string[] | (() => readonly string[])
|
|
362
455
|
}
|
|
363
456
|
|
|
364
457
|
/**
|
|
@@ -367,21 +460,45 @@ export interface PairRoutesDeps {
|
|
|
367
460
|
* @returns the exact routes to register on webServer.
|
|
368
461
|
*/
|
|
369
462
|
export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
370
|
-
const { service,
|
|
463
|
+
const { service, requirePairingForLan = true } = deps
|
|
371
464
|
const pairingRequired = (): boolean => typeof requirePairingForLan === 'function'
|
|
372
465
|
? requirePairingForLan()
|
|
373
466
|
: requirePairingForLan
|
|
374
467
|
const events = new PairingEventsStream(service)
|
|
468
|
+
const dynamicTrustedHosts = new Set<string>()
|
|
375
469
|
|
|
376
470
|
/** Loopback-only fence: the desktop panel's control endpoints. */
|
|
377
471
|
const loopbackFence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, [])
|
|
378
|
-
/** Phone-facing fence: loopback, the service's live LAN literals,
|
|
472
|
+
/** Phone-facing fence: loopback, the service's live LAN literals, configured public host, extra trusted hosts, or dynamically paired hosts. */
|
|
379
473
|
const lanFence = (req: IncomingMessage): boolean => {
|
|
380
474
|
const publicHost = publicHostOf(service.publicBaseUrl)
|
|
381
475
|
// The service's LAN bases re-read per request: a hot rebind (the lan-bind
|
|
382
476
|
// toggle) updates them mid-process, and the fence must follow.
|
|
383
477
|
const bases = service.lanAddresses
|
|
384
|
-
|
|
478
|
+
const extraHosts = typeof deps.trustedHosts === 'function' ? deps.trustedHosts() : (deps.trustedHosts ?? [])
|
|
479
|
+
const combined = [
|
|
480
|
+
...bases,
|
|
481
|
+
...(publicHost !== undefined ? [publicHost] : []),
|
|
482
|
+
...extraHosts,
|
|
483
|
+
...dynamicTrustedHosts,
|
|
484
|
+
]
|
|
485
|
+
if (isTrustedApiRequest(req, combined)) return true
|
|
486
|
+
|
|
487
|
+
// If request carries a valid paired device cookie from a private LAN host (e.g. after service restart), trust and remember it
|
|
488
|
+
const privateLanHost = privateLanHostOf(req)
|
|
489
|
+
if (privateLanHost !== undefined) {
|
|
490
|
+
const cookieDeviceId = readCookie(req.headers.cookie, service.config.cookieName)
|
|
491
|
+
let queryDeviceId: string | null = null
|
|
492
|
+
try {
|
|
493
|
+
queryDeviceId = new URL(req.url ?? '/', 'http://pair.invalid').searchParams.get('device')
|
|
494
|
+
} catch {}
|
|
495
|
+
const deviceId = cookieDeviceId ?? queryDeviceId ?? undefined
|
|
496
|
+
if (deviceId !== undefined && service.hasDevice(deviceId)) {
|
|
497
|
+
addBounded(dynamicTrustedHosts, privateLanHost, MAX_DYNAMIC_TRUSTED_HOSTS)
|
|
498
|
+
return true
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return false
|
|
385
502
|
}
|
|
386
503
|
|
|
387
504
|
const requireMethod = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
|
|
@@ -476,7 +593,8 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
476
593
|
|
|
477
594
|
const handleAccept = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
478
595
|
if (!requireMethod(req, res, 'POST')) return
|
|
479
|
-
|
|
596
|
+
const privateLanHost = privateLanHostOf(req)
|
|
597
|
+
if (!lanFence(req) && privateLanHost === undefined) {
|
|
480
598
|
writeJson(res, 403, { ok: false, code: 'forbidden' })
|
|
481
599
|
return
|
|
482
600
|
}
|
|
@@ -493,9 +611,16 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
493
611
|
const ua = req.headers['user-agent']
|
|
494
612
|
const result = service.accept(payload.token, typeof ua === 'string' ? ua : undefined)
|
|
495
613
|
if (!result.ok) {
|
|
614
|
+
if (!lanFence(req)) {
|
|
615
|
+
writeJson(res, 403, { ok: false, code: 'forbidden' })
|
|
616
|
+
return
|
|
617
|
+
}
|
|
496
618
|
writeJson(res, result.code === 'used' ? 409 : 404, { ok: false, code: result.code })
|
|
497
619
|
return
|
|
498
620
|
}
|
|
621
|
+
if (privateLanHost !== undefined) {
|
|
622
|
+
addBounded(dynamicTrustedHosts, privateLanHost, MAX_DYNAMIC_TRUSTED_HOSTS)
|
|
623
|
+
}
|
|
499
624
|
// No Secure attribute: LAN pairing runs over plain HTTP (the cookie must
|
|
500
625
|
// work there), and the same cookie rides HTTPS on the tunnel. Lax keeps
|
|
501
626
|
// top-level navigations working while blocking cross-site subrequests.
|
|
@@ -612,7 +737,8 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
612
737
|
*/
|
|
613
738
|
const handleAcceptPage = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
614
739
|
if (!requireMethod(req, res, 'GET')) return
|
|
615
|
-
|
|
740
|
+
const privateLanHost = privateLanHostOf(req)
|
|
741
|
+
if (!lanFence(req) && privateLanHost === undefined) {
|
|
616
742
|
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
|
|
617
743
|
res.end('forbidden')
|
|
618
744
|
return
|
|
@@ -627,6 +753,11 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
627
753
|
const ua = req.headers['user-agent']
|
|
628
754
|
const result = token === '' ? { ok: false as const, code: 'invalid' as const } : service.accept(token, typeof ua === 'string' ? ua : undefined)
|
|
629
755
|
if (!result.ok) {
|
|
756
|
+
if (!lanFence(req)) {
|
|
757
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
|
|
758
|
+
res.end('forbidden')
|
|
759
|
+
return
|
|
760
|
+
}
|
|
630
761
|
// accept() now refuses only expired/unknown/stopped tokens: a consumed
|
|
631
762
|
// token stays re-usable until its expiry or replacement, so a mobile
|
|
632
763
|
// flow that split across cookie contexts (camera preview, in-app
|
|
@@ -646,6 +777,9 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
646
777
|
res.end(pairingFailurePage())
|
|
647
778
|
return
|
|
648
779
|
}
|
|
780
|
+
if (privateLanHost !== undefined) {
|
|
781
|
+
addBounded(dynamicTrustedHosts, privateLanHost, MAX_DYNAMIC_TRUSTED_HOSTS)
|
|
782
|
+
}
|
|
649
783
|
// Land the paired device on the cookieless app page: the official shell
|
|
650
784
|
// served by this plugin, with the device id in the URL. No harness index
|
|
651
785
|
// gate, no browser-auth cookie hop - the channel gate accepts the device
|