@linxin666/dsh-remote-web-ui 0.3.14 → 0.3.15

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.
@@ -50,6 +50,7 @@ export const Config = z.object({
50
50
  cookieName: z.string().min(1).default('dsh_pair'),
51
51
  requirePairingForLan: z.boolean().default(true),
52
52
  publicBaseUrl: z.string(),
53
+ trustedHosts: z.array(z.string()),
53
54
  devicesFile: z.string(),
54
55
  autoTunnel: z.boolean().default(false),
55
56
  tunnelToken: z.string().role('secret'),
@@ -89,6 +90,7 @@ const DEFAULTS = {
89
90
  cookieName: 'dsh_pair',
90
91
  requirePairingForLan: true,
91
92
  publicBaseUrl: undefined,
93
+ trustedHosts: undefined,
92
94
  devicesFile: defaultDevicesFile(),
93
95
  autoTunnel: false,
94
96
  tunnelToken: undefined,
@@ -104,6 +106,7 @@ const DEFAULTS = {
104
106
  */
105
107
  export const apply = mountOnce('@linxin666/dsh-remote-web-ui', applyImpl);
106
108
  function applyImpl(ctx, config) {
109
+ const envPublicBase = process.env.DSH_REMOTE_PUBLIC_BASE_URL?.trim() || undefined;
107
110
  const resolved = {
108
111
  tokenTtlMs: config?.tokenTtlMs ?? DEFAULTS.tokenTtlMs,
109
112
  offlineAfterMs: config?.offlineAfterMs ?? DEFAULTS.offlineAfterMs,
@@ -111,7 +114,8 @@ function applyImpl(ctx, config) {
111
114
  idleExpireMs: config?.idleExpireMs ?? DEFAULTS.idleExpireMs,
112
115
  cookieName: config?.cookieName ?? DEFAULTS.cookieName,
113
116
  requirePairingForLan: config?.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
114
- publicBaseUrl: config?.publicBaseUrl,
117
+ publicBaseUrl: config?.publicBaseUrl ?? envPublicBase,
118
+ trustedHosts: config?.trustedHosts,
115
119
  devicesFile: config?.devicesFile ?? DEFAULTS.devicesFile,
116
120
  autoTunnel: config?.autoTunnel ?? DEFAULTS.autoTunnel,
117
121
  tunnelToken: config?.tunnelToken,
@@ -133,7 +137,8 @@ function applyImpl(ctx, config) {
133
137
  idleExpireMs: value.idleExpireMs ?? DEFAULTS.idleExpireMs,
134
138
  cookieName: value.cookieName ?? DEFAULTS.cookieName,
135
139
  requirePairingForLan: value.requirePairingForLan ?? DEFAULTS.requirePairingForLan,
136
- publicBaseUrl: value.publicBaseUrl,
140
+ publicBaseUrl: value.publicBaseUrl ?? envPublicBase,
141
+ trustedHosts: value.trustedHosts,
137
142
  devicesFile: value.devicesFile ?? DEFAULTS.devicesFile,
138
143
  autoTunnel: value.autoTunnel ?? DEFAULTS.autoTunnel,
139
144
  tunnelToken: value.tunnelToken,
@@ -282,14 +287,16 @@ function applyImpl(ctx, config) {
282
287
  // directory on update, so a boot-time captured path would fail to read
283
288
  // after a successful update; versions are re-read from disk per check.
284
289
  const requireFromHost = createRequire(import.meta.url);
285
- const resolveAnchorPath = () => resolveAnchorManifest(specifier => {
290
+ /** Host-process resolve that degrades to "not installed" (undefined) instead of throwing. */
291
+ const hostResolve = (specifier) => {
286
292
  try {
287
293
  return requireFromHost.resolve(specifier);
288
294
  }
289
295
  catch {
290
296
  return undefined;
291
297
  }
292
- });
298
+ };
299
+ const resolveAnchorPath = () => resolveAnchorManifest(hostResolve);
293
300
  const releaseNotesCache = new Map();
294
301
  const fetchReleaseNotesCached = async (version) => {
295
302
  const cached = releaseNotesCache.get(version);
@@ -305,14 +312,7 @@ function applyImpl(ctx, config) {
305
312
  fence: request => isTrustedApiRequest(request, []),
306
313
  check: () => checkUpdates({
307
314
  anchorManifestPath: resolveAnchorPath(),
308
- resolve: specifier => {
309
- try {
310
- return requireFromHost.resolve(specifier);
311
- }
312
- catch {
313
- return undefined;
314
- }
315
- },
315
+ resolve: hostResolve,
316
316
  fetchLatest: name => fetchLatestVersion(name, fetch),
317
317
  fetchReleaseNotes: fetchReleaseNotesCached,
318
318
  }),
@@ -336,14 +336,7 @@ function applyImpl(ctx, config) {
336
336
  run: { profileDir: target.profileDir, packages: target.packages },
337
337
  check: {
338
338
  anchorManifestPath: resolveAnchorPath(),
339
- resolve: specifier => {
340
- try {
341
- return requireFromHost.resolve(specifier);
342
- }
343
- catch {
344
- return undefined;
345
- }
346
- },
339
+ resolve: hostResolve,
347
340
  fetchLatest: name => fetchLatestVersion(name, fetch),
348
341
  fetchReleaseNotes: fetchReleaseNotesCached,
349
342
  },
@@ -437,10 +430,31 @@ function applyImpl(ctx, config) {
437
430
  const routes = [
438
431
  ...makeRoutes({
439
432
  service,
440
- lanAddresses: service.lanAddresses,
441
433
  requirePairingForLan: () => resolve().requirePairingForLan,
442
434
  lanBindStatus,
443
435
  indexDocument: fetchAppShell,
436
+ trustedHosts: () => {
437
+ const list = [];
438
+ const envHosts = process.env.DSH_REMOTE_TRUSTED_HOSTS;
439
+ if (typeof envHosts === 'string' && envHosts.trim() !== '') {
440
+ for (const item of envHosts.split(',')) {
441
+ const trimmed = item.trim();
442
+ if (trimmed !== '')
443
+ list.push(trimmed);
444
+ }
445
+ }
446
+ const cfgHosts = resolve().trustedHosts;
447
+ if (Array.isArray(cfgHosts)) {
448
+ for (const item of cfgHosts) {
449
+ if (typeof item === 'string') {
450
+ const trimmed = item.trim();
451
+ if (trimmed !== '')
452
+ list.push(trimmed);
453
+ }
454
+ }
455
+ }
456
+ return list;
457
+ },
444
458
  }),
445
459
  // The remote desktop channel: policy-gated `/remote` prefix that
446
460
  // re-issues fenced paths to loopback (see remote-api.ts). The live
@@ -709,13 +723,26 @@ function applyImpl(ctx, config) {
709
723
  table.push({ kind: 'script', placement: 'head', text: REMOTE_CHANNEL_BOOT_SCRIPT });
710
724
  }), 'remote-web-ui: remote channel boot patch');
711
725
  ctx.inject(['settings'], (settingsCtx) => {
712
- settingsCtx.settings.installSection(ctx, REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, config ?? {}, {
713
- setSource: (source) => {
714
- current = source;
726
+ try {
727
+ if (typeof settingsCtx.settings?.installSection === 'function') {
728
+ settingsCtx.settings.installSection(ctx, REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, config ?? {}, {
729
+ setSource: (source) => {
730
+ current = source;
731
+ sync();
732
+ },
733
+ onChange: sync,
734
+ });
735
+ }
736
+ else if (typeof settingsCtx.settings?.register === 'function') {
737
+ const scope = settingsCtx.settings.register(REMOTE_WEB_UI_SETTINGS_NAMESPACE, Config, { base: config ?? {} });
738
+ current = () => scope?.get?.() ?? (config ?? {});
739
+ scope?.watch?.(() => { sync(); });
715
740
  sync();
716
- },
717
- onChange: sync,
718
- });
741
+ }
742
+ }
743
+ catch {
744
+ // Defensive fallback against settings registration differences
745
+ }
719
746
  });
720
747
  sync();
721
748
  }
@@ -1 +1 @@
1
- {"version":3,"file":"loopback-proxy.d.ts","sourceRoot":"","sources":["../../src/loopback-proxy.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAuB,cAAc,EAAE,MAAM,WAAW,CAAA;AAGrF,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAEzC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAoBhD;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,cAAc,EACnB,IAAI,EAAE,MAAM,EACZ,YAAY,EAAE,MAAM,EACpB,IAAI,CAAC,EAAE,SAAS,GACf,IAAI,CAIN;AAmDD;;;;;;;;;GASG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,eAAe,EACpB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,YAAY,EAAE,MAAM,EACpB,MAAM,CAAC,EAAE,MAAM,GACd,IAAI,CAoCN"}
1
+ {"version":3,"file":"loopback-proxy.d.ts","sourceRoot":"","sources":["../../src/loopback-proxy.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAuB,cAAc,EAAE,MAAM,WAAW,CAAA;AAGrF,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,aAAa,CAAA;AAEzC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAA;AAoBhD;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAC/B,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,cAAc,EACnB,IAAI,EAAE,MAAM,EACZ,YAAY,EAAE,MAAM,EACpB,IAAI,CAAC,EAAE,SAAS,GACf,IAAI,CAIN;AAkED;;;;;;;;;GASG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,eAAe,EACpB,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,EACZ,YAAY,EAAE,MAAM,EACpB,MAAM,CAAC,EAAE,MAAM,GACd,IAAI,CAoCN"}
@@ -77,6 +77,18 @@ function pipeLoopbackHttp(req, res, port, upstreamPath, auth, cookie) {
77
77
  }
78
78
  res.writeHead(upstreamRes.statusCode ?? 502, out);
79
79
  upstreamRes.pipe(res);
80
+ // Inner leg died mid-response (reset, truncation): tear the outer leg
81
+ // down instead of letting the truncated response raise an unhandled
82
+ // 'error' with no listener.
83
+ upstreamRes.on('error', () => { res.destroy(); });
84
+ // Outer leg died before the response finished (phone left mid-request):
85
+ // reset the inner request so the loopback server stops working on a call
86
+ // nobody will read. Guarded so a normal completion never destroys a
87
+ // keep-alive socket the agent would reuse.
88
+ res.on('close', () => {
89
+ if (!upstreamRes.readableEnded)
90
+ upstream.destroy();
91
+ });
80
92
  });
81
93
  upstream.on('error', () => {
82
94
  if (!res.headersSent) {
@@ -85,6 +97,10 @@ function pipeLoopbackHttp(req, res, port, upstreamPath, auth, cookie) {
85
97
  }
86
98
  res.destroy();
87
99
  });
100
+ // The outer request aborted mid-body: reset the inner request instead of
101
+ // feeding it a truncated body that still gets processed (mirror of the
102
+ // upgrade path's two-leg teardown).
103
+ req.on('error', () => { upstream.destroy(); });
88
104
  req.pipe(upstream);
89
105
  }
90
106
  /**
@@ -31,6 +31,17 @@ import { type PairingService, type PairingSnapshot } from './pairing.ts';
31
31
  * browser markers are same-origin.
32
32
  */
33
33
  export declare function isTrustedApiRequest(request: IncomingMessage, trustedHosts: readonly string[]): boolean;
34
+ /**
35
+ * Test whether a hostname represents a private local-area network (RFC 1918 / ULA / mDNS / loopback).
36
+ * Supports pairing in container-bridged (Docker) or NAT-proxied topologies where the host
37
+ * machine's LAN IP or proxy domain differs from the container's internal sampled network interface.
38
+ */
39
+ export declare function isPrivateOrLocalHostname(hostname: string): boolean;
40
+ /**
41
+ * Validates that an incoming request is non-cross-site (rejects cross-site fetch metadata
42
+ * and ensures Origin matches Host when present).
43
+ */
44
+ export declare function isNonCrossSite(request: IncomingMessage): boolean;
34
45
  /**
35
46
  * The rate-limit bucket key for one accept attempt (pure; unit-tested).
36
47
  * The first client-visible XFF hop separates buckets behind the auto-tunnel
@@ -41,6 +52,19 @@ export declare function isTrustedApiRequest(request: IncomingMessage, trustedHos
41
52
  * @param bucket - page (GET /pair-accept) vs api (POST /api/pair/accept).
42
53
  */
43
54
  export declare function acceptLimitKey(socketIp: string, forwarded: string | undefined, bucket: 'page' | 'api'): string;
55
+ /**
56
+ * Cap on the dynamically trusted hosts (see {@link addBounded}): room for
57
+ * every legitimate router/proxy aliasing shape, small enough that a flood of
58
+ * distinct private Host headers cannot grow the table unboundedly.
59
+ */
60
+ export declare const MAX_DYNAMIC_TRUSTED_HOSTS = 64;
61
+ /**
62
+ * FIFO-bounded Set insert: past `max` entries the oldest one is evicted
63
+ * (a Set iterates in insertion order). The dynamic trusted-host table is fed
64
+ * by caller-controlled Host headers, so it must stay bounded; an evicted
65
+ * legitimate host re-adds itself on that device's next gated request.
66
+ */
67
+ export declare function addBounded(set: Set<string>, value: string, max: number): void;
44
68
  /**
45
69
  * The host authority of a configured public base URL, e.g. `foo.trycloudflare.com`
46
70
  * from `https://foo.trycloudflare.com`. Undefined when the URL does not parse —
@@ -138,8 +162,6 @@ export declare class PairingEventsStream {
138
162
  export interface PairRoutesDeps {
139
163
  /** The pairing service. */
140
164
  service: PairingService;
141
- /** The LAN IP literals the fence accepts (derived from the bind host). */
142
- lanAddresses: readonly string[];
143
165
  /** Current desktop gate policy, re-read for every status response. */
144
166
  requirePairingForLan?: boolean | (() => boolean);
145
167
  /**
@@ -158,6 +180,8 @@ export interface PairRoutesDeps {
158
180
  * cookieless header/query credential). Undefined drops the route (tests).
159
181
  */
160
182
  indexDocument?: (deviceId: string) => Promise<string | undefined>;
183
+ /** Extra trusted non-loopback hosts (from config or environment). */
184
+ trustedHosts?: readonly string[] | (() => readonly string[]);
161
185
  }
162
186
  /**
163
187
  * Build the /api/pair route family.
@@ -1 +1 @@
1
- {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAA;AAChE,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,iCAAiC,CAAA;AAC/D,OAAO,EAAE,CAAC,EAAgB,MAAM,KAAK,CAAA;AACrC,OAAO,EAA0B,KAAK,cAAc,EAAE,KAAK,eAAe,EAAE,MAAM,cAAc,CAAA;AAKhG;;;;;;;;;;;;;GAaG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,eAAe,EAAE,YAAY,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CA6BtG;AAKD;;;;;;;;GAQG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,KAAK,GAAG,MAAM,CAK9G;AAED;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAOxE;AAKD;;;;;;;;;GASG;AACH,eAAO,MAAM,sBAAsB,sBAAsB,CAAA;AAEzD,wBAAgB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAI9D;AAED,wEAAwE;AACxE,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,MAAM,CAMpE;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,sBAAsB,IAAI,MAAM,CAoD/C;AA4BD,8CAA8C;AAC9C,eAAO,MAAM,UAAU;;;;;;;;;IASrB,iEAAiE;;IAEjE,iFAAiF;;IAEjF;;;OAGG;;CAEK,CAAA;AAEV;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB;;iBAE7B,CAAA;AACF,eAAO,MAAM,mBAAmB;;iBAE9B,CAAA;AACF,eAAO,MAAM,mBAAmB;;iBAE9B,CAAA;AACF,eAAO,MAAM,uBAAuB,gCAA6B,CAAA;AAoBjE,gDAAgD;AAChD,qBAAa,mBAAmB;IAC9B,OAAO,CAAC,QAAQ,CAAC,OAAO,CAA0B;IAElD;;OAEG;gBACS,OAAO,EAAE,cAAc;IAInC,4DAA4D;IAC5D,IAAI,CAAC,GAAG,EAAE,eAAe,EAAE,GAAG,EAAE,cAAc,GAAG,IAAI;IAiBrD,kEAAkE;IAClE,IAAI,CAAC,QAAQ,EAAE,eAAe,GAAG,IAAI;IAYrC,wCAAwC;IACxC,IAAI,IAAI,IAAI,MAAM,CAEjB;CACF;AAED,6CAA6C;AAC7C,MAAM,WAAW,cAAc;IAC7B,2BAA2B;IAC3B,OAAO,EAAE,cAAc,CAAA;IACvB,0EAA0E;IAC1E,YAAY,EAAE,SAAS,MAAM,EAAE,CAAA;IAC/B,sEAAsE;IACtE,oBAAoB,CAAC,EAAE,OAAO,GAAG,CAAC,MAAM,OAAO,CAAC,CAAA;IAChD;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC7C;;;;;;;OAOG;IACH,aAAa,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAA;CAClE;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,cAAc,GAAG,QAAQ,EAAE,CAmY3D"}
1
+ {"version":3,"file":"routes.d.ts","sourceRoot":"","sources":["../../src/routes.ts"],"names":[],"mappings":"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"}
@@ -67,6 +67,87 @@ export function isTrustedApiRequest(request, trustedHosts) {
67
67
  return false;
68
68
  }
69
69
  }
70
+ /**
71
+ * Test whether a hostname represents a private local-area network (RFC 1918 / ULA / mDNS / loopback).
72
+ * Supports pairing in container-bridged (Docker) or NAT-proxied topologies where the host
73
+ * machine's LAN IP or proxy domain differs from the container's internal sampled network interface.
74
+ */
75
+ export function isPrivateOrLocalHostname(hostname) {
76
+ const normalized = hostname.toLowerCase().trim();
77
+ if (normalized === 'localhost' ||
78
+ normalized.endsWith('.local') ||
79
+ normalized.endsWith('.lan') ||
80
+ normalized.endsWith('.internal') ||
81
+ normalized.endsWith('.home.arpa')) {
82
+ return true;
83
+ }
84
+ let ip = normalized;
85
+ if (ip.startsWith('[') && ip.endsWith(']'))
86
+ ip = ip.slice(1, -1);
87
+ if (ip === '::1' || ip === '127.0.0.1')
88
+ return true;
89
+ if (ip.startsWith('fc') || ip.startsWith('fd') || ip.startsWith('fe80'))
90
+ return true;
91
+ const parts = ip.split('.').map(Number);
92
+ if (parts.length === 4 && parts.every(n => Number.isInteger(n) && n >= 0 && n <= 255)) {
93
+ const [a, b] = parts;
94
+ if (a === 10)
95
+ return true;
96
+ if (a === 127)
97
+ return true;
98
+ if (a === 169 && b === 254)
99
+ return true;
100
+ if (a === 172 && b >= 16 && b <= 31)
101
+ return true;
102
+ if (a === 192 && b === 168)
103
+ return true;
104
+ }
105
+ return false;
106
+ }
107
+ /**
108
+ * Validates that an incoming request is non-cross-site (rejects cross-site fetch metadata
109
+ * and ensures Origin matches Host when present).
110
+ */
111
+ export function isNonCrossSite(request) {
112
+ if (request.headers['sec-fetch-site'] === 'cross-site')
113
+ return false;
114
+ const host = request.headers.host;
115
+ if (typeof host !== 'string')
116
+ return false;
117
+ const origin = request.headers.origin;
118
+ if (origin === undefined)
119
+ return true;
120
+ try {
121
+ const originUrl = new URL(origin);
122
+ const hostUrl = new URL(`http://${host}`);
123
+ return originUrl.host === hostUrl.host;
124
+ }
125
+ catch {
126
+ return false;
127
+ }
128
+ }
129
+ /**
130
+ * The private-LAN fallback authority of a request: its Host header when the
131
+ * hostname is private/local (RFC 1918 / ULA / mDNS / loopback) and the
132
+ * browser markers are non-cross-site, else undefined. The accept paths and
133
+ * the lanFence fallback use this to serve container-bridged and reverse-proxy
134
+ * topologies whose Host is not among the advertised or configured authorities.
135
+ */
136
+ function privateLanHostOf(request) {
137
+ const host = request.headers.host;
138
+ if (typeof host !== 'string')
139
+ return undefined;
140
+ let hostName = '';
141
+ try {
142
+ hostName = new URL(`http://${host}`).hostname;
143
+ }
144
+ catch {
145
+ return undefined;
146
+ }
147
+ if (hostName === '' || !isPrivateOrLocalHostname(hostName) || !isNonCrossSite(request))
148
+ return undefined;
149
+ return host;
150
+ }
70
151
  /** Cap on pairing request bodies (tokens and workspace ids are tiny). */
71
152
  const MAX_BODY_BYTES = 4096;
72
153
  /**
@@ -84,6 +165,28 @@ export function acceptLimitKey(socketIp, forwarded, bucket) {
84
165
  }
85
166
  return `${bucket}|${socketIp}`;
86
167
  }
168
+ /**
169
+ * Cap on the dynamically trusted hosts (see {@link addBounded}): room for
170
+ * every legitimate router/proxy aliasing shape, small enough that a flood of
171
+ * distinct private Host headers cannot grow the table unboundedly.
172
+ */
173
+ export const MAX_DYNAMIC_TRUSTED_HOSTS = 64;
174
+ /**
175
+ * FIFO-bounded Set insert: past `max` entries the oldest one is evicted
176
+ * (a Set iterates in insertion order). The dynamic trusted-host table is fed
177
+ * by caller-controlled Host headers, so it must stay bounded; an evicted
178
+ * legitimate host re-adds itself on that device's next gated request.
179
+ */
180
+ export function addBounded(set, value, max) {
181
+ if (set.has(value))
182
+ return;
183
+ if (set.size >= max) {
184
+ const oldest = set.values().next().value;
185
+ if (oldest !== undefined)
186
+ set.delete(oldest);
187
+ }
188
+ set.add(value);
189
+ }
87
190
  /**
88
191
  * The host authority of a configured public base URL, e.g. `foo.trycloudflare.com`
89
192
  * from `https://foo.trycloudflare.com`. Undefined when the URL does not parse —
@@ -324,20 +427,45 @@ export class PairingEventsStream {
324
427
  * @returns the exact routes to register on webServer.
325
428
  */
326
429
  export function makeRoutes(deps) {
327
- const { service, lanAddresses, requirePairingForLan = true } = deps;
430
+ const { service, requirePairingForLan = true } = deps;
328
431
  const pairingRequired = () => typeof requirePairingForLan === 'function'
329
432
  ? requirePairingForLan()
330
433
  : requirePairingForLan;
331
434
  const events = new PairingEventsStream(service);
435
+ const dynamicTrustedHosts = new Set();
332
436
  /** Loopback-only fence: the desktop panel's control endpoints. */
333
437
  const loopbackFence = (req) => isTrustedApiRequest(req, []);
334
- /** Phone-facing fence: loopback, the service's live LAN literals, or the configured public host. */
438
+ /** Phone-facing fence: loopback, the service's live LAN literals, configured public host, extra trusted hosts, or dynamically paired hosts. */
335
439
  const lanFence = (req) => {
336
440
  const publicHost = publicHostOf(service.publicBaseUrl);
337
441
  // The service's LAN bases re-read per request: a hot rebind (the lan-bind
338
442
  // toggle) updates them mid-process, and the fence must follow.
339
443
  const bases = service.lanAddresses;
340
- return isTrustedApiRequest(req, publicHost === undefined ? bases : [...bases, publicHost]);
444
+ const extraHosts = typeof deps.trustedHosts === 'function' ? deps.trustedHosts() : (deps.trustedHosts ?? []);
445
+ const combined = [
446
+ ...bases,
447
+ ...(publicHost !== undefined ? [publicHost] : []),
448
+ ...extraHosts,
449
+ ...dynamicTrustedHosts,
450
+ ];
451
+ if (isTrustedApiRequest(req, combined))
452
+ return true;
453
+ // If request carries a valid paired device cookie from a private LAN host (e.g. after service restart), trust and remember it
454
+ const privateLanHost = privateLanHostOf(req);
455
+ if (privateLanHost !== undefined) {
456
+ const cookieDeviceId = readCookie(req.headers.cookie, service.config.cookieName);
457
+ let queryDeviceId = null;
458
+ try {
459
+ queryDeviceId = new URL(req.url ?? '/', 'http://pair.invalid').searchParams.get('device');
460
+ }
461
+ catch { }
462
+ const deviceId = cookieDeviceId ?? queryDeviceId ?? undefined;
463
+ if (deviceId !== undefined && service.hasDevice(deviceId)) {
464
+ addBounded(dynamicTrustedHosts, privateLanHost, MAX_DYNAMIC_TRUSTED_HOSTS);
465
+ return true;
466
+ }
467
+ }
468
+ return false;
341
469
  };
342
470
  const requireMethod = (req, res, method) => {
343
471
  if (req.method === method)
@@ -434,7 +562,8 @@ export function makeRoutes(deps) {
434
562
  const handleAccept = async (req, res) => {
435
563
  if (!requireMethod(req, res, 'POST'))
436
564
  return;
437
- if (!lanFence(req)) {
565
+ const privateLanHost = privateLanHostOf(req);
566
+ if (!lanFence(req) && privateLanHost === undefined) {
438
567
  writeJson(res, 403, { ok: false, code: 'forbidden' });
439
568
  return;
440
569
  }
@@ -451,9 +580,16 @@ export function makeRoutes(deps) {
451
580
  const ua = req.headers['user-agent'];
452
581
  const result = service.accept(payload.token, typeof ua === 'string' ? ua : undefined);
453
582
  if (!result.ok) {
583
+ if (!lanFence(req)) {
584
+ writeJson(res, 403, { ok: false, code: 'forbidden' });
585
+ return;
586
+ }
454
587
  writeJson(res, result.code === 'used' ? 409 : 404, { ok: false, code: result.code });
455
588
  return;
456
589
  }
590
+ if (privateLanHost !== undefined) {
591
+ addBounded(dynamicTrustedHosts, privateLanHost, MAX_DYNAMIC_TRUSTED_HOSTS);
592
+ }
457
593
  // No Secure attribute: LAN pairing runs over plain HTTP (the cookie must
458
594
  // work there), and the same cookie rides HTTPS on the tunnel. Lax keeps
459
595
  // top-level navigations working while blocking cross-site subrequests.
@@ -570,7 +706,8 @@ export function makeRoutes(deps) {
570
706
  const handleAcceptPage = async (req, res) => {
571
707
  if (!requireMethod(req, res, 'GET'))
572
708
  return;
573
- if (!lanFence(req)) {
709
+ const privateLanHost = privateLanHostOf(req);
710
+ if (!lanFence(req) && privateLanHost === undefined) {
574
711
  res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
575
712
  res.end('forbidden');
576
713
  return;
@@ -585,6 +722,11 @@ export function makeRoutes(deps) {
585
722
  const ua = req.headers['user-agent'];
586
723
  const result = token === '' ? { ok: false, code: 'invalid' } : service.accept(token, typeof ua === 'string' ? ua : undefined);
587
724
  if (!result.ok) {
725
+ if (!lanFence(req)) {
726
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' });
727
+ res.end('forbidden');
728
+ return;
729
+ }
588
730
  // accept() now refuses only expired/unknown/stopped tokens: a consumed
589
731
  // token stays re-usable until its expiry or replacement, so a mobile
590
732
  // flow that split across cookie contexts (camera preview, in-app
@@ -604,6 +746,9 @@ export function makeRoutes(deps) {
604
746
  res.end(pairingFailurePage());
605
747
  return;
606
748
  }
749
+ if (privateLanHost !== undefined) {
750
+ addBounded(dynamicTrustedHosts, privateLanHost, MAX_DYNAMIC_TRUSTED_HOSTS);
751
+ }
607
752
  // Land the paired device on the cookieless app page: the official shell
608
753
  // served by this plugin, with the device id in the URL. No harness index
609
754
  // gate, no browser-auth cookie hop - the channel gate accepts the device
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@linxin666/dsh-remote-web-ui",
3
3
  "description": "Scan-to-pair remote access for the dsh web GUI that shares one official interface: a QR beside the settings button pairs phones and PCs into the same Web GUI (a portrait-touch adaptation layer for phones, full desktop on PCs) through one-time tokens and revocable device sessions, with a LAN bind toggle, optional Cloudflare tunnel, and one-click family self-update",
4
- "version": "0.3.14",
4
+ "version": "0.3.15",
5
5
  "type": "module",
6
6
  "engines": {
7
7
  "node": "^22.19.0 || >=24.0.0"
@@ -62,6 +62,7 @@ export function RemoteEntry({ wide, t }: RemoteEntryProps) {
62
62
  const stateRef = useRef(state)
63
63
  useEffect(() => { stateRef.current = state }, [state])
64
64
  const [copied, setCopied] = useState<boolean>(false)
65
+ const [copiedToken, setCopiedToken] = useState<boolean>(false)
65
66
  const eventSource = useRef<EventSource | undefined>(undefined)
66
67
  // Generation counter for the open flow: closing (or re-opening) the panel
67
68
  // bumps it, so an in-flight issue() that resolves after a close does not
@@ -94,6 +95,7 @@ export function RemoteEntry({ wide, t }: RemoteEntryProps) {
94
95
  return {
95
96
  kind: 'ready',
96
97
  url: result.url,
98
+ token: result.token,
97
99
  expiresAt: result.expiresAt,
98
100
  expired: Date.now() > result.expiresAt,
99
101
  phase: 'waiting',
@@ -215,6 +217,14 @@ export function RemoteEntry({ wide, t }: RemoteEntryProps) {
215
217
  })
216
218
  }, [])
217
219
 
220
+ const handleCopyToken = useCallback((token: string) => {
221
+ void copyText(token).then((ok) => {
222
+ if (!ok) return
223
+ setCopiedToken(true)
224
+ window.setTimeout(() => { setCopiedToken(false) }, 1500)
225
+ })
226
+ }, [])
227
+
218
228
  return (
219
229
  <>
220
230
  <div className={css.entryRow} data-rail={wide ? undefined : 'rail'}>
@@ -228,10 +238,12 @@ export function RemoteEntry({ wide, t }: RemoteEntryProps) {
228
238
  t={t}
229
239
  state={state}
230
240
  copied={copied}
241
+ copiedToken={copiedToken}
231
242
  onClose={closePanel}
232
243
  onStop={handleStop}
233
244
  onRefresh={handleRefresh}
234
245
  onCopy={handleCopy}
246
+ onCopyToken={handleCopyToken}
235
247
  onPickAddress={handlePickAddress}
236
248
  onPickPublic={handlePickPublic}
237
249
  onRevoke={handleRevoke}
@@ -41,6 +41,8 @@ export type PanelState =
41
41
  address: string
42
42
  /** Every constructible LAN literal (interface order). */
43
43
  lanAddresses: string[]
44
+ /** The active pairing token itself (useful in Docker/reverse proxy topologies). */
45
+ token?: string
44
46
  /** Whether this QR is built on the configured public (tunneled) base. */
45
47
  public: boolean
46
48
  /** The configured public (tunneled) base URL, when present. */
@@ -58,10 +60,12 @@ export interface RemotePanelProps {
58
60
  t: TranslateNS<'remote'>
59
61
  state: PanelState
60
62
  copied: boolean
63
+ copiedToken?: boolean
61
64
  onClose(): void
62
65
  onStop(): void
63
66
  onRefresh(): void
64
67
  onCopy(url: string): void
68
+ onCopyToken?(token: string): void
65
69
  /** Re-mint the QR against a different LAN address. */
66
70
  onPickAddress(address: string): void
67
71
  /** Re-mint the QR against the configured public (tunneled) base. */
@@ -89,7 +93,20 @@ function statusOf(
89
93
  * @param props - copy, state, and actions.
90
94
  * @returns the panel element tree.
91
95
  */
92
- export function RemotePanel({ t, state, copied, onClose, onStop, onRefresh, onCopy, onPickAddress, onPickPublic, onRevoke }: RemotePanelProps) {
96
+ export function RemotePanel({
97
+ t,
98
+ state,
99
+ copied,
100
+ copiedToken,
101
+ onClose,
102
+ onStop,
103
+ onRefresh,
104
+ onCopy,
105
+ onCopyToken,
106
+ onPickAddress,
107
+ onPickPublic,
108
+ onRevoke,
109
+ }: RemotePanelProps) {
93
110
  return (
94
111
  <div className={css.panel} role="dialog" aria-modal="true" aria-label={t('title')}>
95
112
  <div className={css.header}>
@@ -157,8 +174,26 @@ export function RemotePanel({ t, state, copied, onClose, onStop, onRefresh, onCo
157
174
  {copied ? t('action.copied') : t('action.copyLink')}
158
175
  </button>
159
176
  </div>
177
+ {state.token !== undefined && state.token !== '' && (
178
+ <div className={css.pairLinkRow}>
179
+ <div className={css.pairLinkText}>
180
+ <span className={css.pairLinkLabel}>{t('pair.tokenLabel')}</span>
181
+ <code className={css.link} title={state.token}>{state.token}</code>
182
+ </div>
183
+ <button
184
+ type="button"
185
+ className={css.copyLink}
186
+ onClick={() => onCopyToken ? onCopyToken(state.token!) : onCopy(state.token!)}
187
+ >
188
+ <IconCopyOutline16 size={14} />
189
+ {copiedToken ? t('action.copiedToken') : t('action.copyToken')}
190
+ </button>
191
+ </div>
192
+ )}
160
193
  </div>
161
- <p className={css.oneTimeHint}>{t('pair.oneTimeHint')}</p>
194
+ <p className={css.oneTimeHint}>
195
+ {t('pair.oneTimeHint')} {t('pair.dockerHint')}
196
+ </p>
162
197
  {state.phase === 'stopped' && <p className={css.stoppedHint}>{t('stopped.hint')}</p>}
163
198
  {state.tunnel !== undefined && state.tunnel.state !== 'running' && (
164
199
  <p className={state.tunnel.state === 'failed' ? css.tunnelFailed : css.tunnelNote} role="status">
@@ -29,6 +29,8 @@ export const zh = {
29
29
  'pair.expires': '二维码有效至 {time}',
30
30
  'pair.expired': '二维码已过期,请刷新',
31
31
  'pair.linkLabel': '配对链接',
32
+ 'pair.tokenLabel': '配对令牌',
33
+ 'pair.dockerHint': 'Docker 或反向代理环境下,可直接复制此令牌并在目标设备配对页面输入。',
32
34
  'pair.oneTimeHint': '链接含一次性令牌;任一设备配对成功后立即失效,配对下一台设备请刷新二维码。',
33
35
  'pair.failed.title': '配对失败',
34
36
  'pair.failed.detail': '链接无效或已使用,请回到电脑端刷新二维码后重新扫码。',
@@ -52,7 +54,9 @@ export const zh = {
52
54
  'action.refresh': '刷新二维码',
53
55
  'action.copy': '复制链接',
54
56
  'action.copyLink': '复制链接',
57
+ 'action.copyToken': '复制令牌',
55
58
  'action.copied': '已复制',
59
+ 'action.copiedToken': '已复制令牌',
56
60
  'devices.title': '已授权设备',
57
61
  'devices.empty': '还没有已配对的设备。扫码或打开链接后会出现在这里。',
58
62
  'devices.unknown': '未知设备',
@@ -199,6 +203,8 @@ export const en = {
199
203
  'pair.expires': 'QR code valid until {time}',
200
204
  'pair.expired': 'QR code expired — refresh it',
201
205
  'pair.linkLabel': 'Pairing link',
206
+ 'pair.tokenLabel': 'Pairing token',
207
+ 'pair.dockerHint': 'In Docker or reverse proxy environments, copy this token to pair directly on the target device.',
202
208
  'pair.oneTimeHint': 'The link carries one single-use token; it dies as soon as any device pairs. Refresh the QR to pair the next device.',
203
209
  'pair.failed.title': 'Pairing failed',
204
210
  'pair.failed.detail': 'The link is invalid or was already used. Refresh the QR code on your computer and scan again.',
@@ -222,7 +228,9 @@ export const en = {
222
228
  'action.refresh': 'Refresh QR',
223
229
  'action.copy': 'Copy link',
224
230
  'action.copyLink': 'Copy link',
231
+ 'action.copyToken': 'Copy token',
225
232
  'action.copied': 'Copied',
233
+ 'action.copiedToken': 'Token copied',
226
234
  'devices.title': 'Authorized devices',
227
235
  'devices.empty': 'No paired devices yet. Scan the QR or open the link to add one.',
228
236
  'devices.unknown': 'Unknown device',