@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/lib/types/routes.js
CHANGED
|
@@ -67,6 +67,87 @@ export function isTrustedApiRequest(request, trustedHosts) {
|
|
|
67
67
|
return false;
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Test whether a hostname represents a private local-area network (RFC 1918 / ULA / mDNS / loopback).
|
|
72
|
+
* Supports pairing in container-bridged (Docker) or NAT-proxied topologies where the host
|
|
73
|
+
* machine's LAN IP or proxy domain differs from the container's internal sampled network interface.
|
|
74
|
+
*/
|
|
75
|
+
export function isPrivateOrLocalHostname(hostname) {
|
|
76
|
+
const normalized = hostname.toLowerCase().trim();
|
|
77
|
+
if (normalized === 'localhost' ||
|
|
78
|
+
normalized.endsWith('.local') ||
|
|
79
|
+
normalized.endsWith('.lan') ||
|
|
80
|
+
normalized.endsWith('.internal') ||
|
|
81
|
+
normalized.endsWith('.home.arpa')) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
let ip = normalized;
|
|
85
|
+
if (ip.startsWith('[') && ip.endsWith(']'))
|
|
86
|
+
ip = ip.slice(1, -1);
|
|
87
|
+
if (ip === '::1' || ip === '127.0.0.1')
|
|
88
|
+
return true;
|
|
89
|
+
if (ip.startsWith('fc') || ip.startsWith('fd') || ip.startsWith('fe80'))
|
|
90
|
+
return true;
|
|
91
|
+
const parts = ip.split('.').map(Number);
|
|
92
|
+
if (parts.length === 4 && parts.every(n => Number.isInteger(n) && n >= 0 && n <= 255)) {
|
|
93
|
+
const [a, b] = parts;
|
|
94
|
+
if (a === 10)
|
|
95
|
+
return true;
|
|
96
|
+
if (a === 127)
|
|
97
|
+
return true;
|
|
98
|
+
if (a === 169 && b === 254)
|
|
99
|
+
return true;
|
|
100
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
101
|
+
return true;
|
|
102
|
+
if (a === 192 && b === 168)
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Validates that an incoming request is non-cross-site (rejects cross-site fetch metadata
|
|
109
|
+
* and ensures Origin matches Host when present).
|
|
110
|
+
*/
|
|
111
|
+
export function isNonCrossSite(request) {
|
|
112
|
+
if (request.headers['sec-fetch-site'] === 'cross-site')
|
|
113
|
+
return false;
|
|
114
|
+
const host = request.headers.host;
|
|
115
|
+
if (typeof host !== 'string')
|
|
116
|
+
return false;
|
|
117
|
+
const origin = request.headers.origin;
|
|
118
|
+
if (origin === undefined)
|
|
119
|
+
return true;
|
|
120
|
+
try {
|
|
121
|
+
const originUrl = new URL(origin);
|
|
122
|
+
const hostUrl = new URL(`http://${host}`);
|
|
123
|
+
return originUrl.host === hostUrl.host;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The private-LAN fallback authority of a request: its Host header when the
|
|
131
|
+
* hostname is private/local (RFC 1918 / ULA / mDNS / loopback) and the
|
|
132
|
+
* browser markers are non-cross-site, else undefined. The accept paths and
|
|
133
|
+
* the lanFence fallback use this to serve container-bridged and reverse-proxy
|
|
134
|
+
* topologies whose Host is not among the advertised or configured authorities.
|
|
135
|
+
*/
|
|
136
|
+
function privateLanHostOf(request) {
|
|
137
|
+
const host = request.headers.host;
|
|
138
|
+
if (typeof host !== 'string')
|
|
139
|
+
return undefined;
|
|
140
|
+
let hostName = '';
|
|
141
|
+
try {
|
|
142
|
+
hostName = new URL(`http://${host}`).hostname;
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
if (hostName === '' || !isPrivateOrLocalHostname(hostName) || !isNonCrossSite(request))
|
|
148
|
+
return undefined;
|
|
149
|
+
return host;
|
|
150
|
+
}
|
|
70
151
|
/** Cap on pairing request bodies (tokens and workspace ids are tiny). */
|
|
71
152
|
const MAX_BODY_BYTES = 4096;
|
|
72
153
|
/**
|
|
@@ -84,6 +165,28 @@ export function acceptLimitKey(socketIp, forwarded, bucket) {
|
|
|
84
165
|
}
|
|
85
166
|
return `${bucket}|${socketIp}`;
|
|
86
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* Cap on the dynamically trusted hosts (see {@link addBounded}): room for
|
|
170
|
+
* every legitimate router/proxy aliasing shape, small enough that a flood of
|
|
171
|
+
* distinct private Host headers cannot grow the table unboundedly.
|
|
172
|
+
*/
|
|
173
|
+
export const MAX_DYNAMIC_TRUSTED_HOSTS = 64;
|
|
174
|
+
/**
|
|
175
|
+
* FIFO-bounded Set insert: past `max` entries the oldest one is evicted
|
|
176
|
+
* (a Set iterates in insertion order). The dynamic trusted-host table is fed
|
|
177
|
+
* by caller-controlled Host headers, so it must stay bounded; an evicted
|
|
178
|
+
* legitimate host re-adds itself on that device's next gated request.
|
|
179
|
+
*/
|
|
180
|
+
export function addBounded(set, value, max) {
|
|
181
|
+
if (set.has(value))
|
|
182
|
+
return;
|
|
183
|
+
if (set.size >= max) {
|
|
184
|
+
const oldest = set.values().next().value;
|
|
185
|
+
if (oldest !== undefined)
|
|
186
|
+
set.delete(oldest);
|
|
187
|
+
}
|
|
188
|
+
set.add(value);
|
|
189
|
+
}
|
|
87
190
|
/**
|
|
88
191
|
* The host authority of a configured public base URL, e.g. `foo.trycloudflare.com`
|
|
89
192
|
* from `https://foo.trycloudflare.com`. Undefined when the URL does not parse —
|
|
@@ -324,20 +427,45 @@ export class PairingEventsStream {
|
|
|
324
427
|
* @returns the exact routes to register on webServer.
|
|
325
428
|
*/
|
|
326
429
|
export function makeRoutes(deps) {
|
|
327
|
-
const { service,
|
|
430
|
+
const { service, requirePairingForLan = true } = deps;
|
|
328
431
|
const pairingRequired = () => typeof requirePairingForLan === 'function'
|
|
329
432
|
? requirePairingForLan()
|
|
330
433
|
: requirePairingForLan;
|
|
331
434
|
const events = new PairingEventsStream(service);
|
|
435
|
+
const dynamicTrustedHosts = new Set();
|
|
332
436
|
/** Loopback-only fence: the desktop panel's control endpoints. */
|
|
333
437
|
const loopbackFence = (req) => isTrustedApiRequest(req, []);
|
|
334
|
-
/** Phone-facing fence: loopback, the service's live LAN literals,
|
|
438
|
+
/** Phone-facing fence: loopback, the service's live LAN literals, configured public host, extra trusted hosts, or dynamically paired hosts. */
|
|
335
439
|
const lanFence = (req) => {
|
|
336
440
|
const publicHost = publicHostOf(service.publicBaseUrl);
|
|
337
441
|
// The service's LAN bases re-read per request: a hot rebind (the lan-bind
|
|
338
442
|
// toggle) updates them mid-process, and the fence must follow.
|
|
339
443
|
const bases = service.lanAddresses;
|
|
340
|
-
|
|
444
|
+
const extraHosts = typeof deps.trustedHosts === 'function' ? deps.trustedHosts() : (deps.trustedHosts ?? []);
|
|
445
|
+
const combined = [
|
|
446
|
+
...bases,
|
|
447
|
+
...(publicHost !== undefined ? [publicHost] : []),
|
|
448
|
+
...extraHosts,
|
|
449
|
+
...dynamicTrustedHosts,
|
|
450
|
+
];
|
|
451
|
+
if (isTrustedApiRequest(req, combined))
|
|
452
|
+
return true;
|
|
453
|
+
// If request carries a valid paired device cookie from a private LAN host (e.g. after service restart), trust and remember it
|
|
454
|
+
const privateLanHost = privateLanHostOf(req);
|
|
455
|
+
if (privateLanHost !== undefined) {
|
|
456
|
+
const cookieDeviceId = readCookie(req.headers.cookie, service.config.cookieName);
|
|
457
|
+
let queryDeviceId = null;
|
|
458
|
+
try {
|
|
459
|
+
queryDeviceId = new URL(req.url ?? '/', 'http://pair.invalid').searchParams.get('device');
|
|
460
|
+
}
|
|
461
|
+
catch { }
|
|
462
|
+
const deviceId = cookieDeviceId ?? queryDeviceId ?? undefined;
|
|
463
|
+
if (deviceId !== undefined && service.hasDevice(deviceId)) {
|
|
464
|
+
addBounded(dynamicTrustedHosts, privateLanHost, MAX_DYNAMIC_TRUSTED_HOSTS);
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
return false;
|
|
341
469
|
};
|
|
342
470
|
const requireMethod = (req, res, method) => {
|
|
343
471
|
if (req.method === method)
|
|
@@ -434,7 +562,8 @@ export function makeRoutes(deps) {
|
|
|
434
562
|
const handleAccept = async (req, res) => {
|
|
435
563
|
if (!requireMethod(req, res, 'POST'))
|
|
436
564
|
return;
|
|
437
|
-
|
|
565
|
+
const privateLanHost = privateLanHostOf(req);
|
|
566
|
+
if (!lanFence(req) && privateLanHost === undefined) {
|
|
438
567
|
writeJson(res, 403, { ok: false, code: 'forbidden' });
|
|
439
568
|
return;
|
|
440
569
|
}
|
|
@@ -451,9 +580,16 @@ export function makeRoutes(deps) {
|
|
|
451
580
|
const ua = req.headers['user-agent'];
|
|
452
581
|
const result = service.accept(payload.token, typeof ua === 'string' ? ua : undefined);
|
|
453
582
|
if (!result.ok) {
|
|
583
|
+
if (!lanFence(req)) {
|
|
584
|
+
writeJson(res, 403, { ok: false, code: 'forbidden' });
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
454
587
|
writeJson(res, result.code === 'used' ? 409 : 404, { ok: false, code: result.code });
|
|
455
588
|
return;
|
|
456
589
|
}
|
|
590
|
+
if (privateLanHost !== undefined) {
|
|
591
|
+
addBounded(dynamicTrustedHosts, privateLanHost, MAX_DYNAMIC_TRUSTED_HOSTS);
|
|
592
|
+
}
|
|
457
593
|
// No Secure attribute: LAN pairing runs over plain HTTP (the cookie must
|
|
458
594
|
// work there), and the same cookie rides HTTPS on the tunnel. Lax keeps
|
|
459
595
|
// top-level navigations working while blocking cross-site subrequests.
|
|
@@ -570,7 +706,8 @@ export function makeRoutes(deps) {
|
|
|
570
706
|
const handleAcceptPage = async (req, res) => {
|
|
571
707
|
if (!requireMethod(req, res, 'GET'))
|
|
572
708
|
return;
|
|
573
|
-
|
|
709
|
+
const privateLanHost = privateLanHostOf(req);
|
|
710
|
+
if (!lanFence(req) && privateLanHost === undefined) {
|
|
574
711
|
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
|
|
575
712
|
res.end('forbidden');
|
|
576
713
|
return;
|
|
@@ -585,6 +722,11 @@ export function makeRoutes(deps) {
|
|
|
585
722
|
const ua = req.headers['user-agent'];
|
|
586
723
|
const result = token === '' ? { ok: false, code: 'invalid' } : service.accept(token, typeof ua === 'string' ? ua : undefined);
|
|
587
724
|
if (!result.ok) {
|
|
725
|
+
if (!lanFence(req)) {
|
|
726
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
|
|
727
|
+
res.end('forbidden');
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
588
730
|
// accept() now refuses only expired/unknown/stopped tokens: a consumed
|
|
589
731
|
// token stays re-usable until its expiry or replacement, so a mobile
|
|
590
732
|
// flow that split across cookie contexts (camera preview, in-app
|
|
@@ -604,6 +746,9 @@ export function makeRoutes(deps) {
|
|
|
604
746
|
res.end(pairingFailurePage());
|
|
605
747
|
return;
|
|
606
748
|
}
|
|
749
|
+
if (privateLanHost !== undefined) {
|
|
750
|
+
addBounded(dynamicTrustedHosts, privateLanHost, MAX_DYNAMIC_TRUSTED_HOSTS);
|
|
751
|
+
}
|
|
607
752
|
// Land the paired device on the cookieless app page: the official shell
|
|
608
753
|
// served by this plugin, with the device id in the URL. No harness index
|
|
609
754
|
// gate, no browser-auth cookie hop - the channel gate accepts the device
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-remote-web-ui",
|
|
3
3
|
"description": "Scan-to-pair remote access for the dsh web GUI that shares one official interface: a QR beside the settings button pairs phones and PCs into the same Web GUI (a portrait-touch adaptation layer for phones, full desktop on PCs) through one-time tokens and revocable device sessions, with a LAN bind toggle, optional Cloudflare tunnel, and one-click family self-update",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.15",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"license": "Apache-2.0",
|
|
37
37
|
"dsh": {
|
|
38
38
|
"engines": {
|
|
39
|
-
"dsh": ">=0.1.2-
|
|
39
|
+
"dsh": ">=0.1.2-rc.1"
|
|
40
40
|
},
|
|
41
41
|
"bundle": {
|
|
42
42
|
"patch": "./cordis.patch.yml"
|
|
@@ -63,22 +63,22 @@
|
|
|
63
63
|
},
|
|
64
64
|
"devDependencies": {
|
|
65
65
|
"@deepseek-ai/cordis": "^4.0.2",
|
|
66
|
-
"@deepseek-ai/dsh-agent": "^0.1.2-
|
|
67
|
-
"@deepseek-ai/dsh-api-remotes": "^0.1.2-
|
|
68
|
-
"@deepseek-ai/dsh-api-session-controller": "^0.1.2-
|
|
69
|
-
"@deepseek-ai/dsh-api-workspace-controller": "^0.1.2-
|
|
70
|
-
"@deepseek-ai/dsh-client-connection": "^0.1.2-
|
|
71
|
-
"@deepseek-ai/dsh-client-locale": "^0.1.2-
|
|
72
|
-
"@deepseek-ai/dsh-client-store": "^0.1.2-
|
|
73
|
-
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-
|
|
74
|
-
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.2-
|
|
75
|
-
"@deepseek-ai/dsh-client-ui-settings": "^0.1.2-
|
|
76
|
-
"@deepseek-ai/dsh-client-ui-sidebar": "^0.1.2-
|
|
77
|
-
"@deepseek-ai/dsh-client-ui-slots": "^0.1.2-
|
|
78
|
-
"@deepseek-ai/dsh-commands": "^0.1.2-
|
|
79
|
-
"@deepseek-ai/dsh-host-webserver": "^0.1.2-
|
|
80
|
-
"@deepseek-ai/dsh-invariants": "^0.1.2-
|
|
81
|
-
"@deepseek-ai/dsh-settings": "^0.1.2-
|
|
66
|
+
"@deepseek-ai/dsh-agent": "^0.1.2-rc.1",
|
|
67
|
+
"@deepseek-ai/dsh-api-remotes": "^0.1.2-rc.1",
|
|
68
|
+
"@deepseek-ai/dsh-api-session-controller": "^0.1.2-rc.1",
|
|
69
|
+
"@deepseek-ai/dsh-api-workspace-controller": "^0.1.2-rc.1",
|
|
70
|
+
"@deepseek-ai/dsh-client-connection": "^0.1.2-rc.1",
|
|
71
|
+
"@deepseek-ai/dsh-client-locale": "^0.1.2-rc.1",
|
|
72
|
+
"@deepseek-ai/dsh-client-store": "^0.1.2-rc.1",
|
|
73
|
+
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.2-rc.1",
|
|
74
|
+
"@deepseek-ai/dsh-client-ui-renderer": "^0.1.2-rc.1",
|
|
75
|
+
"@deepseek-ai/dsh-client-ui-settings": "^0.1.2-rc.1",
|
|
76
|
+
"@deepseek-ai/dsh-client-ui-sidebar": "^0.1.2-rc.1",
|
|
77
|
+
"@deepseek-ai/dsh-client-ui-slots": "^0.1.2-rc.1",
|
|
78
|
+
"@deepseek-ai/dsh-commands": "^0.1.2-rc.1",
|
|
79
|
+
"@deepseek-ai/dsh-host-webserver": "^0.1.2-rc.1",
|
|
80
|
+
"@deepseek-ai/dsh-invariants": "^0.1.2-rc.1",
|
|
81
|
+
"@deepseek-ai/dsh-settings": "^0.1.2-rc.1",
|
|
82
82
|
"@testing-library/dom": "^10.4.1",
|
|
83
83
|
"@testing-library/react": "^16.3.2",
|
|
84
84
|
"@types/node": "^22.20.0",
|
|
@@ -62,6 +62,7 @@ export function RemoteEntry({ wide, t }: RemoteEntryProps) {
|
|
|
62
62
|
const stateRef = useRef(state)
|
|
63
63
|
useEffect(() => { stateRef.current = state }, [state])
|
|
64
64
|
const [copied, setCopied] = useState<boolean>(false)
|
|
65
|
+
const [copiedToken, setCopiedToken] = useState<boolean>(false)
|
|
65
66
|
const eventSource = useRef<EventSource | undefined>(undefined)
|
|
66
67
|
// Generation counter for the open flow: closing (or re-opening) the panel
|
|
67
68
|
// bumps it, so an in-flight issue() that resolves after a close does not
|
|
@@ -94,6 +95,7 @@ export function RemoteEntry({ wide, t }: RemoteEntryProps) {
|
|
|
94
95
|
return {
|
|
95
96
|
kind: 'ready',
|
|
96
97
|
url: result.url,
|
|
98
|
+
token: result.token,
|
|
97
99
|
expiresAt: result.expiresAt,
|
|
98
100
|
expired: Date.now() > result.expiresAt,
|
|
99
101
|
phase: 'waiting',
|
|
@@ -215,6 +217,14 @@ export function RemoteEntry({ wide, t }: RemoteEntryProps) {
|
|
|
215
217
|
})
|
|
216
218
|
}, [])
|
|
217
219
|
|
|
220
|
+
const handleCopyToken = useCallback((token: string) => {
|
|
221
|
+
void copyText(token).then((ok) => {
|
|
222
|
+
if (!ok) return
|
|
223
|
+
setCopiedToken(true)
|
|
224
|
+
window.setTimeout(() => { setCopiedToken(false) }, 1500)
|
|
225
|
+
})
|
|
226
|
+
}, [])
|
|
227
|
+
|
|
218
228
|
return (
|
|
219
229
|
<>
|
|
220
230
|
<div className={css.entryRow} data-rail={wide ? undefined : 'rail'}>
|
|
@@ -228,10 +238,12 @@ export function RemoteEntry({ wide, t }: RemoteEntryProps) {
|
|
|
228
238
|
t={t}
|
|
229
239
|
state={state}
|
|
230
240
|
copied={copied}
|
|
241
|
+
copiedToken={copiedToken}
|
|
231
242
|
onClose={closePanel}
|
|
232
243
|
onStop={handleStop}
|
|
233
244
|
onRefresh={handleRefresh}
|
|
234
245
|
onCopy={handleCopy}
|
|
246
|
+
onCopyToken={handleCopyToken}
|
|
235
247
|
onPickAddress={handlePickAddress}
|
|
236
248
|
onPickPublic={handlePickPublic}
|
|
237
249
|
onRevoke={handleRevoke}
|
|
@@ -41,6 +41,8 @@ export type PanelState =
|
|
|
41
41
|
address: string
|
|
42
42
|
/** Every constructible LAN literal (interface order). */
|
|
43
43
|
lanAddresses: string[]
|
|
44
|
+
/** The active pairing token itself (useful in Docker/reverse proxy topologies). */
|
|
45
|
+
token?: string
|
|
44
46
|
/** Whether this QR is built on the configured public (tunneled) base. */
|
|
45
47
|
public: boolean
|
|
46
48
|
/** The configured public (tunneled) base URL, when present. */
|
|
@@ -58,10 +60,12 @@ export interface RemotePanelProps {
|
|
|
58
60
|
t: TranslateNS<'remote'>
|
|
59
61
|
state: PanelState
|
|
60
62
|
copied: boolean
|
|
63
|
+
copiedToken?: boolean
|
|
61
64
|
onClose(): void
|
|
62
65
|
onStop(): void
|
|
63
66
|
onRefresh(): void
|
|
64
67
|
onCopy(url: string): void
|
|
68
|
+
onCopyToken?(token: string): void
|
|
65
69
|
/** Re-mint the QR against a different LAN address. */
|
|
66
70
|
onPickAddress(address: string): void
|
|
67
71
|
/** Re-mint the QR against the configured public (tunneled) base. */
|
|
@@ -89,7 +93,20 @@ function statusOf(
|
|
|
89
93
|
* @param props - copy, state, and actions.
|
|
90
94
|
* @returns the panel element tree.
|
|
91
95
|
*/
|
|
92
|
-
export function RemotePanel({
|
|
96
|
+
export function RemotePanel({
|
|
97
|
+
t,
|
|
98
|
+
state,
|
|
99
|
+
copied,
|
|
100
|
+
copiedToken,
|
|
101
|
+
onClose,
|
|
102
|
+
onStop,
|
|
103
|
+
onRefresh,
|
|
104
|
+
onCopy,
|
|
105
|
+
onCopyToken,
|
|
106
|
+
onPickAddress,
|
|
107
|
+
onPickPublic,
|
|
108
|
+
onRevoke,
|
|
109
|
+
}: RemotePanelProps) {
|
|
93
110
|
return (
|
|
94
111
|
<div className={css.panel} role="dialog" aria-modal="true" aria-label={t('title')}>
|
|
95
112
|
<div className={css.header}>
|
|
@@ -157,8 +174,26 @@ export function RemotePanel({ t, state, copied, onClose, onStop, onRefresh, onCo
|
|
|
157
174
|
{copied ? t('action.copied') : t('action.copyLink')}
|
|
158
175
|
</button>
|
|
159
176
|
</div>
|
|
177
|
+
{state.token !== undefined && state.token !== '' && (
|
|
178
|
+
<div className={css.pairLinkRow}>
|
|
179
|
+
<div className={css.pairLinkText}>
|
|
180
|
+
<span className={css.pairLinkLabel}>{t('pair.tokenLabel')}</span>
|
|
181
|
+
<code className={css.link} title={state.token}>{state.token}</code>
|
|
182
|
+
</div>
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
className={css.copyLink}
|
|
186
|
+
onClick={() => onCopyToken ? onCopyToken(state.token!) : onCopy(state.token!)}
|
|
187
|
+
>
|
|
188
|
+
<IconCopyOutline16 size={14} />
|
|
189
|
+
{copiedToken ? t('action.copiedToken') : t('action.copyToken')}
|
|
190
|
+
</button>
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
160
193
|
</div>
|
|
161
|
-
<p className={css.oneTimeHint}>
|
|
194
|
+
<p className={css.oneTimeHint}>
|
|
195
|
+
{t('pair.oneTimeHint')} {t('pair.dockerHint')}
|
|
196
|
+
</p>
|
|
162
197
|
{state.phase === 'stopped' && <p className={css.stoppedHint}>{t('stopped.hint')}</p>}
|
|
163
198
|
{state.tunnel !== undefined && state.tunnel.state !== 'running' && (
|
|
164
199
|
<p className={state.tunnel.state === 'failed' ? css.tunnelFailed : css.tunnelNote} role="status">
|
package/src/client/locales.ts
CHANGED
|
@@ -29,6 +29,8 @@ export const zh = {
|
|
|
29
29
|
'pair.expires': '二维码有效至 {time}',
|
|
30
30
|
'pair.expired': '二维码已过期,请刷新',
|
|
31
31
|
'pair.linkLabel': '配对链接',
|
|
32
|
+
'pair.tokenLabel': '配对令牌',
|
|
33
|
+
'pair.dockerHint': 'Docker 或反向代理环境下,可直接复制此令牌并在目标设备配对页面输入。',
|
|
32
34
|
'pair.oneTimeHint': '链接含一次性令牌;任一设备配对成功后立即失效,配对下一台设备请刷新二维码。',
|
|
33
35
|
'pair.failed.title': '配对失败',
|
|
34
36
|
'pair.failed.detail': '链接无效或已使用,请回到电脑端刷新二维码后重新扫码。',
|
|
@@ -52,7 +54,9 @@ export const zh = {
|
|
|
52
54
|
'action.refresh': '刷新二维码',
|
|
53
55
|
'action.copy': '复制链接',
|
|
54
56
|
'action.copyLink': '复制链接',
|
|
57
|
+
'action.copyToken': '复制令牌',
|
|
55
58
|
'action.copied': '已复制',
|
|
59
|
+
'action.copiedToken': '已复制令牌',
|
|
56
60
|
'devices.title': '已授权设备',
|
|
57
61
|
'devices.empty': '还没有已配对的设备。扫码或打开链接后会出现在这里。',
|
|
58
62
|
'devices.unknown': '未知设备',
|
|
@@ -199,6 +203,8 @@ export const en = {
|
|
|
199
203
|
'pair.expires': 'QR code valid until {time}',
|
|
200
204
|
'pair.expired': 'QR code expired — refresh it',
|
|
201
205
|
'pair.linkLabel': 'Pairing link',
|
|
206
|
+
'pair.tokenLabel': 'Pairing token',
|
|
207
|
+
'pair.dockerHint': 'In Docker or reverse proxy environments, copy this token to pair directly on the target device.',
|
|
202
208
|
'pair.oneTimeHint': 'The link carries one single-use token; it dies as soon as any device pairs. Refresh the QR to pair the next device.',
|
|
203
209
|
'pair.failed.title': 'Pairing failed',
|
|
204
210
|
'pair.failed.detail': 'The link is invalid or was already used. Refresh the QR code on your computer and scan again.',
|
|
@@ -222,7 +228,9 @@ export const en = {
|
|
|
222
228
|
'action.refresh': 'Refresh QR',
|
|
223
229
|
'action.copy': 'Copy link',
|
|
224
230
|
'action.copyLink': 'Copy link',
|
|
231
|
+
'action.copyToken': 'Copy token',
|
|
225
232
|
'action.copied': 'Copied',
|
|
233
|
+
'action.copiedToken': 'Token copied',
|
|
226
234
|
'devices.title': 'Authorized devices',
|
|
227
235
|
'devices.empty': 'No paired devices yet. Scan the QR or open the link to add one.',
|
|
228
236
|
'devices.unknown': 'Unknown device',
|
|
@@ -236,7 +236,6 @@ const ADAPT_CSS: readonly string[] = [
|
|
|
236
236
|
`body.${ACTIVE_CLASS} [data-dsh-plugin=\"task-board\"],`,
|
|
237
237
|
`body.${ACTIVE_CLASS} [data-dsh-plugin=\"git-graph\"],`,
|
|
238
238
|
`body.${ACTIVE_CLASS} [data-dsh-plugin=\"pet\"],`,
|
|
239
|
-
`body.${ACTIVE_CLASS} [data-dsh-plugin=\"perf\"],`,
|
|
240
239
|
`body.${ACTIVE_CLASS} [data-dsh-plugin=\"usage\"]{display:none !important}`,
|
|
241
240
|
// The official workbench (Files / source control) mounts into a
|
|
242
241
|
// full-viewport portal layer with no data-dsh-plugin root, so the plugin
|
|
@@ -921,15 +920,11 @@ export function startMobileAdapt(): void {
|
|
|
921
920
|
}
|
|
922
921
|
lanOrigFocus.call(this, options)
|
|
923
922
|
}
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
const t = e.target
|
|
930
|
-
if (t instanceof Element && (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT') && t.closest('[class$="_composerSeat"]') !== null) lastComposerTap = Date.now()
|
|
931
|
-
}, true)
|
|
932
|
-
}
|
|
923
|
+
document.addEventListener('pointerdown', (e) => {
|
|
924
|
+
if (!active) return
|
|
925
|
+
const t = e.target
|
|
926
|
+
if (t instanceof Element && (t.tagName === 'TEXTAREA' || t.tagName === 'INPUT') && t.closest('[class$="_composerSeat"]') !== null) lastComposerTap = Date.now()
|
|
927
|
+
}, true)
|
|
933
928
|
|
|
934
929
|
// The plugin apply() wires toggleSidebar/closeDetails to ctx.layout once
|
|
935
930
|
// it is live, and flips the master switch when the settings snapshot
|
|
@@ -11,8 +11,6 @@
|
|
|
11
11
|
* - the pairing routes (`/api/pair/*`) stay where they are — accept must
|
|
12
12
|
* work BEFORE a device is paired;
|
|
13
13
|
* - the update endpoints (`/api/update/*`) stay loopback-only;
|
|
14
|
-
* - desktop-launcher create/shutdown (`/api/dsh-desktop-launcher/*`) stay
|
|
15
|
-
* loopback-only (host-local files and process exit);
|
|
16
14
|
* - the family settings bridge (`/api/dsh-web-ui-settings/*`) stays
|
|
17
15
|
* loopback-only (same plane as SDK settings.*);
|
|
18
16
|
* - `/api/*` (SDK methods and `/api/<plugin>/...` plugin namespaces),
|
|
@@ -77,7 +75,6 @@ export function isLoopbackHostname(hostname: string): boolean {
|
|
|
77
75
|
export function shouldRewriteFetchPath(pathname: string): boolean {
|
|
78
76
|
if (pathname.startsWith(RULES.pairPrefix)) return false
|
|
79
77
|
if (pathname.startsWith(RULES.updatePrefix)) return false
|
|
80
|
-
if (pathname === RULES.desktopLauncherPrefix || pathname.startsWith(`${RULES.desktopLauncherPrefix}/`)) return false
|
|
81
78
|
if (pathname === RULES.settingsBridgePrefix || pathname.startsWith(`${RULES.settingsBridgePrefix}/`)) return false
|
|
82
79
|
if (pathname.startsWith(RULES.apiPrefix)) return true
|
|
83
80
|
if (pathname.startsWith(RULES.sidebarPrefix) || pathname === '/sidebar') return true
|
package/src/index.ts
CHANGED
|
@@ -126,6 +126,13 @@ export interface Config {
|
|
|
126
126
|
* warning (LAN-only behavior preserved). Ignored while `autoTunnel` is on.
|
|
127
127
|
*/
|
|
128
128
|
publicBaseUrl?: string
|
|
129
|
+
/**
|
|
130
|
+
* Extra trusted host authorities (exact host:port or port-less host) allowed
|
|
131
|
+
* to access pairing endpoints and remote web services. Useful in Docker, reverse proxy,
|
|
132
|
+
* or NAT environments where incoming request Host headers differ from the container's
|
|
133
|
+
* internal network addresses. Can also be set via the DSH_REMOTE_TRUSTED_HOSTS env var.
|
|
134
|
+
*/
|
|
135
|
+
trustedHosts?: string[]
|
|
129
136
|
/**
|
|
130
137
|
* Absolute path to a JSON file where paired device sessions are persisted.
|
|
131
138
|
* Defaults to `$DSH_HOME/remote-web-ui-devices.json` so a paired device
|
|
@@ -199,6 +206,7 @@ export const Config: z<Config> = z.object({
|
|
|
199
206
|
cookieName: z.string().min(1).default('dsh_pair'),
|
|
200
207
|
requirePairingForLan: z.boolean().default(true),
|
|
201
208
|
publicBaseUrl: z.string(),
|
|
209
|
+
trustedHosts: z.array(z.string()),
|
|
202
210
|
devicesFile: z.string(),
|
|
203
211
|
autoTunnel: z.boolean().default(false),
|
|
204
212
|
tunnelToken: z.string().role('secret'),
|
|
@@ -216,8 +224,9 @@ const SWEEP_INTERVAL_MS = 10_000
|
|
|
216
224
|
* which legitimately resolves to `undefined` when unset (the schema keeps it
|
|
217
225
|
* optional, so `Required` alone would over-narrow it to `string`).
|
|
218
226
|
*/
|
|
219
|
-
type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl' | 'devicesFile' | 'lanBind' | 'profile' | 'tunnelToken'>> & {
|
|
227
|
+
type ResolvedConfig = Required<Omit<Config, 'publicBaseUrl' | 'trustedHosts' | 'devicesFile' | 'lanBind' | 'profile' | 'tunnelToken'>> & {
|
|
220
228
|
publicBaseUrl: string | undefined
|
|
229
|
+
trustedHosts: string[] | undefined
|
|
221
230
|
devicesFile: string
|
|
222
231
|
/** undefined until the user flips the toggle once; undefined never writes the patch. */
|
|
223
232
|
lanBind: boolean | undefined
|
|
@@ -259,6 +268,7 @@ const DEFAULTS: ResolvedConfig = {
|
|
|
259
268
|
cookieName: 'dsh_pair',
|
|
260
269
|
requirePairingForLan: true,
|
|
261
270
|
publicBaseUrl: undefined,
|
|
271
|
+
trustedHosts: undefined,
|
|
262
272
|
devicesFile: defaultDevicesFile(),
|
|
263
273
|
autoTunnel: false,
|
|
264
274
|
tunnelToken: undefined,
|
|
@@ -276,6 +286,7 @@ const DEFAULTS: ResolvedConfig = {
|
|
|
276
286
|
export const apply = mountOnce('@linxin666/dsh-remote-web-ui', applyImpl)
|
|
277
287
|
|
|
278
288
|
function applyImpl(ctx: Context, config?: Config): void {
|
|
289
|
+
const envPublicBase = process.env.DSH_REMOTE_PUBLIC_BASE_URL?.trim() || undefined
|
|
279
290
|
const resolved: ResolvedConfig = {
|
|
280
291
|
tokenTtlMs: config?.tokenTtlMs ?? DEFAULTS.tokenTtlMs,
|
|
281
292
|
offlineAfterMs: config?.offlineAfterMs ?? DEFAULTS.offlineAfterMs,
|
|
@@ -283,7 +294,8 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
283
294
|
idleExpireMs: config?.idleExpireMs ?? DEFAULTS.idleExpireMs,
|
|
284
295
|
cookieName: config?.cookieName ?? DEFAULTS.cookieName,
|
|
285
296
|
requirePairingForLan: config?.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
|
|
286
|
-
publicBaseUrl: config?.publicBaseUrl,
|
|
297
|
+
publicBaseUrl: config?.publicBaseUrl ?? envPublicBase,
|
|
298
|
+
trustedHosts: config?.trustedHosts,
|
|
287
299
|
devicesFile: config?.devicesFile ?? DEFAULTS.devicesFile,
|
|
288
300
|
autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
|
|
289
301
|
tunnelToken: config?.tunnelToken,
|
|
@@ -305,7 +317,8 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
305
317
|
idleExpireMs: value.idleExpireMs ?? DEFAULTS.idleExpireMs,
|
|
306
318
|
cookieName: value.cookieName ?? DEFAULTS.cookieName,
|
|
307
319
|
requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
|
|
308
|
-
publicBaseUrl: value.publicBaseUrl,
|
|
320
|
+
publicBaseUrl: value.publicBaseUrl ?? envPublicBase,
|
|
321
|
+
trustedHosts: value.trustedHosts,
|
|
309
322
|
devicesFile: value.devicesFile ?? DEFAULTS.devicesFile,
|
|
310
323
|
autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
|
|
311
324
|
tunnelToken: value.tunnelToken,
|
|
@@ -445,13 +458,15 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
445
458
|
// directory on update, so a boot-time captured path would fail to read
|
|
446
459
|
// after a successful update; versions are re-read from disk per check.
|
|
447
460
|
const requireFromHost = createRequire(import.meta.url)
|
|
448
|
-
|
|
461
|
+
/** Host-process resolve that degrades to "not installed" (undefined) instead of throwing. */
|
|
462
|
+
const hostResolve = (specifier: string): string | undefined => {
|
|
449
463
|
try {
|
|
450
464
|
return requireFromHost.resolve(specifier)
|
|
451
465
|
} catch {
|
|
452
466
|
return undefined
|
|
453
467
|
}
|
|
454
|
-
}
|
|
468
|
+
}
|
|
469
|
+
const resolveAnchorPath = (): string | undefined => resolveAnchorManifest(hostResolve)
|
|
455
470
|
|
|
456
471
|
const releaseNotesCache = new Map<string, { at: number; notes?: UpdateReleaseNotes }>()
|
|
457
472
|
const fetchReleaseNotesCached = async (version: string): Promise<UpdateReleaseNotes | undefined> => {
|
|
@@ -467,13 +482,7 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
467
482
|
fence: request => isTrustedApiRequest(request, []),
|
|
468
483
|
check: () => checkUpdates({
|
|
469
484
|
anchorManifestPath: resolveAnchorPath(),
|
|
470
|
-
resolve:
|
|
471
|
-
try {
|
|
472
|
-
return requireFromHost.resolve(specifier)
|
|
473
|
-
} catch {
|
|
474
|
-
return undefined
|
|
475
|
-
}
|
|
476
|
-
},
|
|
485
|
+
resolve: hostResolve,
|
|
477
486
|
fetchLatest: name => fetchLatestVersion(name, fetch),
|
|
478
487
|
fetchReleaseNotes: fetchReleaseNotesCached,
|
|
479
488
|
}),
|
|
@@ -497,13 +506,7 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
497
506
|
run: { profileDir: target.profileDir, packages: target.packages },
|
|
498
507
|
check: {
|
|
499
508
|
anchorManifestPath: resolveAnchorPath(),
|
|
500
|
-
resolve:
|
|
501
|
-
try {
|
|
502
|
-
return requireFromHost.resolve(specifier)
|
|
503
|
-
} catch {
|
|
504
|
-
return undefined
|
|
505
|
-
}
|
|
506
|
-
},
|
|
509
|
+
resolve: hostResolve,
|
|
507
510
|
fetchLatest: name => fetchLatestVersion(name, fetch),
|
|
508
511
|
fetchReleaseNotes: fetchReleaseNotesCached,
|
|
509
512
|
},
|
|
@@ -589,10 +592,29 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
589
592
|
const routes = [
|
|
590
593
|
...makeRoutes({
|
|
591
594
|
service,
|
|
592
|
-
lanAddresses: service.lanAddresses,
|
|
593
595
|
requirePairingForLan: () => resolve().requirePairingForLan,
|
|
594
596
|
lanBindStatus,
|
|
595
597
|
indexDocument: fetchAppShell,
|
|
598
|
+
trustedHosts: () => {
|
|
599
|
+
const list: string[] = []
|
|
600
|
+
const envHosts = process.env.DSH_REMOTE_TRUSTED_HOSTS
|
|
601
|
+
if (typeof envHosts === 'string' && envHosts.trim() !== '') {
|
|
602
|
+
for (const item of envHosts.split(',')) {
|
|
603
|
+
const trimmed = item.trim()
|
|
604
|
+
if (trimmed !== '') list.push(trimmed)
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
const cfgHosts = resolve().trustedHosts
|
|
608
|
+
if (Array.isArray(cfgHosts)) {
|
|
609
|
+
for (const item of cfgHosts) {
|
|
610
|
+
if (typeof item === 'string') {
|
|
611
|
+
const trimmed = item.trim()
|
|
612
|
+
if (trimmed !== '') list.push(trimmed)
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return list
|
|
617
|
+
},
|
|
596
618
|
}),
|
|
597
619
|
// The remote desktop channel: policy-gated `/remote` prefix that
|
|
598
620
|
// re-issues fenced paths to loopback (see remote-api.ts). The live
|
|
@@ -852,13 +874,24 @@ function applyImpl(ctx: Context, config?: Config): void {
|
|
|
852
874
|
}), 'remote-web-ui: remote channel boot patch')
|
|
853
875
|
|
|
854
876
|
ctx.inject(['settings'], (settingsCtx) => {
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
877
|
+
try {
|
|
878
|
+
if (typeof settingsCtx.settings?.installSection === 'function') {
|
|
879
|
+
settingsCtx.settings.installSection(ctx, REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, config ?? {}, {
|
|
880
|
+
setSource: (source) => {
|
|
881
|
+
current = source
|
|
882
|
+
sync()
|
|
883
|
+
},
|
|
884
|
+
onChange: sync,
|
|
885
|
+
})
|
|
886
|
+
} else if (typeof settingsCtx.settings?.register === 'function') {
|
|
887
|
+
const scope = settingsCtx.settings.register(REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, { base: config ?? {} })
|
|
888
|
+
current = () => scope?.get?.() ?? (config ?? {})
|
|
889
|
+
scope?.watch?.(() => { sync() })
|
|
858
890
|
sync()
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
|
|
891
|
+
}
|
|
892
|
+
} catch {
|
|
893
|
+
// Defensive fallback against settings registration differences
|
|
894
|
+
}
|
|
862
895
|
})
|
|
863
896
|
sync()
|
|
864
897
|
}
|