@linxin666/dsh-remote-web-ui 0.3.18 → 0.3.19
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/lib/client.js +249 -90
- package/lib/client.js.map +1 -1
- package/lib/index.js +128 -30
- package/lib/types/client/mobile-adapt.d.ts.map +1 -1
- package/lib/types/client/mobile-adapt.js +313 -117
- package/lib/types/routes.d.ts +23 -0
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +94 -29
- package/lib/types/tunnel.d.ts +8 -0
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +11 -1
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +31 -11
- package/package.json +1 -1
- package/src/client/mobile-adapt.ts +293 -112
- package/src/client/remote.module.css +10 -4
- package/src/routes.ts +92 -25
- package/src/tunnel.ts +12 -1
- package/src/update.ts +30 -11
|
@@ -537,10 +537,15 @@
|
|
|
537
537
|
.fencePairButton {
|
|
538
538
|
flex-shrink: 0;
|
|
539
539
|
padding: 10px 18px;
|
|
540
|
-
|
|
540
|
+
/* The matched primary-action set (contracts/primary-action-tokens-v1.md):
|
|
541
|
+
brand-primary is an accent/foreground token, not a button fill — with a
|
|
542
|
+
hardcoded #fff label it rendered white-on-white in the dark theme, so the
|
|
543
|
+
pairing CTA was invisible on a phone unless a skin declared a saturated
|
|
544
|
+
brand (the "only one skin works" report). */
|
|
545
|
+
border: 1px solid var(--dsw-alias-button-primary-fill);
|
|
541
546
|
border-radius: 10px;
|
|
542
|
-
background: var(--dsw-alias-
|
|
543
|
-
color:
|
|
547
|
+
background: var(--dsw-alias-button-primary-fill);
|
|
548
|
+
color: var(--dsw-alias-label-primary-foreground);
|
|
544
549
|
font: inherit;
|
|
545
550
|
font-size: 13px;
|
|
546
551
|
font-weight: 500;
|
|
@@ -549,7 +554,8 @@
|
|
|
549
554
|
}
|
|
550
555
|
|
|
551
556
|
.fencePairButton:hover:not(:disabled) {
|
|
552
|
-
|
|
557
|
+
background: var(--dsw-alias-button-primary-hover, var(--dsw-alias-button-primary-fill));
|
|
558
|
+
border-color: var(--dsw-alias-button-primary-hover, var(--dsw-alias-button-primary-fill));
|
|
553
559
|
}
|
|
554
560
|
|
|
555
561
|
.fencePairButton:disabled {
|
package/src/routes.ts
CHANGED
|
@@ -36,6 +36,32 @@ import { readJsonBody, writeJson } from './http.ts'
|
|
|
36
36
|
* browser markers are same-origin.
|
|
37
37
|
*/
|
|
38
38
|
export function isTrustedApiRequest(request: IncomingMessage, trustedHosts: readonly string[]): boolean {
|
|
39
|
+
if (!isTrustedHost(request, trustedHosts)) return false
|
|
40
|
+
// Cross-site fence: an explicit cross-site marker is refused regardless of
|
|
41
|
+
// Origin (modern browsers label the initiator on every fetch).
|
|
42
|
+
if (request.headers['sec-fetch-site'] === 'cross-site') return false
|
|
43
|
+
// Origin fence: when a browser attaches an Origin it must be exactly this
|
|
44
|
+
// authority; absent Origin is fine — the Host fence already bound it.
|
|
45
|
+
const origin = request.headers.origin
|
|
46
|
+
if (origin === undefined) return true
|
|
47
|
+
try {
|
|
48
|
+
return new URL(origin).host === new URL(`http://${request.headers.host ?? ''}`).host
|
|
49
|
+
} catch {
|
|
50
|
+
return false
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether the request's Host is an authority this surface serves: loopback, or
|
|
56
|
+
* a trusted entry (exact `host:port`, or port-less `host` matching any port).
|
|
57
|
+
* The browser-marker checks stay in {@link isTrustedApiRequest}: a pairing
|
|
58
|
+
* entry page is a navigation, and its authority is the token or device
|
|
59
|
+
* credential it carries, not who linked to it.
|
|
60
|
+
* @param request - the node HTTP request.
|
|
61
|
+
* @param trustedHosts - non-loopback authorities this surface serves.
|
|
62
|
+
* @returns true when the Host is ours.
|
|
63
|
+
*/
|
|
64
|
+
export function isTrustedHost(request: IncomingMessage, trustedHosts: readonly string[]): boolean {
|
|
39
65
|
const host = request.headers.host
|
|
40
66
|
if (typeof host !== 'string') return false
|
|
41
67
|
let hostUrl: URL
|
|
@@ -45,25 +71,27 @@ export function isTrustedApiRequest(request: IncomingMessage, trustedHosts: read
|
|
|
45
71
|
return false
|
|
46
72
|
}
|
|
47
73
|
const hostname = hostUrl.hostname
|
|
48
|
-
|
|
74
|
+
return isLoopbackClient(request) || trustedHosts.some(entry => {
|
|
49
75
|
// A port-less entry matches the hostname on any port; an exact host:port
|
|
50
76
|
// entry matches that authority verbatim (WHATWG normalization both sides).
|
|
51
77
|
const entryUrl = new URL(`http://${entry}`)
|
|
52
78
|
return entryUrl.port === '' ? entryUrl.hostname === hostname : entryUrl.host === hostUrl.host
|
|
53
79
|
})
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Whether the request is a top-level document navigation — the shape every
|
|
84
|
+
* phone uses to open a pairing link. Browsers label it `Sec-Fetch-Mode:
|
|
85
|
+
* navigate` with `Sec-Fetch-Dest: document`; in-app browsers (WeChat, and
|
|
86
|
+
* anything else wrapping a WKWebView) additionally attach `Sec-Fetch-Site:
|
|
87
|
+
* cross-site` or an opaque `Origin: null`, which the API fence must keep
|
|
88
|
+
* refusing but which says nothing about a document navigation: the pairing
|
|
89
|
+
* token or the device id in the URL is what authorizes it.
|
|
90
|
+
* @param request - the node HTTP request.
|
|
91
|
+
* @returns true for a top-level document navigation.
|
|
92
|
+
*/
|
|
93
|
+
export function isTopLevelDocumentNavigation(request: IncomingMessage): boolean {
|
|
94
|
+
return request.headers['sec-fetch-mode'] === 'navigate' && request.headers['sec-fetch-dest'] === 'document'
|
|
67
95
|
}
|
|
68
96
|
|
|
69
97
|
/**
|
|
@@ -124,7 +152,7 @@ export function isNonCrossSite(request: IncomingMessage): boolean {
|
|
|
124
152
|
* the lanFence fallback use this to serve container-bridged and reverse-proxy
|
|
125
153
|
* topologies whose Host is not among the advertised or configured authorities.
|
|
126
154
|
*/
|
|
127
|
-
function privateLanHostOf(request: IncomingMessage): string | undefined {
|
|
155
|
+
function privateLanHostOf(request: IncomingMessage, navigation = false): string | undefined {
|
|
128
156
|
const host = request.headers.host
|
|
129
157
|
if (typeof host !== 'string') return undefined
|
|
130
158
|
let hostName = ''
|
|
@@ -133,7 +161,11 @@ function privateLanHostOf(request: IncomingMessage): string | undefined {
|
|
|
133
161
|
} catch {
|
|
134
162
|
return undefined
|
|
135
163
|
}
|
|
136
|
-
if (hostName === '' || !isPrivateOrLocalHostname(hostName)
|
|
164
|
+
if (hostName === '' || !isPrivateOrLocalHostname(hostName)) return undefined
|
|
165
|
+
// A top-level navigation is judged by its credential, not by the initiator
|
|
166
|
+
// marker (see isTopLevelDocumentNavigation); every other request keeps the
|
|
167
|
+
// non-cross-site requirement.
|
|
168
|
+
if (!(navigation && isTopLevelDocumentNavigation(request)) && !isNonCrossSite(request)) return undefined
|
|
137
169
|
return host
|
|
138
170
|
}
|
|
139
171
|
|
|
@@ -163,6 +195,33 @@ export function acceptLimitKey(socketIp: string, forwarded: string | undefined,
|
|
|
163
195
|
*/
|
|
164
196
|
export const MAX_DYNAMIC_TRUSTED_HOSTS = 64
|
|
165
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Reasons a phone-facing entry page refused a request, logged once per shape
|
|
200
|
+
* so a real device failure is diagnosable from the host console without a
|
|
201
|
+
* debug build. Keyed by `reason|host|path`; bounded by construction (a handful
|
|
202
|
+
* of reasons, and only the first occurrence of each is printed).
|
|
203
|
+
*/
|
|
204
|
+
const loggedEntryRefusals = new Set<string>()
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Log one entry-page refusal (first occurrence per shape) and cap the set.
|
|
208
|
+
* @param request - the refused request.
|
|
209
|
+
* @param path - the entry path that refused it.
|
|
210
|
+
* @param reason - why the fence refused.
|
|
211
|
+
*/
|
|
212
|
+
function logEntryRefusal(request: IncomingMessage, path: string, reason: string): void {
|
|
213
|
+
const key = `${reason}|${request.headers.host ?? ''}|${path}`
|
|
214
|
+
if (loggedEntryRefusals.has(key) || loggedEntryRefusals.size >= 64) return
|
|
215
|
+
loggedEntryRefusals.add(key)
|
|
216
|
+
const markers = [
|
|
217
|
+
`sec-fetch-site=${request.headers['sec-fetch-site'] ?? '-'}`,
|
|
218
|
+
`sec-fetch-mode=${request.headers['sec-fetch-mode'] ?? '-'}`,
|
|
219
|
+
`sec-fetch-dest=${request.headers['sec-fetch-dest'] ?? '-'}`,
|
|
220
|
+
`origin=${request.headers.origin ?? '-'}`,
|
|
221
|
+
].join(' ')
|
|
222
|
+
console.log(`remote-web-ui: refused ${path} from host ${request.headers.host ?? '-'} (${reason}; ${markers})`)
|
|
223
|
+
}
|
|
224
|
+
|
|
166
225
|
/**
|
|
167
226
|
* FIFO-bounded Set insert: past `max` entries the oldest one is evicted
|
|
168
227
|
* (a Set iterates in insertion order). The dynamic trusted-host table is fed
|
|
@@ -469,8 +528,13 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
469
528
|
|
|
470
529
|
/** Loopback-only fence: the desktop panel's control endpoints. */
|
|
471
530
|
const loopbackFence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, [])
|
|
472
|
-
/**
|
|
473
|
-
|
|
531
|
+
/**
|
|
532
|
+
* Phone-facing fence: loopback, the service's live LAN literals, configured
|
|
533
|
+
* public host, extra trusted hosts, or dynamically paired hosts. `navigation`
|
|
534
|
+
* relaxes the browser-marker checks for a top-level document navigation (the
|
|
535
|
+
* pairing entry pages), never for an API or subresource request.
|
|
536
|
+
*/
|
|
537
|
+
const lanFence = (req: IncomingMessage, navigation = false): boolean => {
|
|
474
538
|
const publicHost = publicHostOf(service.publicBaseUrl)
|
|
475
539
|
// The service's LAN bases re-read per request: a hot rebind (the lan-bind
|
|
476
540
|
// toggle) updates them mid-process, and the fence must follow.
|
|
@@ -482,10 +546,10 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
482
546
|
...extraHosts,
|
|
483
547
|
...dynamicTrustedHosts,
|
|
484
548
|
]
|
|
485
|
-
if (isTrustedApiRequest(req, combined)) return true
|
|
549
|
+
if (navigation && isTopLevelDocumentNavigation(req) ? isTrustedHost(req, combined) : isTrustedApiRequest(req, combined)) return true
|
|
486
550
|
|
|
487
551
|
// 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)
|
|
552
|
+
const privateLanHost = privateLanHostOf(req, navigation)
|
|
489
553
|
if (privateLanHost !== undefined) {
|
|
490
554
|
const cookieDeviceId = readCookie(req.headers.cookie, service.config.cookieName)
|
|
491
555
|
let queryDeviceId: string | null = null
|
|
@@ -737,8 +801,9 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
737
801
|
*/
|
|
738
802
|
const handleAcceptPage = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
739
803
|
if (!requireMethod(req, res, 'GET')) return
|
|
740
|
-
const privateLanHost = privateLanHostOf(req)
|
|
741
|
-
if (!lanFence(req) && privateLanHost === undefined) {
|
|
804
|
+
const privateLanHost = privateLanHostOf(req, true)
|
|
805
|
+
if (!lanFence(req, true) && privateLanHost === undefined) {
|
|
806
|
+
logEntryRefusal(req, PAIR_PATHS.acceptPage, 'untrusted-host')
|
|
742
807
|
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
|
|
743
808
|
res.end('forbidden')
|
|
744
809
|
return
|
|
@@ -753,7 +818,8 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
753
818
|
const ua = req.headers['user-agent']
|
|
754
819
|
const result = token === '' ? { ok: false as const, code: 'invalid' as const } : service.accept(token, typeof ua === 'string' ? ua : undefined)
|
|
755
820
|
if (!result.ok) {
|
|
756
|
-
if (!lanFence(req)) {
|
|
821
|
+
if (!lanFence(req, true)) {
|
|
822
|
+
logEntryRefusal(req, PAIR_PATHS.acceptPage, 'untrusted-host-after-invalid-token')
|
|
757
823
|
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
|
|
758
824
|
res.end('forbidden')
|
|
759
825
|
return
|
|
@@ -803,7 +869,8 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
803
869
|
*/
|
|
804
870
|
const handleAppPage = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
805
871
|
if (!requireMethod(req, res, 'GET')) return
|
|
806
|
-
if (!lanFence(req)) {
|
|
872
|
+
if (!lanFence(req, true)) {
|
|
873
|
+
logEntryRefusal(req, PAIR_PATHS.appPage, 'untrusted-host')
|
|
807
874
|
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
|
|
808
875
|
res.end('forbidden')
|
|
809
876
|
return
|
|
@@ -852,7 +919,7 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
852
919
|
*/
|
|
853
920
|
const handleAppServiceWorker = (req: IncomingMessage, res: ServerResponse): void => {
|
|
854
921
|
if (!requireMethod(req, res, 'GET')) return
|
|
855
|
-
if (!lanFence(req)) {
|
|
922
|
+
if (!lanFence(req, true)) {
|
|
856
923
|
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
|
|
857
924
|
res.end('forbidden')
|
|
858
925
|
return
|
package/src/tunnel.ts
CHANGED
|
@@ -203,6 +203,17 @@ export function quickTunnelFlags(originHostHeader?: string): Record<string, stri
|
|
|
203
203
|
: { ...SHARED_TUNNEL_FLAGS, '--http-host-header': originHostHeader }
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Command-line arguments for a named tunnel with a persistent Cloudflare token.
|
|
208
|
+
* Note: `--no-autoupdate` and `--protocol` are flags for the `tunnel` command
|
|
209
|
+
* and must appear BEFORE the `run` subcommand. Putting them after `run` causes
|
|
210
|
+
* cloudflared to exit immediately with "flag provided but not defined: -no-autoupdate"
|
|
211
|
+
* (issues #1432, #1433).
|
|
212
|
+
*/
|
|
213
|
+
export function namedTunnelArgs(token: string): string[] {
|
|
214
|
+
return ['tunnel', '--no-autoupdate', '--protocol', 'http2', 'run', '--token', token]
|
|
215
|
+
}
|
|
216
|
+
|
|
206
217
|
/** Default factory: the cloudflared package's quick and named tunnels. */
|
|
207
218
|
function defaultFactory(target: TunnelTarget): TunnelHandle {
|
|
208
219
|
// `--no-autoupdate`: the binary must never upgrade itself out from under
|
|
@@ -216,7 +227,7 @@ function defaultFactory(target: TunnelTarget): TunnelHandle {
|
|
|
216
227
|
if (target.kind === 'quick') {
|
|
217
228
|
return Tunnel.quick(target.targetUrl, quickTunnelFlags(target.originHostHeader))
|
|
218
229
|
}
|
|
219
|
-
return namedTunnelHandle(Tunnel
|
|
230
|
+
return namedTunnelHandle(new Tunnel(namedTunnelArgs(target.token)), target.publicUrl)
|
|
220
231
|
}
|
|
221
232
|
|
|
222
233
|
/** Node timers. */
|
package/src/update.ts
CHANGED
|
@@ -564,10 +564,28 @@ const OUTPUT_CAP = 16 * 1024
|
|
|
564
564
|
|
|
565
565
|
/**
|
|
566
566
|
* Windows cmd command-not-found stderr. With shell:true a missing shim
|
|
567
|
-
* exits with code 1 (cmd cannot report ENOENT), so the fallback chain
|
|
568
|
-
* detects this message instead of the spawn error event.
|
|
567
|
+
* exits with code 1 or 9009 (cmd cannot report ENOENT), so the fallback chain
|
|
568
|
+
* detects this message instead of the spawn error event. Supports both English
|
|
569
|
+
* and localized (e.g. Chinese) cmd error messages.
|
|
569
570
|
*/
|
|
570
|
-
const WIN_CMD_MISSING_RE = /not recognized as an internal or external command
|
|
571
|
+
const WIN_CMD_MISSING_RE = /not recognized as an internal or external command|不是内部或外部命令/i
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Decode process output with tolerant decoding:
|
|
575
|
+
* Defaults to UTF-8; if invalid byte sequences are encountered (common on
|
|
576
|
+
* Windows consoles using code page 936/GBK), falls back to GBK via TextDecoder.
|
|
577
|
+
*/
|
|
578
|
+
function decodeProcessChunk(chunk: Buffer): string {
|
|
579
|
+
try {
|
|
580
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(chunk)
|
|
581
|
+
} catch {
|
|
582
|
+
try {
|
|
583
|
+
return new TextDecoder('gbk').decode(chunk)
|
|
584
|
+
} catch {
|
|
585
|
+
return chunk.toString('utf8')
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
571
589
|
|
|
572
590
|
/**
|
|
573
591
|
* Bypass for pnpm 11's supply-chain gate: `minimumReleaseAge` (default 24 h)
|
|
@@ -623,9 +641,10 @@ export function runUpdate(deps: UpdateRunDeps): Promise<UpdateRunResult> {
|
|
|
623
641
|
let output = ''
|
|
624
642
|
let currentOutput = ''
|
|
625
643
|
const append = (chunk: Buffer): void => {
|
|
626
|
-
|
|
644
|
+
const text = decodeProcessChunk(chunk)
|
|
645
|
+
output += text
|
|
627
646
|
if (output.length > OUTPUT_CAP) output = output.slice(output.length - OUTPUT_CAP)
|
|
628
|
-
currentOutput +=
|
|
647
|
+
currentOutput += text
|
|
629
648
|
if (currentOutput.length > OUTPUT_CAP) currentOutput = currentOutput.slice(currentOutput.length - OUTPUT_CAP)
|
|
630
649
|
}
|
|
631
650
|
let currentChild: ReturnType<typeof spawnImpl> | undefined
|
|
@@ -693,11 +712,11 @@ export function runUpdate(deps: UpdateRunDeps): Promise<UpdateRunResult> {
|
|
|
693
712
|
})
|
|
694
713
|
child.on('close', (code: number | null) => {
|
|
695
714
|
if (settled || finished) return
|
|
696
|
-
// Windows + shell: a missing command reports exit 1 with
|
|
697
|
-
// 'not recognized' instead of ENOENT — keep the
|
|
698
|
-
// against this candidate's own output so a prior
|
|
699
|
-
// cannot misclassify a real failure here.
|
|
700
|
-
if (platform === 'win32' && code !== 0 && WIN_CMD_MISSING_RE.test(currentOutput)) {
|
|
715
|
+
// Windows + shell: a missing command reports exit 1 or 9009 with
|
|
716
|
+
// 'not recognized' or localized message instead of ENOENT — keep the
|
|
717
|
+
// chain going. Checked against this candidate's own output so a prior
|
|
718
|
+
// fallback's stderr cannot misclassify a real failure here.
|
|
719
|
+
if (platform === 'win32' && (code === 9009 || (code !== 0 && WIN_CMD_MISSING_RE.test(currentOutput)))) {
|
|
701
720
|
runCandidate(index + 1)
|
|
702
721
|
return
|
|
703
722
|
}
|
|
@@ -706,7 +725,7 @@ export function runUpdate(deps: UpdateRunDeps): Promise<UpdateRunResult> {
|
|
|
706
725
|
ok: code === 0,
|
|
707
726
|
exitCode: code,
|
|
708
727
|
output,
|
|
709
|
-
error: code === 0 ? undefined :
|
|
728
|
+
error: code === 0 ? undefined : `${candidate.command} exited with code ` + String(code),
|
|
710
729
|
...(code === 0 ? {} : { errorCode: 'pnpm-failed' as const }),
|
|
711
730
|
})
|
|
712
731
|
})
|