@linxin666/dsh-remote-web-ui 0.3.18 → 0.3.20

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.
@@ -537,10 +537,15 @@
537
537
  .fencePairButton {
538
538
  flex-shrink: 0;
539
539
  padding: 10px 18px;
540
- border: 1px solid var(--dsw-alias-brand-primary);
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-brand-primary);
543
- color: #fff;
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
- filter: brightness(0.96);
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
- const trusted = isLoopbackClient(request) || trustedHosts.some(entry => {
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
- if (!trusted) return false
55
- // Cross-site fence: an explicit cross-site marker is refused regardless of
56
- // Origin (modern browsers label the initiator on every fetch).
57
- if (request.headers['sec-fetch-site'] === 'cross-site') return false
58
- // Origin fence: when a browser attaches an Origin it must be exactly this
59
- // authority; absent Origin is fine — the Host fence already bound it.
60
- const origin = request.headers.origin
61
- if (origin === undefined) return true
62
- try {
63
- return new URL(origin).host === hostUrl.host
64
- } catch {
65
- return false
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) || !isNonCrossSite(request)) return undefined
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
- /** Phone-facing fence: loopback, the service's live LAN literals, configured public host, extra trusted hosts, or dynamically paired hosts. */
473
- const lanFence = (req: IncomingMessage): boolean => {
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.withToken(target.token, { ...SHARED_TUNNEL_FLAGS }), target.publicUrl)
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/i
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
- output += chunk.toString('utf8')
644
+ const text = decodeProcessChunk(chunk)
645
+ output += text
627
646
  if (output.length > OUTPUT_CAP) output = output.slice(output.length - OUTPUT_CAP)
628
- currentOutput += chunk.toString('utf8')
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 chain going. Checked
698
- // against this candidate's own output so a prior fallback's stderr
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 : 'pnpm exited with code ' + String(code),
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
  })