@linxin666/dsh-remote-web-ui 0.4.5 → 0.4.7
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 -13
- package/README.zh.md +13 -13
- package/lib/client.js +25 -92
- package/lib/client.js.map +1 -1
- package/lib/index.js +408 -53
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +3 -15
- package/lib/types/client/RemotePanel.d.ts +1 -9
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +7 -4
- package/lib/types/client/locales.d.ts +4 -8
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +4 -8
- package/lib/types/client/mobile-adapt.d.ts.map +1 -1
- package/lib/types/client/mobile-adapt.js +30 -3
- package/lib/types/client/plugin-card-seat.d.ts +5 -0
- package/lib/types/client/plugin-card-seat.d.ts.map +1 -1
- package/lib/types/client/plugin-card-seat.js +2 -0
- package/lib/types/client/settings-entry-form.d.ts.map +1 -1
- package/lib/types/client/settings-entry-form.js +0 -1
- package/lib/types/detached-work.d.ts +7 -7
- package/lib/types/detached-work.d.ts.map +1 -1
- package/lib/types/detached-work.js +9 -8
- package/lib/types/index.d.ts +14 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +67 -17
- package/lib/types/lan-source.d.ts +65 -0
- package/lib/types/lan-source.d.ts.map +1 -0
- package/lib/types/lan-source.js +113 -0
- package/lib/types/lan.d.ts +49 -3
- package/lib/types/lan.d.ts.map +1 -1
- package/lib/types/lan.js +120 -6
- package/lib/types/remote-api.d.ts +7 -2
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +17 -2
- package/lib/types/remote-channel-boot.d.ts.map +1 -1
- package/lib/types/remote-channel-boot.js +4 -1
- package/lib/types/routes.d.ts +71 -9
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +157 -27
- package/lib/types/tunnel-plan.d.ts +8 -2
- package/lib/types/tunnel-plan.d.ts.map +1 -1
- package/lib/types/tunnel-plan.js +9 -1
- package/package.json +20 -25
- package/src/client/RemoteEntry.tsx +2 -18
- package/src/client/RemotePanel.tsx +9 -43
- package/src/client/locales.ts +4 -8
- package/src/client/mobile-adapt.ts +30 -3
- package/src/client/plugin-card-seat.ts +7 -0
- package/src/client/remote.module.css +2 -48
- package/src/client/settings-entry-form.ts +0 -1
- package/src/detached-work.ts +9 -8
- package/src/index.ts +77 -17
- package/src/lan-source.ts +132 -0
- package/src/lan.ts +126 -6
- package/src/remote-api.ts +24 -4
- package/src/remote-channel-boot.ts +4 -1
- package/src/routes.ts +178 -27
- package/src/tunnel-plan.ts +9 -2
- package/lib/invariant.js +0 -21
- package/lib/types/invariant.d.ts +0 -16
- package/lib/types/invariant.d.ts.map +0 -1
- package/lib/types/invariant.js +0 -23
- package/src/invariant.ts +0 -32
package/src/lan.ts
CHANGED
|
@@ -5,17 +5,137 @@
|
|
|
5
5
|
* with, so the same non-internal IPv4 derivation applies here — an external
|
|
6
6
|
* plugin cannot read the CLI's sampled snapshot, but the fence accepts
|
|
7
7
|
* exactly these literals, which is the property that matters.
|
|
8
|
+
*
|
|
9
|
+
* One derivation is deliberately narrower than the CLI's: everything this
|
|
10
|
+
* module returns is advertised to a phone (the QR base, the landing page's
|
|
11
|
+
* LAN probe, and the fence's trusted-literal list), and an advertised literal
|
|
12
|
+
* the peer can never reach costs a phone the whole pairing attempt. A macOS
|
|
13
|
+
* host running a fake-IP proxy (Clash/Surge/Mihomo) samples its TUN gateway as
|
|
14
|
+
* `198.18.0.1`, which is a benchmarking-range address no LAN peer can route
|
|
15
|
+
* to; the old derivation advertised it like any other interface. The filter
|
|
16
|
+
* below drops the interface kinds and address ranges a peer cannot reach, and
|
|
17
|
+
* keeps everything else — RFC1918, ULA-side IPv4 and a routable public literal
|
|
18
|
+
* (a VPS whose NIC is its only reachable base must stay advertisable).
|
|
19
|
+
*
|
|
20
|
+
* @module @linxin666/dsh-remote-web-ui/lan
|
|
8
21
|
*/
|
|
9
22
|
|
|
10
23
|
import { networkInterfaces } from 'node:os'
|
|
11
24
|
|
|
12
25
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
26
|
+
* Interface-name stems of tunnels and VPN adapters: interfaces whose address
|
|
27
|
+
* is a private point-to-point endpoint on this machine, never an authority a
|
|
28
|
+
* peer on the local network can dial. Matching is case-insensitive and
|
|
29
|
+
* anchored at the start, so `utun0`/`utun4` both match while a hypothetical
|
|
30
|
+
* `en0utun` does not. Windows reports adapter *descriptions* rather than POSIX
|
|
31
|
+
* stems (`Tailscale`, `WireGuard Tunnel`, `OpenVPN Wintun`), which the same
|
|
32
|
+
* list covers by prefix.
|
|
33
|
+
*/
|
|
34
|
+
const TUNNEL_INTERFACE_PREFIXES: readonly string[] = [
|
|
35
|
+
'utun', 'tun', 'tap', 'ppp', 'ipsec', 'wg', 'wireguard', 'tailscale', 'nordlynx', 'proton', 'openvpn', 'zt',
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Interface-name stems of virtual machine and container bridges: `docker0`,
|
|
40
|
+
* `vethXXXX`, `br-<id>`, `virbr0`, `vmnet8`, `vboxnet0` and the Hyper-V
|
|
41
|
+
* `vEthernet` adapters. Their RFC1918 addresses belong to a NAT the host
|
|
42
|
+
* shares with its own guests, so a phone on the physical LAN cannot reach
|
|
43
|
+
* them; `bridge0`/`br0` are deliberately absent because a real bridged LAN
|
|
44
|
+
* (macOS Internet Sharing) is exactly the address a phone does reach.
|
|
45
|
+
*/
|
|
46
|
+
const VIRTUAL_INTERFACE_PREFIXES: readonly string[] = [
|
|
47
|
+
'docker', 'veth', 'virbr', 'vmnet', 'vboxnet', 'vethernet', 'br-',
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* IPv4 ranges no peer on the local network can reach. The list is a denial
|
|
52
|
+
* list rather than an RFC1918 allowlist on purpose: a host whose only
|
|
53
|
+
* interface is a routable public literal (a VPS, a standalone server) would
|
|
54
|
+
* otherwise lose its only constructible base and fall back to the
|
|
55
|
+
* `lan-required` banner.
|
|
56
|
+
* @param octets - the four address octets, in order.
|
|
57
|
+
* @returns true when an address in this range must not be advertised.
|
|
58
|
+
*/
|
|
59
|
+
function isUnreachableIPv4(octets: readonly number[]): boolean {
|
|
60
|
+
const [a = 0, b = 0] = octets
|
|
61
|
+
// 0.0.0.0/8 "this network": never a usable address.
|
|
62
|
+
if (a === 0) return true
|
|
63
|
+
// 127.0.0.0/8 loopback (node already marks it internal; kept as the belt).
|
|
64
|
+
if (a === 127) return true
|
|
65
|
+
// 100.64.0.0/10 carrier-grade NAT: Tailscale and ISP-level NAT hand out
|
|
66
|
+
// addresses here, and a peer on the physical LAN cannot route to them.
|
|
67
|
+
if (a === 100 && b >= 64 && b <= 127) return true
|
|
68
|
+
// 169.254.0.0/16 link-local (APIPA / self-assigned).
|
|
69
|
+
if (a === 169 && b === 254) return true
|
|
70
|
+
// 198.18.0.0/15 benchmarking: the range a fake-IP TUN gateway takes.
|
|
71
|
+
if (a === 198 && (b === 18 || b === 19)) return true
|
|
72
|
+
// 224.0.0.0/4 multicast and 240.0.0.0/4 reserved (includes the broadcast).
|
|
73
|
+
if (a >= 224) return true
|
|
74
|
+
return false
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Whether an interface name belongs to a tunnel, VPN adapter, or virtual
|
|
79
|
+
* machine/container bridge, so its address is not an authority a LAN peer
|
|
80
|
+
* can dial.
|
|
81
|
+
* @param name - the interface name from `os.networkInterfaces()`.
|
|
82
|
+
* @returns true when the interface must not be advertised.
|
|
83
|
+
*/
|
|
84
|
+
export function isUnadvertisableInterface(name: string): boolean {
|
|
85
|
+
const lower = name.toLowerCase()
|
|
86
|
+
return [...TUNNEL_INTERFACE_PREFIXES, ...VIRTUAL_INTERFACE_PREFIXES].some(prefix => lower.startsWith(prefix))
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Whether an IPv4 literal can be advertised to a pairing peer.
|
|
91
|
+
* @param address - a dotted-quad IPv4 literal.
|
|
92
|
+
* @returns true when a peer could plausibly reach it.
|
|
93
|
+
*/
|
|
94
|
+
export function isAdvertisableIPv4(address: string): boolean {
|
|
95
|
+
const parts = address.split('.')
|
|
96
|
+
if (parts.length !== 4) return false
|
|
97
|
+
const octets = parts.map(part => (/^\d{1,3}$/.test(part) ? Number(part) : Number.NaN))
|
|
98
|
+
if (octets.some(octet => !Number.isInteger(octet) || octet > 255)) return false
|
|
99
|
+
return !isUnreachableIPv4(octets)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** One sampled interface address. */
|
|
103
|
+
export interface LanCandidate {
|
|
104
|
+
/** The interface name the address was sampled from. */
|
|
105
|
+
iface: string
|
|
106
|
+
/** The IPv4 literal. */
|
|
107
|
+
address: string
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The `networkInterfaces()` shape, injectable so the filter is testable. */
|
|
111
|
+
export type NetworkInterfaceMap = ReturnType<typeof networkInterfaces>
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Every IPv4 interface address this machine can advertise to a pairing peer:
|
|
115
|
+
* non-internal, on a non-tunnel and non-virtual interface, outside the ranges
|
|
116
|
+
* no peer can route to.
|
|
117
|
+
* @param interfaces - the sampled interface map (defaults to the live one).
|
|
118
|
+
* @returns the candidates in interface order (possibly empty).
|
|
119
|
+
*/
|
|
120
|
+
export function lanIPv4Candidates(interfaces: NetworkInterfaceMap = networkInterfaces()): LanCandidate[] {
|
|
121
|
+
const candidates: LanCandidate[] = []
|
|
122
|
+
for (const [name, addresses] of Object.entries(interfaces)) {
|
|
123
|
+
if (isUnadvertisableInterface(name)) continue
|
|
124
|
+
for (const iface of addresses ?? []) {
|
|
125
|
+
if (iface.family !== 'IPv4' || iface.internal) continue
|
|
126
|
+
if (!isAdvertisableIPv4(iface.address)) continue
|
|
127
|
+
candidates.push({ iface: name, address: iface.address })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return candidates
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Non-internal, reachable IPv4 interface addresses of this machine — the
|
|
135
|
+
* IP-literal authorities an all-interfaces bind is reachable by on the LAN.
|
|
136
|
+
* @param interfaces - the sampled interface map (defaults to the live one).
|
|
15
137
|
* @returns the addresses in interface order (possibly empty).
|
|
16
138
|
*/
|
|
17
|
-
export function lanIPv4Addresses(): string[] {
|
|
18
|
-
return
|
|
19
|
-
.filter((iface): iface is NonNullable<typeof iface> => { return iface !== undefined && iface.family === 'IPv4' && !iface.internal })
|
|
20
|
-
.map(iface => iface.address)
|
|
139
|
+
export function lanIPv4Addresses(interfaces: NetworkInterfaceMap = networkInterfaces()): string[] {
|
|
140
|
+
return lanIPv4Candidates(interfaces).map(candidate => candidate.address)
|
|
21
141
|
}
|
package/src/remote-api.ts
CHANGED
|
@@ -73,8 +73,13 @@ function isSafeSegment(segment: string): boolean {
|
|
|
73
73
|
export interface RemoteApiDeps {
|
|
74
74
|
/** The pairing service (device gate + cookie name). */
|
|
75
75
|
service: PairingService
|
|
76
|
-
/**
|
|
77
|
-
|
|
76
|
+
/**
|
|
77
|
+
* Local webServer port the loopback proxy connects to, read per request:
|
|
78
|
+
* `undefined` before the listener binds (a recompose pass can run this
|
|
79
|
+
* apply ahead of the bind), in which case the handlers refuse with 503
|
|
80
|
+
* instead of minting an `:undefined` URL.
|
|
81
|
+
*/
|
|
82
|
+
port: () => number | undefined
|
|
78
83
|
/**
|
|
79
84
|
* The process's inner browser-auth credential attached to re-issued
|
|
80
85
|
* requests (the connection plugin's /api route enforces that cookie and
|
|
@@ -140,7 +145,7 @@ export function pairedDeviceIdOf(req: IncomingMessage, service: PairingService):
|
|
|
140
145
|
* @returns the routes to register on webServer.
|
|
141
146
|
*/
|
|
142
147
|
export function makeRemoteApiRoutes(deps: RemoteApiDeps): WebRoute[] {
|
|
143
|
-
const { service
|
|
148
|
+
const { service } = deps
|
|
144
149
|
|
|
145
150
|
const handler = (req: IncomingMessage, res: ServerResponse): void => {
|
|
146
151
|
// Paired credential first — before any bytes are buffered or forwarded.
|
|
@@ -175,6 +180,14 @@ export function makeRemoteApiRoutes(deps: RemoteApiDeps): WebRoute[] {
|
|
|
175
180
|
return
|
|
176
181
|
}
|
|
177
182
|
|
|
183
|
+
const port = deps.port()
|
|
184
|
+
if (port === undefined) {
|
|
185
|
+
// The listener has not bound yet (a recompose pass): refuse instead of
|
|
186
|
+
// minting an http://127.0.0.1:undefined target; the desktop retries.
|
|
187
|
+
req.resume()
|
|
188
|
+
envelopeError(res, 503, 'invalid-request', 'port-unbound', 'the local web server port is not bound yet; retry shortly')
|
|
189
|
+
return
|
|
190
|
+
}
|
|
178
191
|
proxyLoopbackHttp(req, res, port, `${inner}${url.search}`, deps.auth)
|
|
179
192
|
}
|
|
180
193
|
|
|
@@ -217,7 +230,7 @@ export function upgradeInnerPath(reqUrl: string | undefined, fallbackPath: strin
|
|
|
217
230
|
* @returns the upgrade routes to register on webServer.
|
|
218
231
|
*/
|
|
219
232
|
export function makeRemoteApiUpgradeRoutes(deps: RemoteApiDeps): WebUpgradeRoute[] {
|
|
220
|
-
const { service
|
|
233
|
+
const { service } = deps
|
|
221
234
|
|
|
222
235
|
const handlerFor = (fallbackPath: string) => (req: IncomingMessage, socket: Duplex, head: Buffer): void => {
|
|
223
236
|
// WebSocket handshakes cannot carry headers from the Web API, so the
|
|
@@ -234,6 +247,13 @@ export function makeRemoteApiUpgradeRoutes(deps: RemoteApiDeps): WebUpgradeRoute
|
|
|
234
247
|
socket.destroy()
|
|
235
248
|
return
|
|
236
249
|
}
|
|
250
|
+
const port = deps.port()
|
|
251
|
+
if (port === undefined) {
|
|
252
|
+
// Same unbound-port refusal as the HTTP leg: no :undefined handshake.
|
|
253
|
+
socket.write('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n')
|
|
254
|
+
socket.destroy()
|
|
255
|
+
return
|
|
256
|
+
}
|
|
237
257
|
const inner = upgradeInnerPath(req.url, fallbackPath)
|
|
238
258
|
const denied = loopbackOnlyDenial(inner.split('?')[0] ?? inner)
|
|
239
259
|
if (denied !== undefined) {
|
|
@@ -204,7 +204,7 @@ export function buildRemoteChannelBootScript(rules: RemoteChannelRules = REMOTE_
|
|
|
204
204
|
/** Boot-watchdog latch: one self-reload per session while the boot is broken. */
|
|
205
205
|
export const BOOT_WATCHDOG_KEY = 'dsh-remote-boot-reload'
|
|
206
206
|
|
|
207
|
-
/**
|
|
207
|
+
/** Post-load wait for the app's first conversation surface before the reload. */
|
|
208
208
|
const BOOT_WATCHDOG_WAIT_MS = 15_000
|
|
209
209
|
|
|
210
210
|
/** Watchdog poll cadence. */
|
|
@@ -227,6 +227,9 @@ export function buildBootWatchdogScript(waitMs = BOOT_WATCHDOG_WAIT_MS, pollMs =
|
|
|
227
227
|
';function wBoot(){try{return !!(w.document&&w.document.querySelector&&(w.document.querySelector("[data-conversation-scroll]")||w.document.querySelector("[data-slot=\\"conversation\\"]")))}catch(e){return false}}' +
|
|
228
228
|
'function wTick(n){try{' +
|
|
229
229
|
'if(wBoot()){try{w.sessionStorage.removeItem(' + key + ')}catch(e){}return}' +
|
|
230
|
+
// Parse-time installation must not spend the recovery budget downloading
|
|
231
|
+
// the initial scripts/styles over a slow remote link (issue #1845).
|
|
232
|
+
'if(!w.document||w.document.readyState!=="complete"){w.setTimeout(function(){wTick(0)},' + pollMs + ');return}' +
|
|
230
233
|
'if(n<' + waitMs + '){w.setTimeout(function(){wTick(n+' + pollMs + ')},' + pollMs + ');return}' +
|
|
231
234
|
'var done=false;try{done=w.sessionStorage.getItem(' + key + ')==="1"}catch(e){}' +
|
|
232
235
|
'if(done)return;' +
|
package/src/routes.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { isLoopbackClient, readCookie } from './gate.ts'
|
|
|
29
29
|
import { readJsonBody, writeJson } from './http.ts'
|
|
30
30
|
import { createPairGrantStore, type PairGrantStore } from './pair-grant.ts'
|
|
31
31
|
import { REMOTE_HOST_GRANT_GLOBAL } from './remote-channel-rules.ts'
|
|
32
|
+
import { appShellLanSwitchScript, type LanSourceHop } from './lan-source.ts'
|
|
32
33
|
|
|
33
34
|
/**
|
|
34
35
|
* Browser-trust fence for the /api/pair routes, mirroring the connection
|
|
@@ -293,19 +294,27 @@ const COOKIE_MAX_AGE_SEC = 365 * 24 * 60 * 60
|
|
|
293
294
|
* The cookieless device credential: pass the device id from the /pair-app
|
|
294
295
|
* URL into sessionStorage (and localStorage for tab reloads) before any app
|
|
295
296
|
* script runs - same key the boot patch and the channel gate read. The
|
|
296
|
-
* replaceState
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
297
|
+
* replaceState drops the one-time grant from the address bar and leaves the
|
|
298
|
+
* SPA on a path this plugin answers again; which path depends on the origin.
|
|
299
|
+
*
|
|
300
|
+
* A secure context registers the reopen service worker in the same breath,
|
|
301
|
+
* and that worker owns navigations to '/', so the SPA keeps its canonical
|
|
302
|
+
* root. A plain-HTTP LAN origin is never a secure context, so the worker
|
|
303
|
+
* cannot exist there and '/' falls through to the harness index gate the
|
|
304
|
+
* cookieless flow can never satisfy (issue #1867). The address bar therefore
|
|
305
|
+
* keeps the plugin-owned landing path, which re-validates the device
|
|
306
|
+
* credential on every reload and serves the bilingual re-pair page once the
|
|
307
|
+
* pairing is gone. Both targets are bare paths: the one-time grant never
|
|
308
|
+
* survives the rewrite, and no credential is ever left in the URL.
|
|
301
309
|
*/
|
|
302
310
|
export const APP_DEVICE_STORAGE_KEY = 'dsh-remote-device'
|
|
303
311
|
|
|
304
312
|
export function appShellCaptureScript(deviceId: string): string {
|
|
305
313
|
const safeId = JSON.stringify(deviceId)
|
|
306
314
|
const grantGlobal = JSON.stringify(REMOTE_HOST_GRANT_GLOBAL)
|
|
315
|
+
const reopenPath = JSON.stringify(PAIR_PATHS.appPage)
|
|
307
316
|
const register = `try{if('serviceWorker' in navigator){navigator.serviceWorker.register(${JSON.stringify(PAIR_PATHS.appServiceWorker)},{scope:'/'}).catch(function(e){})}}catch(e){}`
|
|
308
|
-
return `<script>(function(){try{sessionStorage.setItem(${JSON.stringify(APP_DEVICE_STORAGE_KEY)},${safeId});}catch(e){}try{history.replaceState(null,'','/')}catch(e){}try{window[${grantGlobal}]=true}catch(e){}${register}})()</script>`
|
|
317
|
+
return `<script>(function(){try{sessionStorage.setItem(${JSON.stringify(APP_DEVICE_STORAGE_KEY)},${safeId});}catch(e){}try{history.replaceState(null,'',window.isSecureContext?'/':${reopenPath})}catch(e){}try{window[${grantGlobal}]=true}catch(e){}${register}})()</script>`
|
|
309
318
|
}
|
|
310
319
|
|
|
311
320
|
/**
|
|
@@ -315,9 +324,19 @@ export function appShellCaptureScript(deviceId: string): string {
|
|
|
315
324
|
* hook only when this server-served marker is already set (see
|
|
316
325
|
* remote-channel-boot.ts), so host mode is granted by the device-gated landing
|
|
317
326
|
* instead of being asserted from the origin.
|
|
327
|
+
*
|
|
328
|
+
* A landing reached on a base other than the machine's own LAN origin also
|
|
329
|
+
* carries the source-decision script (lan-source.ts): the phone decides at
|
|
330
|
+
* connect time whether a LAN candidate answers, and switches its origin when
|
|
331
|
+
* one does. It runs after the capture script so the granted page is fully
|
|
332
|
+
* usable before the probe's outcome can move it.
|
|
333
|
+
* @param html - the official index document.
|
|
334
|
+
* @param deviceId - the device session this landing is being served for.
|
|
335
|
+
* @param hop - the LAN candidates to probe, when a switch is possible.
|
|
336
|
+
* @returns the patched document.
|
|
318
337
|
*/
|
|
319
|
-
export function patchAppShell(html: string, deviceId: string): string {
|
|
320
|
-
const script = appShellCaptureScript(deviceId)
|
|
338
|
+
export function patchAppShell(html: string, deviceId: string, hop?: LanSourceHop): string {
|
|
339
|
+
const script = appShellCaptureScript(deviceId) + (hop === undefined ? '' : appShellLanSwitchScript(hop))
|
|
321
340
|
const head = /<head[^>]*>/i.exec(html)
|
|
322
341
|
if (head === null) return script + html
|
|
323
342
|
const end = head.index + head[0].length
|
|
@@ -332,9 +351,10 @@ export function patchAppShell(html: string, deviceId: string): string {
|
|
|
332
351
|
* worker owns navigations to `/` instead: network-first through /pair-app
|
|
333
352
|
* (which validates the device cookie and refreshes its presence), the
|
|
334
353
|
* cached shell for offline opens, and a pass-through of the original
|
|
335
|
-
* request when the plugin no longer answers.
|
|
336
|
-
* secure
|
|
337
|
-
*
|
|
354
|
+
* request when the plugin no longer answers. A plain-HTTP LAN origin is
|
|
355
|
+
* never a secure context, so the worker cannot exist there; the capture
|
|
356
|
+
* script keeps the plugin-owned landing path in the address bar instead,
|
|
357
|
+
* and that landing re-validates the device credential on every reload.
|
|
338
358
|
*
|
|
339
359
|
* Kept as a plain string (same pattern as the capture script): it must load
|
|
340
360
|
* with no build step, run on every JS engine that ships service workers,
|
|
@@ -435,6 +455,12 @@ export const PAIR_PATHS = {
|
|
|
435
455
|
acceptPage: '/pair-accept',
|
|
436
456
|
/** The cookieless app landing: serves the official shell for a paired device. */
|
|
437
457
|
appPage: '/pair-app',
|
|
458
|
+
/**
|
|
459
|
+
* The landing page's LAN reachability beacon (lan-source.ts): an inert
|
|
460
|
+
* no-cors GET target, so a probe proves the origin answers without touching
|
|
461
|
+
* a credential or any state.
|
|
462
|
+
*/
|
|
463
|
+
ping: '/pair-ping',
|
|
438
464
|
/**
|
|
439
465
|
* The reopen service worker (registered by the capture script). Root-level
|
|
440
466
|
* path so its default script-directory scope already covers `/`.
|
|
@@ -527,12 +553,65 @@ export class PairingEventsStream {
|
|
|
527
553
|
}
|
|
528
554
|
}
|
|
529
555
|
|
|
556
|
+
/**
|
|
557
|
+
* The manual override of the automatic network choice, for the case where the
|
|
558
|
+
* phone's own probe misjudges (a firewall that answers the reachability ping
|
|
559
|
+
* but not the page, or a LAN the probe cannot see).
|
|
560
|
+
*
|
|
561
|
+
* - `auto` (default): the QR link names the public base when one is configured
|
|
562
|
+
* and the landing page probes the LAN candidates, switching to the first one
|
|
563
|
+
* that answers.
|
|
564
|
+
* - `public`: the public base only, and the landing page never probes. For a
|
|
565
|
+
* deployment where the LAN hop is undesirable.
|
|
566
|
+
* - `lan`: the first LAN literal directly, with no public hop. For a phone that
|
|
567
|
+
* is known to sit on this machine's own network.
|
|
568
|
+
*/
|
|
569
|
+
export type PairingSourcePreference = 'auto' | 'public' | 'lan'
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* The accepted {@link PairingSourcePreference} values, shared with the config
|
|
573
|
+
* schema so the patch that admits a value and the handler that reads it cannot
|
|
574
|
+
* drift apart.
|
|
575
|
+
*/
|
|
576
|
+
export const PAIRING_SOURCE_OPTIONS: readonly PairingSourcePreference[] = ['auto', 'public', 'lan']
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* The LAN origins a landing page served from one authority may hop to: every
|
|
580
|
+
* LAN base the service advertises except the authority serving the request
|
|
581
|
+
* (hopping to the current origin would be a no-op), normalized through `URL`
|
|
582
|
+
* so a default port compares equal to `location.origin`.
|
|
583
|
+
* @param lanBases - the advertised LAN base URLs, in interface order.
|
|
584
|
+
* @param currentOrigin - the origin serving the landing page.
|
|
585
|
+
* @returns the distinct candidate origins, in interface order.
|
|
586
|
+
*/
|
|
587
|
+
export function lanSourceCandidates(lanBases: readonly string[], currentOrigin: string): string[] {
|
|
588
|
+
const origins: string[] = []
|
|
589
|
+
for (const base of lanBases) {
|
|
590
|
+
let origin: string
|
|
591
|
+
try {
|
|
592
|
+
origin = new URL(base).origin
|
|
593
|
+
} catch {
|
|
594
|
+
continue
|
|
595
|
+
}
|
|
596
|
+
if (origin === currentOrigin || origins.includes(origin)) continue
|
|
597
|
+
origins.push(origin)
|
|
598
|
+
}
|
|
599
|
+
return origins
|
|
600
|
+
}
|
|
601
|
+
|
|
530
602
|
/** Route-family dependencies (test seam). */
|
|
531
603
|
export interface PairRoutesDeps {
|
|
532
604
|
/** The pairing service. */
|
|
533
605
|
service: PairingService
|
|
534
606
|
/** Current desktop gate policy, re-read for every status response. */
|
|
535
607
|
requirePairingForLan?: boolean | (() => boolean)
|
|
608
|
+
/**
|
|
609
|
+
* The manual override of the automatic network choice
|
|
610
|
+
* ({@link PairingSourcePreference}), re-read per request. Defaults to
|
|
611
|
+
* `auto`: the QR link names the public base and the landing page decides
|
|
612
|
+
* whether to switch to a LAN origin.
|
|
613
|
+
*/
|
|
614
|
+
pairingSource?: () => PairingSourcePreference
|
|
536
615
|
/**
|
|
537
616
|
* LAN-bind facts for the settings card (managed patch block state, live
|
|
538
617
|
* bind host/port, firewall summary). Re-read per request so a hot-reloaded
|
|
@@ -568,6 +647,28 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
568
647
|
const pairingRequired = (): boolean => typeof requirePairingForLan === 'function'
|
|
569
648
|
? requirePairingForLan()
|
|
570
649
|
: requirePairingForLan
|
|
650
|
+
/** The manual network override, re-read per request (default: automatic). */
|
|
651
|
+
const sourcePreference = (): PairingSourcePreference => deps.pairingSource?.() ?? 'auto'
|
|
652
|
+
/**
|
|
653
|
+
* Every authority this surface serves, as {@link isTrustedHost} wants them
|
|
654
|
+
* (bare `host` or `host:port` literals). The LAN literals are re-read per
|
|
655
|
+
* request: a hot rebind (the lan-bind toggle) updates them mid-process, and
|
|
656
|
+
* both the fence and the reachability beacon must follow.
|
|
657
|
+
*/
|
|
658
|
+
const servedAuthorities = (): string[] => {
|
|
659
|
+
const publicHost = publicHostOf(service.publicBaseUrl)
|
|
660
|
+
const extraHosts = typeof deps.trustedHosts === 'function' ? deps.trustedHosts() : (deps.trustedHosts ?? [])
|
|
661
|
+
return [
|
|
662
|
+
...service.lanAddresses,
|
|
663
|
+
...(publicHost !== undefined ? [publicHost] : []),
|
|
664
|
+
...extraHosts,
|
|
665
|
+
...dynamicTrustedHosts,
|
|
666
|
+
]
|
|
667
|
+
}
|
|
668
|
+
/** Every LAN base URL the service currently advertises, in interface order. */
|
|
669
|
+
const lanBaseUrls = (): string[] => service.lanAddresses
|
|
670
|
+
.map(address => service.lanBaseUrlFor(address))
|
|
671
|
+
.filter((base): base is string => base !== undefined)
|
|
571
672
|
const grants = deps.grants ?? createPairGrantStore()
|
|
572
673
|
const events = new PairingEventsStream(service)
|
|
573
674
|
const dynamicTrustedHosts = new Set<string>()
|
|
@@ -581,17 +682,7 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
581
682
|
* pairing entry pages), never for an API or subresource request.
|
|
582
683
|
*/
|
|
583
684
|
const lanFence = (req: IncomingMessage, navigation = false): boolean => {
|
|
584
|
-
const
|
|
585
|
-
// The service's LAN bases re-read per request: a hot rebind (the lan-bind
|
|
586
|
-
// toggle) updates them mid-process, and the fence must follow.
|
|
587
|
-
const bases = service.lanAddresses
|
|
588
|
-
const extraHosts = typeof deps.trustedHosts === 'function' ? deps.trustedHosts() : (deps.trustedHosts ?? [])
|
|
589
|
-
const combined = [
|
|
590
|
-
...bases,
|
|
591
|
-
...(publicHost !== undefined ? [publicHost] : []),
|
|
592
|
-
...extraHosts,
|
|
593
|
-
...dynamicTrustedHosts,
|
|
594
|
-
]
|
|
685
|
+
const combined = servedAuthorities()
|
|
595
686
|
if (navigation && isTopLevelDocumentNavigation(req) ? isTrustedHost(req, combined) : isTrustedApiRequest(req, combined)) return true
|
|
596
687
|
|
|
597
688
|
// If request carries a valid paired device cookie from a private LAN host (e.g. after service restart), trust and remember it
|
|
@@ -702,10 +793,23 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
702
793
|
// the injected portrait adaptation, PCs the full desktop UI), so the
|
|
703
794
|
// remote surface can never drift from the official one.
|
|
704
795
|
const { token, expiresAt } = service.issue(undefined, address)
|
|
705
|
-
// The
|
|
706
|
-
// phone anywhere can reach
|
|
707
|
-
//
|
|
708
|
-
|
|
796
|
+
// The advertised base is the public (tunneled/relayed) one whenever it is
|
|
797
|
+
// configured: it is the only base a phone anywhere can reach, so the QR
|
|
798
|
+
// never depends on the scanning phone being on this machine's network.
|
|
799
|
+
// The landing page then decides at connect time whether to move to a LAN
|
|
800
|
+
// origin (lan-source.ts). `lan` is the manual override that pins the QR to
|
|
801
|
+
// a LAN literal; an explicit address always names one.
|
|
802
|
+
const preference = sourcePreference()
|
|
803
|
+
const base = address !== undefined
|
|
804
|
+
? service.lanBaseUrlFor(address)
|
|
805
|
+
: preference === 'lan'
|
|
806
|
+
? service.lanBaseUrl
|
|
807
|
+
: preference === 'public'
|
|
808
|
+
// Pinned to public: with none configured there is no satisfiable
|
|
809
|
+
// link, which the caller reports as lan-required rather than
|
|
810
|
+
// silently advertising a base the override excluded.
|
|
811
|
+
? service.publicBaseUrl
|
|
812
|
+
: (service.publicBaseUrl ?? service.lanBaseUrl)
|
|
709
813
|
if (base === undefined) throw new Error('remote-web-ui: base unavailable')
|
|
710
814
|
writeJson(res, 200, {
|
|
711
815
|
ok: true,
|
|
@@ -992,7 +1096,50 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
992
1096
|
'referrer-policy': 'no-referrer',
|
|
993
1097
|
'set-cookie': [deviceCookie(req, service.config.cookieName, id)],
|
|
994
1098
|
})
|
|
995
|
-
res.end(patchAppShell(html, id))
|
|
1099
|
+
res.end(patchAppShell(html, id, lanHop(req, id)))
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* The LAN hop this landing may take, or undefined when there is nothing to
|
|
1104
|
+
* decide. Three conditions have to hold together: the automatic preference
|
|
1105
|
+
* (both overrides answer the question already — `lan` serves the QR from a
|
|
1106
|
+
* LAN origin, `public` forbids the move), at least one LAN candidate other
|
|
1107
|
+
* than this origin, and a fresh one-time grant for the target landing. The
|
|
1108
|
+
* grant is what lets the target origin serve the shell: the device cookie is
|
|
1109
|
+
* origin-scoped, so the LAN origin has no credential of its own, and the
|
|
1110
|
+
* grant table lives in this process regardless of which interface answered.
|
|
1111
|
+
* The candidate set is carried to the client as origins (lan-source.ts
|
|
1112
|
+
* compares them against `location.origin`).
|
|
1113
|
+
* @param req - the landing request (its authority is the current origin).
|
|
1114
|
+
* @param deviceId - the device session the landing is served for.
|
|
1115
|
+
* @returns the hop payload, or undefined when no switch is possible.
|
|
1116
|
+
*/
|
|
1117
|
+
const lanHop = (req: IncomingMessage, deviceId: string): LanSourceHop | undefined => {
|
|
1118
|
+
if (sourcePreference() !== 'auto') return undefined
|
|
1119
|
+
const candidates = lanSourceCandidates(lanBaseUrls(), appOrigin(req))
|
|
1120
|
+
if (candidates.length === 0) return undefined
|
|
1121
|
+
return { candidates, path: PAIR_PATHS.appPage, pingPath: PAIR_PATHS.ping, grant: grants.issue(deviceId).grant }
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* The landing page's LAN reachability beacon: a 204 for a Host this plugin
|
|
1126
|
+
* serves, a 403 otherwise. It carries no credential and no state — a phone
|
|
1127
|
+
* that reaches it has proved the origin answers, which is all the source
|
|
1128
|
+
* decision needs, and the fence's browser-marker half is deliberately not
|
|
1129
|
+
* applied here: the probe is cross-site by construction (a page on the
|
|
1130
|
+
* public origin fetching a LAN origin), so the marker check would turn every
|
|
1131
|
+
* probe into the same 403 that means "not ours" and make the two
|
|
1132
|
+
* indistinguishable.
|
|
1133
|
+
*/
|
|
1134
|
+
const handlePing = (req: IncomingMessage, res: ServerResponse): void => {
|
|
1135
|
+
if (!requireMethod(req, res, 'GET')) return
|
|
1136
|
+
if (!isTrustedHost(req, servedAuthorities())) {
|
|
1137
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
|
|
1138
|
+
res.end()
|
|
1139
|
+
return
|
|
1140
|
+
}
|
|
1141
|
+
res.writeHead(204, { 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' })
|
|
1142
|
+
res.end()
|
|
996
1143
|
}
|
|
997
1144
|
|
|
998
1145
|
/**
|
|
@@ -1032,6 +1179,10 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
1032
1179
|
routes.push({ kind: 'exact', path: PAIR_PATHS.lanBind, handler: handleLanBind })
|
|
1033
1180
|
}
|
|
1034
1181
|
routes.push({ kind: 'exact', path: PAIR_PATHS.acceptPage, handler: handleAcceptPage })
|
|
1182
|
+
// The beacon is independent of the app landing: a deployment with no
|
|
1183
|
+
// indexDocument still advertises LAN bases, and a landing served some other
|
|
1184
|
+
// way may still probe them.
|
|
1185
|
+
routes.push({ kind: 'exact', path: PAIR_PATHS.ping, handler: handlePing })
|
|
1035
1186
|
if (deps.indexDocument !== undefined) {
|
|
1036
1187
|
routes.push({ kind: 'exact', path: PAIR_PATHS.appPage, handler: handleAppPage })
|
|
1037
1188
|
// The reopen worker is only meaningful when the app landing it
|
package/src/tunnel-plan.ts
CHANGED
|
@@ -28,14 +28,21 @@ export type TunnelPlan =
|
|
|
28
28
|
* token AND a valid public hostname (the token does not carry the hostname,
|
|
29
29
|
* so the QR base and the fence trust would have no source without it);
|
|
30
30
|
* otherwise no tunnel and the manual public base applies.
|
|
31
|
+
*
|
|
32
|
+
* The quick target URL names the local webServer port, so a plan made before
|
|
33
|
+
* the listener binds cannot be quick: it comes back `off` and the caller —
|
|
34
|
+
* which can observe the port settle — re-plans. The planner itself never
|
|
35
|
+
* emits an `http://127.0.0.1:undefined` target.
|
|
31
36
|
* @param value - the resolved settings slice that drives the tunnel.
|
|
32
|
-
* @param port - the local webServer port the tunnel forwards to
|
|
37
|
+
* @param port - the local webServer port the tunnel forwards to (undefined
|
|
38
|
+
* before the listener binds).
|
|
33
39
|
*/
|
|
34
40
|
export function tunnelPlanOf(
|
|
35
41
|
value: { autoTunnel?: boolean; tunnelToken?: string; publicBaseUrl?: string },
|
|
36
|
-
port: number,
|
|
42
|
+
port: number | undefined,
|
|
37
43
|
): TunnelPlan {
|
|
38
44
|
if (value.autoTunnel === true) {
|
|
45
|
+
if (port === undefined) return { mode: 'off' }
|
|
39
46
|
const ignored: Array<'tunnelToken' | 'publicBaseUrl'> = []
|
|
40
47
|
if (value.tunnelToken !== undefined && value.tunnelToken !== '') ignored.push('tunnelToken')
|
|
41
48
|
if (value.publicBaseUrl !== undefined && value.publicBaseUrl !== '') ignored.push('publicBaseUrl')
|
package/lib/invariant.js
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
//#region src/invariant.ts
|
|
2
|
-
const PACKAGE_NAME = "@linxin666/dsh-remote-web-ui";
|
|
3
|
-
/** Cordis companion plugin name. */
|
|
4
|
-
const name = "remote-web-ui-invariant";
|
|
5
|
-
/** Service required before the companion can reserve package ownership. */
|
|
6
|
-
const inject = ["invariants"];
|
|
7
|
-
/**
|
|
8
|
-
* No runtime invariant: this package owns no durable package-local event
|
|
9
|
-
* stream; its route-table and device-session relationships are asserted by
|
|
10
|
-
* the package's own specs (route register/dispose symmetry via the
|
|
11
|
-
* webServer disposer contract, token/revocation semantics on the service).
|
|
12
|
-
*/
|
|
13
|
-
const install = () => {};
|
|
14
|
-
/**
|
|
15
|
-
* Register this package's invariant companion.
|
|
16
|
-
* @param ctx - Cordis context carrying the invariant service.
|
|
17
|
-
* @returns the installed registration's disposer after setup succeeds.
|
|
18
|
-
*/
|
|
19
|
-
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
20
|
-
//#endregion
|
|
21
|
-
export { apply, inject, name };
|
package/lib/types/invariant.d.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Package-owned invariant companion for `@linxin666/dsh-remote-web-ui`.
|
|
3
|
-
* @module @linxin666/dsh-remote-web-ui/invariant
|
|
4
|
-
*/
|
|
5
|
-
import type { Context } from '@deepseek-ai/cordis';
|
|
6
|
-
/** Cordis companion plugin name. */
|
|
7
|
-
export declare const name = "remote-web-ui-invariant";
|
|
8
|
-
/** Service required before the companion can reserve package ownership. */
|
|
9
|
-
export declare const inject: string[];
|
|
10
|
-
/**
|
|
11
|
-
* Register this package's invariant companion.
|
|
12
|
-
* @param ctx - Cordis context carrying the invariant service.
|
|
13
|
-
* @returns the installed registration's disposer after setup succeeds.
|
|
14
|
-
*/
|
|
15
|
-
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
16
|
-
//# sourceMappingURL=invariant.d.ts.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"invariant.d.ts","sourceRoot":"","sources":["../../src/invariant.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAKlD,oCAAoC;AACpC,eAAO,MAAM,IAAI,4BAA4B,CAAA;AAC7C,2EAA2E;AAC3E,eAAO,MAAM,MAAM,UAAiB,CAAA;AAUpC;;;;GAIG;AACH,eAAO,MAAM,KAAK,GAAI,KAAK,OAAO,KAAG,OAAO,CAAC,MAAM,IAAI,CACU,CAAA"}
|
package/lib/types/invariant.js
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Package-owned invariant companion for `@linxin666/dsh-remote-web-ui`.
|
|
3
|
-
* @module @linxin666/dsh-remote-web-ui/invariant
|
|
4
|
-
*/
|
|
5
|
-
const PACKAGE_NAME = '@linxin666/dsh-remote-web-ui';
|
|
6
|
-
/** Cordis companion plugin name. */
|
|
7
|
-
export const name = 'remote-web-ui-invariant';
|
|
8
|
-
/** Service required before the companion can reserve package ownership. */
|
|
9
|
-
export const inject = ['invariants'];
|
|
10
|
-
/**
|
|
11
|
-
* No runtime invariant: this package owns no durable package-local event
|
|
12
|
-
* stream; its route-table and device-session relationships are asserted by
|
|
13
|
-
* the package's own specs (route register/dispose symmetry via the
|
|
14
|
-
* webServer disposer contract, token/revocation semantics on the service).
|
|
15
|
-
*/
|
|
16
|
-
const install = () => { };
|
|
17
|
-
/**
|
|
18
|
-
* Register this package's invariant companion.
|
|
19
|
-
* @param ctx - Cordis context carrying the invariant service.
|
|
20
|
-
* @returns the installed registration's disposer after setup succeeds.
|
|
21
|
-
*/
|
|
22
|
-
export const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
23
|
-
/* jscpd:ignore-end */
|
package/src/invariant.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Package-owned invariant companion for `@linxin666/dsh-remote-web-ui`.
|
|
3
|
-
* @module @linxin666/dsh-remote-web-ui/invariant
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
/* jscpd:ignore-start */
|
|
7
|
-
import type { Context } from '@deepseek-ai/cordis'
|
|
8
|
-
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
|
9
|
-
|
|
10
|
-
const PACKAGE_NAME = '@linxin666/dsh-remote-web-ui'
|
|
11
|
-
|
|
12
|
-
/** Cordis companion plugin name. */
|
|
13
|
-
export const name = 'remote-web-ui-invariant'
|
|
14
|
-
/** Service required before the companion can reserve package ownership. */
|
|
15
|
-
export const inject = ['invariants']
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* No runtime invariant: this package owns no durable package-local event
|
|
19
|
-
* stream; its route-table and device-session relationships are asserted by
|
|
20
|
-
* the package's own specs (route register/dispose symmetry via the
|
|
21
|
-
* webServer disposer contract, token/revocation semantics on the service).
|
|
22
|
-
*/
|
|
23
|
-
const install: InvariantInstaller = () => {}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Register this package's invariant companion.
|
|
27
|
-
* @param ctx - Cordis context carrying the invariant service.
|
|
28
|
-
* @returns the installed registration's disposer after setup succeeds.
|
|
29
|
-
*/
|
|
30
|
-
export const apply = (ctx: Context): Promise<() => void> =>
|
|
31
|
-
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
|
32
|
-
/* jscpd:ignore-end */
|