@linxin666/dsh-remote-web-ui 0.4.6 → 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.
Files changed (51) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +13 -13
  3. package/README.zh.md +13 -13
  4. package/lib/client.js +21 -90
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +398 -43
  7. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  8. package/lib/types/client/RemoteEntry.js +3 -15
  9. package/lib/types/client/RemotePanel.d.ts +1 -9
  10. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  11. package/lib/types/client/RemotePanel.js +7 -4
  12. package/lib/types/client/locales.d.ts +4 -8
  13. package/lib/types/client/locales.d.ts.map +1 -1
  14. package/lib/types/client/locales.js +4 -8
  15. package/lib/types/client/settings-entry-form.d.ts.map +1 -1
  16. package/lib/types/client/settings-entry-form.js +0 -1
  17. package/lib/types/index.d.ts +14 -0
  18. package/lib/types/index.d.ts.map +1 -1
  19. package/lib/types/index.js +67 -17
  20. package/lib/types/lan-source.d.ts +65 -0
  21. package/lib/types/lan-source.d.ts.map +1 -0
  22. package/lib/types/lan-source.js +113 -0
  23. package/lib/types/lan.d.ts +49 -3
  24. package/lib/types/lan.d.ts.map +1 -1
  25. package/lib/types/lan.js +120 -6
  26. package/lib/types/remote-api.d.ts +7 -2
  27. package/lib/types/remote-api.d.ts.map +1 -1
  28. package/lib/types/remote-api.js +17 -2
  29. package/lib/types/routes.d.ts +71 -9
  30. package/lib/types/routes.d.ts.map +1 -1
  31. package/lib/types/routes.js +157 -27
  32. package/lib/types/tunnel-plan.d.ts +8 -2
  33. package/lib/types/tunnel-plan.d.ts.map +1 -1
  34. package/lib/types/tunnel-plan.js +9 -1
  35. package/package.json +20 -25
  36. package/src/client/RemoteEntry.tsx +2 -18
  37. package/src/client/RemotePanel.tsx +9 -43
  38. package/src/client/locales.ts +4 -8
  39. package/src/client/remote.module.css +2 -48
  40. package/src/client/settings-entry-form.ts +0 -1
  41. package/src/index.ts +77 -17
  42. package/src/lan-source.ts +132 -0
  43. package/src/lan.ts +126 -6
  44. package/src/remote-api.ts +24 -4
  45. package/src/routes.ts +178 -27
  46. package/src/tunnel-plan.ts +9 -2
  47. package/lib/invariant.js +0 -21
  48. package/lib/types/invariant.d.ts +0 -16
  49. package/lib/types/invariant.d.ts.map +0 -1
  50. package/lib/types/invariant.js +0 -23
  51. package/src/invariant.ts +0 -32
package/src/index.ts CHANGED
@@ -24,7 +24,7 @@ import { DEFAULT_IDLE_EXPIRE_MS, PairingService, type PairingConfig } from './pa
24
24
  import { dshHome } from './dsh-home.ts'
25
25
  import { isPairedDeviceRequest, makeGateListener } from './gate.ts'
26
26
  import { RemoteWebUiPairing } from './pairing-access.ts'
27
- import { isTrustedApiRequest, makeRoutes } from './routes.ts'
27
+ import { isTrustedApiRequest, makeRoutes, PAIRING_SOURCE_OPTIONS, type PairingSourcePreference } from './routes.ts'
28
28
  import { makeRemoteApiRoutes, makeRemoteApiUpgradeRoutes } from './remote-api.ts'
29
29
  import { startRemotePresencePet, type PresencePetSeam } from './remote-presence-pet.ts'
30
30
  import { claimPostureKey, postureTargets, probePosture, releasePostureKey } from './posture.ts'
@@ -207,6 +207,16 @@ export interface Config {
207
207
  profile?: string
208
208
  /** Master switch for the plugin (browser half + host pairing surfaces). */
209
209
  enabled?: boolean
210
+ /**
211
+ * The manual override of the automatic pairing-network choice, for the case
212
+ * where the phone's own probe misjudges (see `PairingSourcePreference` in
213
+ * routes.ts). `auto` (default) names the public base in the QR link and lets
214
+ * the landing page switch to a LAN origin when one answers; `public` pins the
215
+ * public base and disables the probe; `lan` pins the first LAN literal and
216
+ * skips the public hop entirely. Set through the profile patch — the settings
217
+ * card carries no control for it, so a save from that card leaves it alone.
218
+ */
219
+ pairingSource?: PairingSourcePreference
210
220
  }
211
221
 
212
222
  /**
@@ -245,11 +255,18 @@ export const Config = z.object({
245
255
  lanBind: z.boolean().volatile(),
246
256
  profile: z.string().pattern(/^[A-Za-z0-9][A-Za-z0-9._-]*$/),
247
257
  enabled: z.boolean().default(true).volatile(),
258
+ pairingSource: z.union([...PAIRING_SOURCE_OPTIONS]).default('auto').volatile(),
248
259
  })
249
260
 
250
261
  /** Presence sweep cadence (a stale device flips to disconnected within two sweeps). */
251
262
  const SWEEP_INTERVAL_MS = 10_000
252
263
 
264
+ /**
265
+ * Re-plan retry while the quick tunnel waits for the webServer port to bind
266
+ * (a recompose pass can run this apply ahead of the listener's bind).
267
+ */
268
+ const PORT_SETTLE_RETRY_MS = 1_000
269
+
253
270
  /**
254
271
  * Fully resolved config: every field non-optional except `publicBaseUrl`,
255
272
  * which legitimately resolves to `undefined` when unset (the schema keeps it
@@ -294,6 +311,7 @@ export interface ResolvedConfigFields {
294
311
  lanBind?: ConfigField<boolean>
295
312
  profile?: ConfigField<string>
296
313
  enabled?: ConfigField<boolean>
314
+ pairingSource?: ConfigField<PairingSourcePreference>
297
315
  }
298
316
 
299
317
  /** Read one resolved config field, following the live reference the schema produces. */
@@ -360,6 +378,7 @@ const DEFAULTS: ResolvedConfig = {
360
378
  lanBind: undefined,
361
379
  profile: resolveManagedProfile(undefined, undefined, process.env.DSH_PROFILE),
362
380
  enabled: true,
381
+ pairingSource: 'auto',
363
382
  }
364
383
 
365
384
  /**
@@ -406,6 +425,7 @@ function applyImpl(ctx: Context, config?: ResolvedConfigFields): void {
406
425
  lanBind: readOptionalConfigField(config?.lanBind),
407
426
  profile: resolveManagedProfile(readOptionalConfigField(config?.profile), launchedProfileName(ctx), process.env.DSH_PROFILE),
408
427
  enabled: readConfigField(config?.enabled, DEFAULTS.enabled),
428
+ pairingSource: readConfigField(config?.pairingSource, DEFAULTS.pairingSource),
409
429
  })
410
430
  const service = new PairingService(pairingConfigOf(resolve()))
411
431
 
@@ -475,6 +495,14 @@ function applyImpl(ctx: Context, config?: ResolvedConfigFields): void {
475
495
  // 'off' until a sync pass turns a mode on; the phase listener only feeds
476
496
  // the public base while a plugin-managed tunnel (quick or named) runs.
477
497
  let tunnelMode: 'off' | 'quick' | 'named' = resolve().autoTunnel ? 'quick' : 'off'
498
+ /**
499
+ * Retry timer while the quick tunnel waits for the webServer port to bind
500
+ * (undefined when no deferral is pending). Unref'd: never keeps the
501
+ * process alive on its own.
502
+ */
503
+ let portSettleTimer: ReturnType<typeof nodeSetTimeout> | undefined
504
+ /** Dedupes the unbound-port warning to one line per unsettled episode. */
505
+ let portSettleWarned = false
478
506
  tunnel.onPhase((info: TunnelInfo) => {
479
507
  if (tunnelMode === 'off') return
480
508
  if (info.phase === 'running' && info.url !== undefined) {
@@ -501,6 +529,7 @@ function applyImpl(ctx: Context, config?: ResolvedConfigFields): void {
501
529
  disposeRelayRegistrar()
502
530
  publicBase.dispose()
503
531
  tunnel.dispose()
532
+ if (portSettleTimer !== undefined) clearTimeout(portSettleTimer)
504
533
  }, 'remote-web-ui: auto tunnel')
505
534
  // The bind facts are known by now (webServer is an inject edge): the LAN
506
535
  // bases are frozen per process, matching the CLI's once-per-invocation
@@ -616,6 +645,7 @@ function applyImpl(ctx: Context, config?: ResolvedConfigFields): void {
616
645
  ...makeRoutes({
617
646
  service,
618
647
  requirePairingForLan: () => resolve().requirePairingForLan,
648
+ pairingSource: () => resolve().pairingSource,
619
649
  lanBindStatus,
620
650
  indexDocument: fetchAppShell,
621
651
  trustedHosts: () => {
@@ -647,13 +677,13 @@ function applyImpl(ctx: Context, config?: ResolvedConfigFields): void {
647
677
  // credential to everything it forwards (issue #1665).
648
678
  ...makeRemoteApiRoutes({
649
679
  service,
650
- port: ctx.webServer.port,
680
+ port: () => ctx.webServer.port,
651
681
  auth: innerAuth,
652
682
  }),
653
683
  ]
654
684
  const upgrades = makeRemoteApiUpgradeRoutes({
655
685
  service,
656
- port: ctx.webServer.port,
686
+ port: () => ctx.webServer.port,
657
687
  auth: innerAuth,
658
688
  })
659
689
  const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled)
@@ -669,7 +699,12 @@ function applyImpl(ctx: Context, config?: ResolvedConfigFields): void {
669
699
  let postureWasExposed = false
670
700
  const runPostureProbe = (): void => {
671
701
  if (!resolve().enabled) return
672
- const targets = postureTargets(service.publicBaseUrl, service.lanAddresses, ctx.webServer.port)
702
+ // The probe and the LAN targets need the bound port; before it settles
703
+ // (a recompose pass) there is nothing honest to probe — keep the previous
704
+ // snapshot and let the next trigger retry.
705
+ const port = ctx.webServer.port
706
+ if (port === undefined) return
707
+ const targets = postureTargets(service.publicBaseUrl, service.lanAddresses, port)
673
708
  if (targets.length === 0) {
674
709
  postureKey = undefined
675
710
  service.setPosture(undefined)
@@ -679,7 +714,7 @@ function applyImpl(ctx: Context, config?: ResolvedConfigFields): void {
679
714
  const claim = claimPostureKey(postureKey, key)
680
715
  if (!claim.run) return
681
716
  postureKey = claim.next
682
- void probePosture({ port: ctx.webServer.port, targets }).then((snapshot) => {
717
+ void probePosture({ port, targets }).then((snapshot) => {
683
718
  service.setPosture(snapshot)
684
719
  const exposedHosts = snapshot.hosts.filter(host => host.exposed).map(host => host.host)
685
720
  const exposed = exposedHosts.length > 0
@@ -833,18 +868,13 @@ function applyImpl(ctx: Context, config?: ResolvedConfigFields): void {
833
868
  }
834
869
  ctx.effect(() => () => { lanBindWorkPending = false }, 'remote-web-ui: lan-bind work')
835
870
 
836
- // Apply the effective configuration to the running surfaces once per
837
- // activation: the service tunables, the LAN-bind block/firewall, the tunnel
838
- // plan, the pairing routes, the presence sweep, and the posture probe.
839
- const sync = (): void => {
840
- const value = resolve()
841
- service.config = pairingConfigOf(value)
842
- scheduleLanBindWork()
843
- // The plugin-managed tunnels own the public base while one runs: the URL
844
- // lands in the service through the tunnel's phase listener (the minted
845
- // quick URL, or the named tunnel's fixed public hostname). The manual
846
- // publicBaseUrl applies only when no tunnel runs.
847
- const plan = tunnelPlanOf(value, ctx.webServer.port)
871
+ // Apply one tunnel plan to the running surfaces. The plugin-managed tunnels
872
+ // own the public base while one runs: the URL lands in the service through
873
+ // the tunnel's phase listener (the minted quick URL, or the named tunnel's
874
+ // fixed public hostname). The manual publicBaseUrl applies only when no
875
+ // tunnel runs.
876
+ const applyTunnelPlan = (value: ResolvedConfig, port: number | undefined): void => {
877
+ const plan = tunnelPlanOf(value, port)
848
878
  tunnelMode = plan.mode
849
879
  publicBase.setMode(plan.mode)
850
880
  const liveTunnelUrl = publicBase.quickUrl()
@@ -903,6 +933,36 @@ function applyImpl(ctx: Context, config?: ResolvedConfigFields): void {
903
933
  service.setPublicBaseUrl(value.publicBaseUrl)
904
934
  }
905
935
  }
936
+ }
937
+
938
+ // Apply the effective configuration to the running surfaces once per
939
+ // activation: the service tunables, the LAN-bind block/firewall, the tunnel
940
+ // plan, the pairing routes, the presence sweep, and the posture probe.
941
+ const sync = (): void => {
942
+ const value = resolve()
943
+ service.config = pairingConfigOf(value)
944
+ scheduleLanBindWork()
945
+ // The quick tunnel's loopback target URL needs the bound port. A recompose
946
+ // pass can run this apply before the listener binds: defer the tunnel
947
+ // branch (and re-plan on a short timer) instead of minting an
948
+ // http://127.0.0.1:undefined target; everything else in sync applies.
949
+ const livePort = ctx.webServer.port
950
+ if (livePort === undefined && value.autoTunnel === true) {
951
+ if (!portSettleWarned) {
952
+ portSettleWarned = true
953
+ console.warn('remote-web-ui: autoTunnel is on but the web server port is not bound yet — the quick tunnel starts once the port settles')
954
+ }
955
+ if (portSettleTimer === undefined) {
956
+ portSettleTimer = nodeSetTimeout(() => {
957
+ portSettleTimer = undefined
958
+ sync()
959
+ }, PORT_SETTLE_RETRY_MS)
960
+ portSettleTimer.unref()
961
+ }
962
+ } else {
963
+ portSettleWarned = false
964
+ applyTunnelPlan(value, livePort)
965
+ }
906
966
  const enabled = value.enabled
907
967
  if (!enabled) {
908
968
  service.stop()
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The landing page's network-source decision: which origin a paired phone
3
+ * should keep using.
4
+ *
5
+ * The QR link can only name one base, and the server cannot know whether the
6
+ * scanning phone sits on this machine's own network: the public (relayed)
7
+ * base is the only one that is always reachable, so the QR names it and the
8
+ * phone decides at connect time. The device-gated landing (/pair-app) is the
9
+ * plugin's own page already rendered ahead of the official shell's boot, so it
10
+ * is the natural place to probe: after the page is served, a short
11
+ * cross-origin `fetch` per LAN candidate (a no-cors GET at
12
+ * {@link LAN_PING_PATH}) tells the browser whether that LAN origin answers,
13
+ * and the first one that does becomes the new origin. A probe that finds
14
+ * nothing, or that runs out of budget, changes nothing — the page the phone
15
+ * already holds stays paired and usable, so the probe can never block pairing.
16
+ *
17
+ * Browsers do not block this probe. An https page may not normally fetch
18
+ * `http://` subresources, but a host that is an IP literal is exempt from both
19
+ * the block and the automatic upgrade (verified against Chrome: the console
20
+ * reports "This request was not upgraded to HTTPS because its URL's host is an
21
+ * IP address" and delivers the response), which is exactly the shape every LAN
22
+ * candidate has.
23
+ *
24
+ * @module @linxin666/dsh-remote-web-ui/lan-source
25
+ */
26
+
27
+ /** sessionStorage key holding this session's source decision. */
28
+ export const LAN_SOURCE_STORAGE_KEY = 'dsh-remote-lan-source'
29
+
30
+ /**
31
+ * Probe budget for the whole candidate set. Long enough for a LAN round trip,
32
+ * short enough that a phone on another network never waits on it — the probe
33
+ * is fire-and-forget and the page it runs on is already usable.
34
+ */
35
+ export const LAN_SOURCE_PROBE_TIMEOUT_MS = 800
36
+
37
+ /**
38
+ * How recently a LAN hop must have been taken for a return to a non-LAN origin
39
+ * to read as "the hop did not stick". A phone that fails to load the LAN page
40
+ * re-scans the QR (which always names the public base) within seconds, and the
41
+ * landing then has to choose the public source instead of hopping again into
42
+ * the same black hole; a deliberate return much later is a fresh decision and
43
+ * re-probes.
44
+ */
45
+ export const LAN_SOURCE_RETRY_WINDOW_MS = 120_000
46
+
47
+ /** One probe payload: everything the injected script needs, JSON-serializable. */
48
+ export interface LanSourceHop {
49
+ /**
50
+ * The LAN origins the probe may hop to, normalized (`https://host:port` with
51
+ * a default port dropped) so they compare equal to `location.origin`.
52
+ */
53
+ candidates: readonly string[]
54
+ /** The path the hop targets on the winning origin. */
55
+ path: string
56
+ /** The path the probe itself requests, on each candidate. */
57
+ pingPath: string
58
+ /** The one-time grant the hop consumes on the winning origin. */
59
+ grant: string
60
+ }
61
+
62
+ /**
63
+ * Build the inline script that runs the source decision on the landing page.
64
+ * Reads every browser global through `window`, so a test can run it against
65
+ * one fake object (the same seam the parse-time boot patch uses).
66
+ * @param hop - the probe payload for this landing render.
67
+ * @returns the `<script>` element to splice into the landing's head.
68
+ */
69
+ export function appShellLanSwitchScript(hop: LanSourceHop): string {
70
+ const payload = JSON.stringify({
71
+ key: LAN_SOURCE_STORAGE_KEY,
72
+ candidates: hop.candidates,
73
+ path: hop.path,
74
+ ping: hop.pingPath,
75
+ grant: hop.grant,
76
+ timeout: LAN_SOURCE_PROBE_TIMEOUT_MS,
77
+ retry: LAN_SOURCE_RETRY_WINDOW_MS,
78
+ })
79
+ const body = '(function(){try{' +
80
+ 'var w=window,loc=w.location;' +
81
+ 'var P=' + payload + ';' +
82
+ 'var S=w.sessionStorage;' +
83
+ // The candidate set is the decision's identity: a host whose interfaces
84
+ // changed hands the landing a different signature, which forces a re-probe.
85
+ 'P.sig=P.candidates.join(",");' +
86
+ // The stored decision: which source this session settled on, the candidate
87
+ // set it was made against (so a host whose interfaces changed re-decides),
88
+ // and when. Anything unreadable is treated as "no decision".
89
+ 'function rd(){try{' +
90
+ 'var raw=S.getItem(P.key);' +
91
+ 'if(typeof raw!=="string"||raw==="")return undefined;' +
92
+ 'var d=JSON.parse(raw);' +
93
+ 'if(typeof d!=="object"||d===null)return undefined;' +
94
+ 'if(d.src!=="lan"&&d.src!=="public")return undefined;' +
95
+ 'if(typeof d.sig!=="string"||typeof d.at!=="number")return undefined;' +
96
+ 'return d' +
97
+ '}catch(e){return undefined}}' +
98
+ 'function wr(d){try{S.setItem(P.key,JSON.stringify(d))}catch(e){}}' +
99
+ // Already on a LAN candidate: nothing to decide, and a hop back to it
100
+ // would be the same origin. Recording the fact keeps a reload from probing.
101
+ 'function own(){for(var i=0;i<P.candidates.length;i++){if(P.candidates[i]===loc.origin)return true}return false}' +
102
+ 'function go(base){try{loc.replace(base+P.path+"?grant="+encodeURIComponent(P.grant))}catch(e){}}' +
103
+ 'function probe(){' +
104
+ 'if(typeof w.fetch!=="function")return;' +
105
+ 'var settled=false;' +
106
+ 'var timer=w.setTimeout(function(){finish(undefined)},P.timeout);' +
107
+ 'function finish(base){' +
108
+ 'if(settled)return;' +
109
+ 'settled=true;' +
110
+ 'try{w.clearTimeout(timer)}catch(e){}' +
111
+ // The first answer wins: one reachable candidate is all the hop needs, and
112
+ // the losers keep running their own no-cors GETs harmlessly.
113
+ 'if(base===undefined){wr({src:"public",sig:P.sig,at:Date.now()});return}' +
114
+ 'wr({src:"lan",sig:P.sig,base:base,at:Date.now()});' +
115
+ 'go(base)}' +
116
+ 'for(var i=0;i<P.candidates.length;i++){(function(base){' +
117
+ 'var sent;' +
118
+ 'try{sent=w.fetch(base+P.ping,{mode:"no-cors",cache:"no-store",credentials:"omit",referrerPolicy:"no-referrer"})}catch(e){sent=undefined}' +
119
+ 'if(sent===undefined||typeof sent.then!=="function")return;' +
120
+ 'sent.then(function(){finish(base)},function(){})' +
121
+ '})(P.candidates[i])}}' +
122
+ 'var d=rd();' +
123
+ 'if(own()){if(d===undefined||d.sig!==P.sig)wr({src:"lan",sig:P.sig,base:loc.origin,at:Date.now()});return}' +
124
+ 'if(d!==undefined&&d.sig===P.sig){' +
125
+ 'if(d.src==="public")return;' +
126
+ // A LAN decision taken moments ago, on a page that is not a LAN candidate,
127
+ // is a hop that did not stick. Fall back rather than hop again.
128
+ 'if(Date.now()-d.at<P.retry){wr({src:"public",sig:P.sig,at:Date.now()});return}}' +
129
+ 'probe()' +
130
+ '}catch(e){}})();'
131
+ return `<script>${body}</script>`
132
+ }
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
- * Non-internal IPv4 interface addresses of this machine — the IP-literal
14
- * authorities an all-interfaces bind is reachable by on the LAN.
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 Object.values(networkInterfaces()).flat()
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
- /** Local webServer port the loopback proxy connects to. */
77
- port: number
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, port } = deps
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, port } = deps
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) {