@linxin666/dsh-remote-web-ui 0.3.23 → 0.4.0
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 -10
- package/README.zh.md +13 -10
- package/icon.svg +3 -0
- package/lib/client.js +396 -132
- package/lib/client.js.map +1 -1
- package/lib/index.js +604 -262
- package/lib/types/client/FenceNotice.d.ts.map +1 -1
- package/lib/types/client/FenceNotice.js +0 -3
- package/lib/types/client/PluginSettingsCard.d.ts +4 -0
- package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PluginSettingsCard.js +3 -2
- package/lib/types/client/RemoteEntry.d.ts.map +1 -1
- package/lib/types/client/RemoteEntry.js +14 -9
- package/lib/types/client/RemotePanel.d.ts.map +1 -1
- package/lib/types/client/RemotePanel.js +13 -4
- package/lib/types/client/RemoteSettingsCard.d.ts +6 -6
- package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
- package/lib/types/client/RemoteSettingsCard.js +14 -8
- package/lib/types/client/UpdateEntry.js +2 -2
- package/lib/types/client/UpdatePanel.js +2 -2
- package/lib/types/client/deep-link.js +2 -2
- package/lib/types/client/index.d.ts +16 -8
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +56 -32
- package/lib/types/client/locales.d.ts +0 -2
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +6 -8
- package/lib/types/client/mobile-adapt.d.ts.map +1 -1
- package/lib/types/client/mobile-adapt.js +143 -28
- package/lib/types/client/pair-api.d.ts +32 -3
- package/lib/types/client/pair-api.d.ts.map +1 -1
- package/lib/types/client/pair-api.js +44 -6
- package/lib/types/client/plugin-card-seat.d.ts +104 -0
- package/lib/types/client/plugin-card-seat.d.ts.map +1 -0
- package/lib/types/client/plugin-card-seat.js +143 -0
- package/lib/types/client/remote-channel.d.ts.map +1 -1
- package/lib/types/client/remote-channel.js +18 -5
- package/lib/types/client/settings-form.d.ts +17 -17
- package/lib/types/client/settings-form.d.ts.map +1 -1
- package/lib/types/client/settings-form.js +30 -27
- package/lib/types/console-output.d.ts +41 -0
- package/lib/types/console-output.d.ts.map +1 -0
- package/lib/types/console-output.js +89 -0
- package/lib/types/http.d.ts +18 -1
- package/lib/types/http.d.ts.map +1 -1
- package/lib/types/http.js +22 -1
- package/lib/types/index.d.ts +19 -11
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +91 -76
- package/lib/types/inner-auth.d.ts +3 -2
- package/lib/types/inner-auth.d.ts.map +1 -1
- package/lib/types/inner-auth.js +3 -2
- package/lib/types/pair-grant.d.ts +66 -0
- package/lib/types/pair-grant.d.ts.map +1 -0
- package/lib/types/pair-grant.js +95 -0
- package/lib/types/pairing.d.ts +44 -12
- package/lib/types/pairing.d.ts.map +1 -1
- package/lib/types/pairing.js +69 -20
- package/lib/types/pairing.persist.test.js +26 -0
- package/lib/types/posture.d.ts +11 -3
- package/lib/types/posture.d.ts.map +1 -1
- package/lib/types/posture.js +8 -3
- package/lib/types/remote-api.d.ts +10 -13
- package/lib/types/remote-api.d.ts.map +1 -1
- package/lib/types/remote-api.js +34 -34
- package/lib/types/remote-channel-boot.d.ts +6 -1
- package/lib/types/remote-channel-boot.d.ts.map +1 -1
- package/lib/types/remote-channel-boot.js +16 -8
- package/lib/types/remote-channel-rules.d.ts +14 -0
- package/lib/types/remote-channel-rules.d.ts.map +1 -1
- package/lib/types/remote-channel-rules.js +8 -0
- package/lib/types/remote-methods.d.ts +1 -1
- package/lib/types/remote-methods.d.ts.map +1 -1
- package/lib/types/remote-methods.js +1 -0
- package/lib/types/routes.d.ts +47 -5
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +160 -58
- package/lib/types/tunnel.d.ts +21 -6
- package/lib/types/tunnel.d.ts.map +1 -1
- package/lib/types/tunnel.js +58 -11
- package/lib/types/update.d.ts +4 -0
- package/lib/types/update.d.ts.map +1 -1
- package/lib/types/update.js +74 -33
- package/package.json +23 -20
- package/src/client/FenceNotice.tsx +0 -2
- package/src/client/PluginSettingsCard.tsx +7 -2
- package/src/client/RemoteEntry.tsx +14 -9
- package/src/client/RemotePanel.tsx +17 -7
- package/src/client/RemoteSettingsCard.tsx +16 -9
- package/src/client/UpdateEntry.tsx +2 -2
- package/src/client/UpdatePanel.tsx +3 -3
- package/src/client/deep-link.ts +2 -2
- package/src/client/index.ts +70 -40
- package/src/client/locales.ts +6 -8
- package/src/client/mobile-adapt.ts +136 -25
- package/src/client/pair-api.ts +47 -7
- package/src/client/plugin-card-seat.ts +192 -0
- package/src/client/remote-channel.ts +19 -4
- package/src/client/remote.module.css +38 -0
- package/src/client/settings-form.ts +34 -31
- package/src/console-output.ts +97 -0
- package/src/http.ts +23 -1
- package/src/index.ts +102 -79
- package/src/inner-auth.ts +3 -2
- package/src/pair-grant.ts +131 -0
- package/src/pairing.persist.test.ts +36 -0
- package/src/pairing.ts +75 -24
- package/src/posture.ts +12 -4
- package/src/remote-api.ts +34 -41
- package/src/remote-channel-boot.ts +16 -8
- package/src/remote-channel-rules.ts +17 -0
- package/src/remote-methods.ts +1 -0
- package/src/routes.ts +165 -57
- package/src/tunnel.ts +56 -11
- package/src/update.ts +74 -29
package/src/routes.ts
CHANGED
|
@@ -11,6 +11,13 @@
|
|
|
11
11
|
* on the 0.1.2-alpha.2 cohort nothing emits api/gate — direct /api is
|
|
12
12
|
* governed by the harness fence + browser auth — while the /remote channel
|
|
13
13
|
* always enforces the pairing cookie itself.
|
|
14
|
+
*
|
|
15
|
+
* The app landing is reached with a one-time grant, never with the device id
|
|
16
|
+
* itself: /pair-accept mints a short-lived, single-consume grant
|
|
17
|
+
* (pair-grant.ts) and redirects to /pair-app?grant=<g>, and only the landing's
|
|
18
|
+
* own response carries the device credential into the shell. The device cookie
|
|
19
|
+
* stays the primary credential; the grant only keeps a live session credential
|
|
20
|
+
* out of the URL, the address bar, and every log on the redirect path.
|
|
14
21
|
*/
|
|
15
22
|
|
|
16
23
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
@@ -20,6 +27,8 @@ import { UnknownLanAddressError, type PairingService, type PairingSnapshot } fro
|
|
|
20
27
|
import { isLoopbackAddress } from './loopback.ts'
|
|
21
28
|
import { isLoopbackClient, readCookie } from './gate.ts'
|
|
22
29
|
import { readJsonBody, writeJson } from './http.ts'
|
|
30
|
+
import { createPairGrantStore, type PairGrantStore } from './pair-grant.ts'
|
|
31
|
+
import { REMOTE_HOST_GRANT_GLOBAL } from './remote-channel-rules.ts'
|
|
23
32
|
|
|
24
33
|
/**
|
|
25
34
|
* Browser-trust fence for the /api/pair routes, mirroring the connection
|
|
@@ -74,7 +83,16 @@ export function isTrustedHost(request: IncomingMessage, trustedHosts: readonly s
|
|
|
74
83
|
return isLoopbackClient(request) || trustedHosts.some(entry => {
|
|
75
84
|
// A port-less entry matches the hostname on any port; an exact host:port
|
|
76
85
|
// entry matches that authority verbatim (WHATWG normalization both sides).
|
|
77
|
-
|
|
86
|
+
// A malformed entry (a config or DSH_REMOTE_TRUSTED_HOSTS typo) contributes
|
|
87
|
+
// no trust instead of throwing out of the fence: every pairing request from
|
|
88
|
+
// a non-loopback origin would otherwise answer 400 with only a generic
|
|
89
|
+
// webserver warning, and the valid entries would never be consulted.
|
|
90
|
+
let entryUrl: URL
|
|
91
|
+
try {
|
|
92
|
+
entryUrl = new URL(`http://${entry}`)
|
|
93
|
+
} catch {
|
|
94
|
+
return false
|
|
95
|
+
}
|
|
78
96
|
return entryUrl.port === '' ? entryUrl.hostname === hostname : entryUrl.host === hostUrl.host
|
|
79
97
|
})
|
|
80
98
|
}
|
|
@@ -174,20 +192,34 @@ const MAX_BODY_BYTES = 4096
|
|
|
174
192
|
|
|
175
193
|
/**
|
|
176
194
|
* The rate-limit bucket key for one accept attempt (pure; unit-tested).
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
195
|
+
* A forwarded hop separates buckets behind the auto-tunnel (every internet
|
|
196
|
+
* client arrives from 127.0.0.1 there) — but only for loopback peers, since a
|
|
197
|
+
* direct LAN client can rotate the header freely. The caller passes the hop it
|
|
198
|
+
* trusts (the edge-appended LAST one; see rateLimitAccept), and the hop is
|
|
199
|
+
* truncated so one oversized header cannot mint a huge key.
|
|
180
200
|
* @param socketIp - the socket peer address.
|
|
181
|
-
* @param forwarded - the
|
|
201
|
+
* @param forwarded - the trusted XFF hop, already trimmed, if any.
|
|
182
202
|
* @param bucket - page (GET /pair-accept) vs api (POST /api/pair/accept).
|
|
183
203
|
*/
|
|
184
204
|
export function acceptLimitKey(socketIp: string, forwarded: string | undefined, bucket: 'page' | 'api'): string {
|
|
185
205
|
if (isLoopbackAddress(socketIp) && forwarded !== undefined && forwarded !== '') {
|
|
186
|
-
return `${bucket}|${socketIp}|${forwarded}`
|
|
206
|
+
return `${bucket}|${socketIp}|${forwarded.slice(0, MAX_FORWARDED_KEY_CHARS)}`
|
|
187
207
|
}
|
|
188
208
|
return `${bucket}|${socketIp}`
|
|
189
209
|
}
|
|
190
210
|
|
|
211
|
+
/** Longest forwarded hop kept in a bucket key (the header is caller-sized). */
|
|
212
|
+
const MAX_FORWARDED_KEY_CHARS = 64
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Hard cap on live accept buckets. The key is caller-influenced (a forwarded
|
|
216
|
+
* hop), so a rotating flood must not grow the table without bound: past the
|
|
217
|
+
* cap the oldest bucket is evicted, the same FIFO stance as
|
|
218
|
+
* {@link addBounded}. A legitimate client that loses its bucket merely starts
|
|
219
|
+
* a fresh window.
|
|
220
|
+
*/
|
|
221
|
+
export const MAX_ACCEPT_BUCKETS = 1024
|
|
222
|
+
|
|
191
223
|
/**
|
|
192
224
|
* Cap on the dynamically trusted hosts (see {@link addBounded}): room for
|
|
193
225
|
* every legitimate router/proxy aliasing shape, small enough that a flood of
|
|
@@ -271,17 +303,25 @@ export const APP_DEVICE_STORAGE_KEY = 'dsh-remote-device'
|
|
|
271
303
|
|
|
272
304
|
export function appShellCaptureScript(deviceId: string): string {
|
|
273
305
|
const safeId = JSON.stringify(deviceId)
|
|
306
|
+
const grantGlobal = JSON.stringify(REMOTE_HOST_GRANT_GLOBAL)
|
|
274
307
|
const register = `try{if('serviceWorker' in navigator){navigator.serviceWorker.register(${JSON.stringify(PAIR_PATHS.appServiceWorker)},{scope:'/'}).catch(function(e){})}}catch(e){}`
|
|
275
|
-
return `<script>(function(){try{sessionStorage.setItem(${JSON.stringify(APP_DEVICE_STORAGE_KEY)},${safeId});
|
|
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>`
|
|
276
309
|
}
|
|
277
310
|
|
|
278
|
-
/**
|
|
311
|
+
/**
|
|
312
|
+
* Patch the official index document with the device-capture script. The script
|
|
313
|
+
* goes immediately after the opening <head> tag, ahead of the harness-injected
|
|
314
|
+
* parse-time channel boot patch: that patch installs the transport host-mode
|
|
315
|
+
* hook only when this server-served marker is already set (see
|
|
316
|
+
* remote-channel-boot.ts), so host mode is granted by the device-gated landing
|
|
317
|
+
* instead of being asserted from the origin.
|
|
318
|
+
*/
|
|
279
319
|
export function patchAppShell(html: string, deviceId: string): string {
|
|
280
320
|
const script = appShellCaptureScript(deviceId)
|
|
281
|
-
const
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
return html.slice(0,
|
|
321
|
+
const head = /<head[^>]*>/i.exec(html)
|
|
322
|
+
if (head === null) return script + html
|
|
323
|
+
const end = head.index + head[0].length
|
|
324
|
+
return html.slice(0, end) + script + html.slice(end)
|
|
285
325
|
}
|
|
286
326
|
|
|
287
327
|
/**
|
|
@@ -511,6 +551,11 @@ export interface PairRoutesDeps {
|
|
|
511
551
|
indexDocument?: (deviceId: string) => Promise<string | undefined>
|
|
512
552
|
/** Extra trusted non-loopback hosts (from config or environment). */
|
|
513
553
|
trustedHosts?: readonly string[] | (() => readonly string[])
|
|
554
|
+
/**
|
|
555
|
+
* The one-time grant table behind the /pair-accept → /pair-app redirect.
|
|
556
|
+
* Defaults to a fresh store; tests inject one with a fixed clock/entropy.
|
|
557
|
+
*/
|
|
558
|
+
grants?: PairGrantStore
|
|
514
559
|
}
|
|
515
560
|
|
|
516
561
|
/**
|
|
@@ -523,6 +568,7 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
523
568
|
const pairingRequired = (): boolean => typeof requirePairingForLan === 'function'
|
|
524
569
|
? requirePairingForLan()
|
|
525
570
|
: requirePairingForLan
|
|
571
|
+
const grants = deps.grants ?? createPairGrantStore()
|
|
526
572
|
const events = new PairingEventsStream(service)
|
|
527
573
|
const dynamicTrustedHosts = new Set<string>()
|
|
528
574
|
|
|
@@ -553,10 +599,17 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
553
599
|
if (privateLanHost !== undefined) {
|
|
554
600
|
const cookieDeviceId = readCookie(req.headers.cookie, service.config.cookieName)
|
|
555
601
|
let queryDeviceId: string | null = null
|
|
602
|
+
let queryGrant: string | undefined
|
|
556
603
|
try {
|
|
557
|
-
|
|
604
|
+
const params = new URL(req.url ?? '/', 'http://pair.invalid').searchParams
|
|
605
|
+
queryDeviceId = params.get('device')
|
|
606
|
+
queryGrant = params.get('grant') ?? undefined
|
|
558
607
|
} catch {}
|
|
559
|
-
|
|
608
|
+
// The app landing carries a one-time grant instead of the device id;
|
|
609
|
+
// peek (never spend) so the fence can trust a container/NAT authority
|
|
610
|
+
// whose Host is not among the advertised ones.
|
|
611
|
+
const granted = grants.peek(queryGrant)
|
|
612
|
+
const deviceId = cookieDeviceId ?? queryDeviceId ?? granted ?? undefined
|
|
560
613
|
if (deviceId !== undefined && service.hasDevice(deviceId)) {
|
|
561
614
|
addBounded(dynamicTrustedHosts, privateLanHost, MAX_DYNAMIC_TRUSTED_HOSTS)
|
|
562
615
|
return true
|
|
@@ -581,26 +634,48 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
581
634
|
* count separately: a QR re-scan (page navigation) must not consume a
|
|
582
635
|
* brute-force budget that belongs to token guessing (and vice versa).
|
|
583
636
|
*/
|
|
637
|
+
/**
|
|
638
|
+
* Drop expired buckets from the FRONT of the insertion-ordered table, then
|
|
639
|
+
* FIFO-evict past the hard cap. The front always holds the oldest window
|
|
640
|
+
* because a re-armed bucket is re-inserted at the tail, so this is amortized
|
|
641
|
+
* O(1) per request: every iteration deletes an entry that was inserted once.
|
|
642
|
+
* It replaces a full-table scan that ran on every pairing request once the
|
|
643
|
+
* table passed 256 entries (and deleted nothing while all windows were
|
|
644
|
+
* fresh).
|
|
645
|
+
*/
|
|
646
|
+
const pruneAcceptAttempts = (nowMs: number): void => {
|
|
647
|
+
while (acceptAttempts.size > 0) {
|
|
648
|
+
const oldest = acceptAttempts.keys().next().value
|
|
649
|
+
if (oldest === undefined) break
|
|
650
|
+
const attempt = acceptAttempts.get(oldest)
|
|
651
|
+
if (attempt !== undefined && nowMs - attempt.windowStart <= ACCEPT_WINDOW_MS) break
|
|
652
|
+
acceptAttempts.delete(oldest)
|
|
653
|
+
}
|
|
654
|
+
while (acceptAttempts.size > MAX_ACCEPT_BUCKETS) {
|
|
655
|
+
const oldest = acceptAttempts.keys().next().value
|
|
656
|
+
if (oldest === undefined) break
|
|
657
|
+
acceptAttempts.delete(oldest)
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
584
661
|
const rateLimitAccept = (req: IncomingMessage, bucket: 'page' | 'api'): boolean => {
|
|
585
662
|
const socketIp = (req.socket as { remoteAddress?: string } | undefined)?.remoteAddress ?? 'unknown'
|
|
586
663
|
// XFF is honored only for loopback peers (the tunnel edge); see
|
|
587
664
|
// acceptLimitKey. It is untrusted for authentication and never grants
|
|
588
|
-
// access.
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
665
|
+
// access. Only the LAST hop is used: an edge APPENDS the address it saw, so
|
|
666
|
+
// any earlier hop is whatever the client sent — keying on the first one let
|
|
667
|
+
// an attacker rotate the header for a fresh brute-force budget per request.
|
|
668
|
+
const hops = typeof req.headers['x-forwarded-for'] === 'string'
|
|
669
|
+
? req.headers['x-forwarded-for'].split(',')
|
|
670
|
+
: []
|
|
671
|
+
const forwarded = (hops[hops.length - 1] ?? '').trim()
|
|
592
672
|
const ip = acceptLimitKey(socketIp, forwarded, bucket)
|
|
593
673
|
const nowMs = Date.now()
|
|
594
|
-
|
|
595
|
-
// table grows past a modest size so distinct source IPs (LAN clients,
|
|
596
|
-
// brute-force scans) cannot accumulate forever.
|
|
597
|
-
if (acceptAttempts.size > 256) {
|
|
598
|
-
for (const [key, attempt] of acceptAttempts) {
|
|
599
|
-
if (nowMs - attempt.windowStart > ACCEPT_WINDOW_MS) acceptAttempts.delete(key)
|
|
600
|
-
}
|
|
601
|
-
}
|
|
674
|
+
pruneAcceptAttempts(nowMs)
|
|
602
675
|
const entry = acceptAttempts.get(ip)
|
|
603
676
|
if (entry === undefined || nowMs - entry.windowStart > ACCEPT_WINDOW_MS) {
|
|
677
|
+
// Re-insert at the tail so insertion order stays window order.
|
|
678
|
+
acceptAttempts.delete(ip)
|
|
604
679
|
acceptAttempts.set(ip, { count: 1, windowStart: nowMs })
|
|
605
680
|
return false
|
|
606
681
|
}
|
|
@@ -679,19 +754,18 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
679
754
|
writeJson(res, 403, { ok: false, code: 'forbidden' })
|
|
680
755
|
return
|
|
681
756
|
}
|
|
682
|
-
writeJson(res,
|
|
757
|
+
writeJson(res, 404, { ok: false, code: result.code })
|
|
683
758
|
return
|
|
684
759
|
}
|
|
685
760
|
if (privateLanHost !== undefined) {
|
|
686
761
|
addBounded(dynamicTrustedHosts, privateLanHost, MAX_DYNAMIC_TRUSTED_HOSTS)
|
|
687
762
|
}
|
|
688
|
-
//
|
|
689
|
-
//
|
|
690
|
-
//
|
|
763
|
+
// The Secure attribute follows the transport (deviceCookie): off on
|
|
764
|
+
// plain-HTTP LAN, where a Secure cookie is dropped by the browser, and on
|
|
765
|
+
// for a tunneled request the edge stamped as https. Lax keeps top-level
|
|
766
|
+
// navigations working while blocking cross-site subrequests.
|
|
691
767
|
writeJson(res, 200, { ok: true, deviceId: result.deviceId }, {
|
|
692
|
-
'set-cookie': [
|
|
693
|
-
`${service.config.cookieName}=${result.deviceId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}`,
|
|
694
|
-
],
|
|
768
|
+
'set-cookie': [deviceCookie(req, service.config.cookieName, result.deviceId)],
|
|
695
769
|
})
|
|
696
770
|
}
|
|
697
771
|
|
|
@@ -757,6 +831,11 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
757
831
|
}
|
|
758
832
|
const deviceId = readCookie(req.headers.cookie, service.config.cookieName)
|
|
759
833
|
const paired = deviceId !== undefined && service.hasDevice(deviceId)
|
|
834
|
+
// The desktop (loopback) keeps the full pairing-relevant view; the LAN IP
|
|
835
|
+
// literals stay behind a live pairing or loopback so an unauthenticated
|
|
836
|
+
// caller on a tunnel cannot map the internal network. A phone already knows
|
|
837
|
+
// its usable literal from the URL it opened.
|
|
838
|
+
const local = isLoopbackClient(req)
|
|
760
839
|
const snapshot = service.snapshot()
|
|
761
840
|
// Unpaired LAN/tunnel clients get only the pairing-relevant fields; the
|
|
762
841
|
// token expiry, public tunnel URL, and counts are an oracle for targeting
|
|
@@ -764,9 +843,9 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
764
843
|
// session credentials) is never returned here — only the loopback events
|
|
765
844
|
// stream carries it to the desktop panel.
|
|
766
845
|
const { devices: _devices, ...rest } = snapshot
|
|
767
|
-
const visible = paired
|
|
846
|
+
const visible = paired || local
|
|
768
847
|
? rest
|
|
769
|
-
: { phase: snapshot.phase, lanAvailable: snapshot.lanAvailable
|
|
848
|
+
: { phase: snapshot.phase, lanAvailable: snapshot.lanAvailable }
|
|
770
849
|
writeJson(res, 200, { ok: true, paired, requirePairingForLan: pairingRequired(), ...visible })
|
|
771
850
|
}
|
|
772
851
|
|
|
@@ -833,9 +912,10 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
833
912
|
// instead of the harness 401 dead end.
|
|
834
913
|
const deviceId = readCookie(req.headers.cookie, service.config.cookieName)
|
|
835
914
|
if (deviceId !== undefined && service.hasDevice(deviceId)) {
|
|
836
|
-
// An already-paired device re-opening a dead link goes straight to
|
|
837
|
-
//
|
|
838
|
-
|
|
915
|
+
// An already-paired device re-opening a dead link goes straight to the
|
|
916
|
+
// cookieless app landing, again on a fresh one-time grant.
|
|
917
|
+
const { grant } = grants.issue(deviceId)
|
|
918
|
+
res.writeHead(303, { location: `${appOrigin(req)}/pair-app?grant=${encodeURIComponent(grant)}`, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' })
|
|
839
919
|
res.end()
|
|
840
920
|
return
|
|
841
921
|
}
|
|
@@ -847,25 +927,28 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
847
927
|
addBounded(dynamicTrustedHosts, privateLanHost, MAX_DYNAMIC_TRUSTED_HOSTS)
|
|
848
928
|
}
|
|
849
929
|
// Land the paired device on the cookieless app page: the official shell
|
|
850
|
-
// served by this plugin
|
|
851
|
-
//
|
|
852
|
-
//
|
|
930
|
+
// served by this plugin. The URL carries a one-time grant, never the
|
|
931
|
+
// device id - a session credential must not travel in a URL (history, the
|
|
932
|
+
// address bar, every log on the redirect path) - and the landing's own
|
|
933
|
+
// response hands the device id to the shell. No harness index gate, no
|
|
934
|
+
// browser-auth cookie hop: the channel gate accepts the device credential
|
|
935
|
+
// whether or not the browser stores cookies.
|
|
936
|
+
const { grant } = grants.issue(result.deviceId)
|
|
853
937
|
res.writeHead(303, {
|
|
854
|
-
location: `${appOrigin(req)}/pair-app?
|
|
938
|
+
location: `${appOrigin(req)}/pair-app?grant=${encodeURIComponent(grant)}`,
|
|
855
939
|
'cache-control': 'no-store',
|
|
856
940
|
'referrer-policy': 'no-referrer',
|
|
857
|
-
'set-cookie': [
|
|
858
|
-
`${service.config.cookieName}=${result.deviceId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}`,
|
|
859
|
-
],
|
|
941
|
+
'set-cookie': [deviceCookie(req, service.config.cookieName, result.deviceId)],
|
|
860
942
|
})
|
|
861
943
|
res.end()
|
|
862
944
|
}
|
|
863
945
|
|
|
864
946
|
/**
|
|
865
|
-
* The cookieless app landing. A paired device (
|
|
866
|
-
* pairing cookie) receives the official
|
|
867
|
-
* device-capture script; the shell itself is
|
|
868
|
-
* carries no data, so serving it needs only the
|
|
947
|
+
* The cookieless app landing. A paired device (a one-time grant minted by
|
|
948
|
+
* /pair-accept, or a live pairing cookie on a reopen) receives the official
|
|
949
|
+
* index shell patched with the device-capture script; the shell itself is
|
|
950
|
+
* the official document and carries no data, so serving it needs only the
|
|
951
|
+
* device credential.
|
|
869
952
|
*/
|
|
870
953
|
const handleAppPage = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
871
954
|
if (!requireMethod(req, res, 'GET')) return
|
|
@@ -881,10 +964,14 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
881
964
|
return
|
|
882
965
|
}
|
|
883
966
|
const url = new URL(req.url ?? '/', 'http://pair.invalid')
|
|
884
|
-
|
|
967
|
+
// The one-time grant is spent before anything else: a replayed or expired
|
|
968
|
+
// grant resolves to nothing (pair-grant.ts), so a leaked landing URL can
|
|
969
|
+
// never be re-run. The cookie stays the primary credential for a reopen
|
|
970
|
+
// (the service worker's network-first navigation carries it).
|
|
971
|
+
const grantedDevice = grants.consume(url.searchParams.get('grant') ?? undefined)
|
|
885
972
|
const cookieDevice = readCookie(req.headers.cookie, service.config.cookieName)
|
|
886
|
-
const id = (
|
|
887
|
-
?
|
|
973
|
+
const id = (grantedDevice !== undefined && service.touchDevice(grantedDevice))
|
|
974
|
+
? grantedDevice
|
|
888
975
|
: (cookieDevice !== undefined && service.touchDevice(cookieDevice))
|
|
889
976
|
? cookieDevice
|
|
890
977
|
: undefined
|
|
@@ -903,9 +990,7 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
903
990
|
'content-type': 'text/html; charset=utf-8',
|
|
904
991
|
'cache-control': 'no-store',
|
|
905
992
|
'referrer-policy': 'no-referrer',
|
|
906
|
-
'set-cookie': [
|
|
907
|
-
`${service.config.cookieName}=${id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}`,
|
|
908
|
-
],
|
|
993
|
+
'set-cookie': [deviceCookie(req, service.config.cookieName, id)],
|
|
909
994
|
})
|
|
910
995
|
res.end(patchAppShell(html, id))
|
|
911
996
|
}
|
|
@@ -956,9 +1041,32 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
|
|
|
956
1041
|
return routes
|
|
957
1042
|
}
|
|
958
1043
|
|
|
1044
|
+
/**
|
|
1045
|
+
* Whether the request reached this process over TLS. The harness itself serves
|
|
1046
|
+
* plain HTTP; a tunnel edge terminates TLS and stamps `x-forwarded-proto`
|
|
1047
|
+
* (the same signal {@link appOrigin} trusts when it rebuilds a redirect).
|
|
1048
|
+
*/
|
|
1049
|
+
export function isHttpsRequest(req: IncomingMessage): boolean {
|
|
1050
|
+
const forwarded = req.headers['x-forwarded-proto']
|
|
1051
|
+
if (typeof forwarded === 'string') {
|
|
1052
|
+
const first = forwarded.split(',')[0]?.trim().toLowerCase()
|
|
1053
|
+
if (first === 'https') return true
|
|
1054
|
+
}
|
|
1055
|
+
return (req.socket as { encrypted?: boolean }).encrypted === true
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* The device cookie for one request. `Secure` is added only when the request
|
|
1060
|
+
* arrived over TLS: LAN pairing runs on plain HTTP, where a Secure cookie is
|
|
1061
|
+
* simply dropped by the browser and the phone would silently lose its session.
|
|
1062
|
+
*/
|
|
1063
|
+
export function deviceCookie(req: IncomingMessage, cookieName: string, deviceId: string): string {
|
|
1064
|
+
const secure = isHttpsRequest(req) ? '; Secure' : ''
|
|
1065
|
+
return `${cookieName}=${deviceId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}${secure}`
|
|
1066
|
+
}
|
|
1067
|
+
|
|
959
1068
|
/** The request origin (https when a tunnel edge says so). */
|
|
960
1069
|
function appOrigin(req: IncomingMessage): string {
|
|
961
|
-
const proto = req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'
|
|
962
1070
|
const origin = `http://${req.headers.host ?? '127.0.0.1'}`
|
|
963
|
-
return
|
|
1071
|
+
return isHttpsRequest(req) ? origin.replace('http://', 'https://') : origin
|
|
964
1072
|
}
|
package/src/tunnel.ts
CHANGED
|
@@ -164,20 +164,37 @@ export function createBinaryReadiness(executable: string, seams: BinaryReadiness
|
|
|
164
164
|
const runs = seams.runs ?? binaryRuns
|
|
165
165
|
const installBinary = seams.install ?? install
|
|
166
166
|
const validated = new Set<string>()
|
|
167
|
+
let installs = 0
|
|
168
|
+
let lastFailure: Error | undefined
|
|
167
169
|
return async () => {
|
|
168
170
|
if (validated.has(executable)) return
|
|
171
|
+
// The manager retries forever with backoff, so a permanently unrunnable
|
|
172
|
+
// binary must not re-download the whole platform archive on every attempt
|
|
173
|
+
// (a wrong-arch payload would otherwise fetch it once per restart round).
|
|
174
|
+
// A transient install failure still gets its retries; past the cap the
|
|
175
|
+
// remembered error is rethrown until the process restarts.
|
|
176
|
+
if (installs >= MAX_BINARY_INSTALL_ATTEMPTS && lastFailure !== undefined) throw lastFailure
|
|
169
177
|
if (exists(executable) && await runs(executable)) {
|
|
170
178
|
validated.add(executable)
|
|
171
179
|
return
|
|
172
180
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
181
|
+
installs += 1
|
|
182
|
+
try {
|
|
183
|
+
await installBinary(executable)
|
|
184
|
+
if (!(await runs(executable))) {
|
|
185
|
+
throw new Error(`the cloudflared binary at ${executable} still does not run after the reinstall`)
|
|
186
|
+
}
|
|
187
|
+
} catch (error) {
|
|
188
|
+
lastFailure = error instanceof Error ? error : new Error(String(error))
|
|
189
|
+
throw lastFailure
|
|
176
190
|
}
|
|
177
191
|
validated.add(executable)
|
|
178
192
|
}
|
|
179
193
|
}
|
|
180
194
|
|
|
195
|
+
/** How many archive downloads one process may attempt before giving up. */
|
|
196
|
+
export const MAX_BINARY_INSTALL_ATTEMPTS = 3
|
|
197
|
+
|
|
181
198
|
/** The readiness policy the manager uses by default: the package's real binary path. */
|
|
182
199
|
const defaultBinaryReadiness = createBinaryReadiness(bin)
|
|
183
200
|
|
|
@@ -204,14 +221,42 @@ export function quickTunnelFlags(originHostHeader?: string): Record<string, stri
|
|
|
204
221
|
}
|
|
205
222
|
|
|
206
223
|
/**
|
|
207
|
-
* Command-line arguments for a named tunnel
|
|
208
|
-
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
224
|
+
* Command-line arguments for a named tunnel. The account token is deliberately
|
|
225
|
+
* NOT part of the argv: `--token <t>` would expose an account credential to any
|
|
226
|
+
* local user through ps/proc for the whole tunnel lifetime, while the flag's own
|
|
227
|
+
* environment variable (TUNNEL_TOKEN, see cloudflared's tunnel run flags) is not
|
|
228
|
+
* readable that way. Note: `--no-autoupdate` and `--protocol` are flags for the
|
|
229
|
+
* `tunnel` command and must appear BEFORE the `run` subcommand. Putting them
|
|
230
|
+
* after `run` causes cloudflared to exit immediately with "flag provided but not
|
|
231
|
+
* defined: -no-autoupdate" (issues #1432, #1433).
|
|
212
232
|
*/
|
|
213
|
-
export function namedTunnelArgs(
|
|
214
|
-
return ['tunnel', '--no-autoupdate', '--protocol', 'http2', 'run'
|
|
233
|
+
export function namedTunnelArgs(): string[] {
|
|
234
|
+
return ['tunnel', '--no-autoupdate', '--protocol', 'http2', 'run']
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Run one spawn call with TUNNEL_TOKEN set, restoring the previous value
|
|
239
|
+
* afterwards. The cloudflared package's Tunnel spawns with the inherited
|
|
240
|
+
* environment inside its constructor, so the variable only has to exist for
|
|
241
|
+
* that call; the child keeps its own copy of the environment.
|
|
242
|
+
* @param token - the named-tunnel token.
|
|
243
|
+
* @param spawnTunnel - the call that creates the child process.
|
|
244
|
+
* @returns whatever the spawn call returns.
|
|
245
|
+
*/
|
|
246
|
+
export function withTunnelTokenEnv<T>(token: string, spawnTunnel: () => T): T {
|
|
247
|
+
const previous = process.env.TUNNEL_TOKEN
|
|
248
|
+
process.env.TUNNEL_TOKEN = token
|
|
249
|
+
try {
|
|
250
|
+
return spawnTunnel()
|
|
251
|
+
} finally {
|
|
252
|
+
if (previous === undefined) delete process.env.TUNNEL_TOKEN
|
|
253
|
+
else process.env.TUNNEL_TOKEN = previous
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Spawn one named tunnel without putting its token in the child's argv. */
|
|
258
|
+
function spawnNamedTunnel(token: string): NamedTunnelProcess {
|
|
259
|
+
return withTunnelTokenEnv(token, () => new Tunnel(namedTunnelArgs()))
|
|
215
260
|
}
|
|
216
261
|
|
|
217
262
|
/** Default factory: the cloudflared package's quick and named tunnels. */
|
|
@@ -227,7 +272,7 @@ function defaultFactory(target: TunnelTarget): TunnelHandle {
|
|
|
227
272
|
if (target.kind === 'quick') {
|
|
228
273
|
return Tunnel.quick(target.targetUrl, quickTunnelFlags(target.originHostHeader))
|
|
229
274
|
}
|
|
230
|
-
return namedTunnelHandle(
|
|
275
|
+
return namedTunnelHandle(spawnNamedTunnel(target.token), target.publicUrl)
|
|
231
276
|
}
|
|
232
277
|
|
|
233
278
|
/** Node timers. */
|