@linxin666/dsh-remote-web-ui 0.3.5 → 0.3.9

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 (238) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +77 -468
  3. package/README.zh.md +83 -113
  4. package/lib/client.js +955 -203
  5. package/lib/client.js.map +1 -1
  6. package/lib/index.js +1136 -1466
  7. package/lib/types/client/FooterRemoteEntry.d.ts +6 -7
  8. package/lib/types/client/FooterRemoteEntry.d.ts.map +1 -1
  9. package/lib/types/client/FooterRemoteEntry.js +1 -1
  10. package/lib/types/client/RemoteEntry.d.ts +7 -4
  11. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  12. package/lib/types/client/RemoteEntry.js +7 -10
  13. package/lib/types/client/RemotePanel.d.ts +2 -2
  14. package/lib/types/client/RemotePanel.d.ts.map +1 -1
  15. package/lib/types/client/RemotePanel.js +2 -2
  16. package/lib/types/client/RemoteSettingsCard.d.ts +9 -5
  17. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  18. package/lib/types/client/RemoteSettingsCard.js +56 -3
  19. package/lib/types/client/deep-link.d.ts +10 -13
  20. package/lib/types/client/deep-link.d.ts.map +1 -1
  21. package/lib/types/client/deep-link.js +14 -74
  22. package/lib/types/client/index.d.ts +2 -6
  23. package/lib/types/client/index.d.ts.map +1 -1
  24. package/lib/types/client/index.js +73 -39
  25. package/lib/types/client/locales.d.ts +26 -12
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +36 -22
  28. package/lib/types/client/mobile-adapt.d.ts +40 -0
  29. package/lib/types/client/mobile-adapt.d.ts.map +1 -0
  30. package/lib/types/client/mobile-adapt.js +967 -0
  31. package/lib/types/client/pair-api.d.ts +24 -6
  32. package/lib/types/client/pair-api.d.ts.map +1 -1
  33. package/lib/types/client/pair-api.js +9 -14
  34. package/lib/types/client/remote-channel.d.ts +3 -0
  35. package/lib/types/client/remote-channel.d.ts.map +1 -1
  36. package/lib/types/client/remote-channel.js +35 -3
  37. package/lib/types/client/settings-form.d.ts +35 -43
  38. package/lib/types/client/settings-form.d.ts.map +1 -1
  39. package/lib/types/client/settings-form.js +60 -53
  40. package/lib/types/firewall.d.ts +75 -0
  41. package/lib/types/firewall.d.ts.map +1 -0
  42. package/lib/types/firewall.js +184 -0
  43. package/lib/types/gate.d.ts +11 -5
  44. package/lib/types/gate.d.ts.map +1 -1
  45. package/lib/types/gate.js +11 -5
  46. package/lib/types/index.d.ts +39 -10
  47. package/lib/types/index.d.ts.map +1 -1
  48. package/lib/types/index.js +206 -58
  49. package/lib/types/inner-auth.d.ts +32 -0
  50. package/lib/types/inner-auth.d.ts.map +1 -0
  51. package/lib/types/inner-auth.js +72 -0
  52. package/lib/types/lan-bind-plan.d.ts +55 -0
  53. package/lib/types/lan-bind-plan.d.ts.map +1 -0
  54. package/lib/types/lan-bind-plan.js +60 -0
  55. package/lib/types/lan-bind.d.ts +69 -0
  56. package/lib/types/lan-bind.d.ts.map +1 -0
  57. package/lib/types/lan-bind.js +128 -0
  58. package/lib/types/loopback-proxy.d.ts +9 -2
  59. package/lib/types/loopback-proxy.d.ts.map +1 -1
  60. package/lib/types/loopback-proxy.js +24 -2
  61. package/lib/types/pairing.d.ts +15 -3
  62. package/lib/types/pairing.d.ts.map +1 -1
  63. package/lib/types/pairing.js +20 -6
  64. package/lib/types/remote-api.d.ts +30 -13
  65. package/lib/types/remote-api.d.ts.map +1 -1
  66. package/lib/types/remote-api.js +55 -42
  67. package/lib/types/remote-channel-boot.d.ts +21 -2
  68. package/lib/types/remote-channel-boot.d.ts.map +1 -1
  69. package/lib/types/remote-channel-boot.js +65 -3
  70. package/lib/types/remote-channel-rules.d.ts +8 -0
  71. package/lib/types/remote-channel-rules.d.ts.map +1 -1
  72. package/lib/types/remote-channel-rules.js +9 -2
  73. package/lib/types/remote-methods.d.ts +45 -11
  74. package/lib/types/remote-methods.d.ts.map +1 -1
  75. package/lib/types/remote-methods.js +56 -27
  76. package/lib/types/remote-presence-pet.d.ts +51 -0
  77. package/lib/types/remote-presence-pet.d.ts.map +1 -0
  78. package/lib/types/remote-presence-pet.js +101 -0
  79. package/lib/types/routes.d.ts +76 -4
  80. package/lib/types/routes.d.ts.map +1 -1
  81. package/lib/types/routes.js +336 -19
  82. package/lib/types/tunnel.d.ts.map +1 -1
  83. package/lib/types/tunnel.js +7 -1
  84. package/package.json +20 -18
  85. package/src/client/FooterRemoteEntry.tsx +9 -10
  86. package/src/client/RemoteEntry.tsx +13 -14
  87. package/src/client/RemotePanel.tsx +5 -16
  88. package/src/client/RemoteSettingsCard.tsx +72 -13
  89. package/src/client/deep-link.ts +14 -75
  90. package/src/client/index.ts +77 -39
  91. package/src/client/locales.ts +36 -22
  92. package/src/client/mobile-adapt.ts +913 -0
  93. package/src/client/pair-api.ts +26 -15
  94. package/src/client/remote-channel.ts +31 -3
  95. package/src/client/settings-form.ts +78 -92
  96. package/src/dsh-home.ts +1 -1
  97. package/src/firewall.ts +230 -0
  98. package/src/gate.ts +11 -5
  99. package/src/index.ts +235 -65
  100. package/src/inner-auth.ts +85 -0
  101. package/src/lan-bind-plan.ts +81 -0
  102. package/src/lan-bind.ts +145 -0
  103. package/src/loopback-proxy.ts +34 -0
  104. package/src/pairing.persist.test.ts +1 -1
  105. package/src/pairing.ts +20 -6
  106. package/src/remote-api.ts +67 -45
  107. package/src/remote-channel-boot.ts +70 -3
  108. package/src/remote-channel-rules.ts +17 -2
  109. package/src/remote-methods.ts +58 -27
  110. package/src/remote-presence-pet.ts +129 -0
  111. package/src/routes.ts +358 -19
  112. package/src/tunnel.ts +7 -1
  113. package/assets/apple-touch-icon.png +0 -0
  114. package/assets/mobile-icon-192.png +0 -0
  115. package/assets/mobile-icon-512.png +0 -0
  116. package/assets/mobile-service-worker.js +0 -63
  117. package/lib/mobile.js +0 -17621
  118. package/lib/mobile.js.map +0 -1
  119. package/lib/types/mobile/PairRequiredView.d.ts +0 -7
  120. package/lib/types/mobile/PairRequiredView.d.ts.map +0 -1
  121. package/lib/types/mobile/PairRequiredView.js +0 -28
  122. package/lib/types/mobile/PairRequiredView.test.d.ts +0 -2
  123. package/lib/types/mobile/PairRequiredView.test.d.ts.map +0 -1
  124. package/lib/types/mobile/PairRequiredView.test.js +0 -24
  125. package/lib/types/mobile/api.d.ts +0 -145
  126. package/lib/types/mobile/api.d.ts.map +0 -1
  127. package/lib/types/mobile/api.js +0 -103
  128. package/lib/types/mobile/index.d.ts +0 -10
  129. package/lib/types/mobile/index.d.ts.map +0 -1
  130. package/lib/types/mobile/index.js +0 -38
  131. package/lib/types/mobile/markdown.d.ts +0 -34
  132. package/lib/types/mobile/markdown.d.ts.map +0 -1
  133. package/lib/types/mobile/markdown.js +0 -285
  134. package/lib/types/mobile/markdown.test.d.ts +0 -2
  135. package/lib/types/mobile/markdown.test.d.ts.map +0 -1
  136. package/lib/types/mobile/markdown.test.js +0 -79
  137. package/lib/types/mobile/messages.d.ts +0 -141
  138. package/lib/types/mobile/messages.d.ts.map +0 -1
  139. package/lib/types/mobile/messages.js +0 -553
  140. package/lib/types/mobile/messages.test.d.ts +0 -2
  141. package/lib/types/mobile/messages.test.d.ts.map +0 -1
  142. package/lib/types/mobile/messages.test.js +0 -356
  143. package/lib/types/mobile/mobile-styles.d.ts +0 -6
  144. package/lib/types/mobile/mobile-styles.d.ts.map +0 -1
  145. package/lib/types/mobile/mobile-styles.js +0 -1438
  146. package/lib/types/mobile/mobile-theme.d.ts +0 -22
  147. package/lib/types/mobile/mobile-theme.d.ts.map +0 -1
  148. package/lib/types/mobile/mobile-theme.js +0 -70
  149. package/lib/types/mobile/mobile-theme.test.d.ts +0 -2
  150. package/lib/types/mobile/mobile-theme.test.d.ts.map +0 -1
  151. package/lib/types/mobile/mobile-theme.test.js +0 -71
  152. package/lib/types/mobile/mux.d.ts +0 -128
  153. package/lib/types/mobile/mux.d.ts.map +0 -1
  154. package/lib/types/mobile/mux.js +0 -305
  155. package/lib/types/mobile/mux.test.d.ts +0 -2
  156. package/lib/types/mobile/mux.test.d.ts.map +0 -1
  157. package/lib/types/mobile/mux.test.js +0 -267
  158. package/lib/types/mobile/pairing.d.ts +0 -31
  159. package/lib/types/mobile/pairing.d.ts.map +0 -1
  160. package/lib/types/mobile/pairing.js +0 -62
  161. package/lib/types/mobile/rpc.d.ts +0 -38
  162. package/lib/types/mobile/rpc.d.ts.map +0 -1
  163. package/lib/types/mobile/rpc.js +0 -83
  164. package/lib/types/mobile/theme-toggle.d.ts +0 -12
  165. package/lib/types/mobile/theme-toggle.d.ts.map +0 -1
  166. package/lib/types/mobile/theme-toggle.js +0 -26
  167. package/lib/types/mobile/views/App.d.ts +0 -57
  168. package/lib/types/mobile/views/App.d.ts.map +0 -1
  169. package/lib/types/mobile/views/App.js +0 -150
  170. package/lib/types/mobile/views/App.test.d.ts +0 -2
  171. package/lib/types/mobile/views/App.test.d.ts.map +0 -1
  172. package/lib/types/mobile/views/App.test.js +0 -52
  173. package/lib/types/mobile/views/ChatView.d.ts +0 -56
  174. package/lib/types/mobile/views/ChatView.d.ts.map +0 -1
  175. package/lib/types/mobile/views/ChatView.js +0 -773
  176. package/lib/types/mobile/views/ChatView.stream.test.d.ts +0 -2
  177. package/lib/types/mobile/views/ChatView.stream.test.d.ts.map +0 -1
  178. package/lib/types/mobile/views/ChatView.stream.test.js +0 -231
  179. package/lib/types/mobile/views/ChatView.test.d.ts +0 -2
  180. package/lib/types/mobile/views/ChatView.test.d.ts.map +0 -1
  181. package/lib/types/mobile/views/ChatView.test.js +0 -718
  182. package/lib/types/mobile/views/SessionListView.d.ts +0 -27
  183. package/lib/types/mobile/views/SessionListView.d.ts.map +0 -1
  184. package/lib/types/mobile/views/SessionListView.js +0 -139
  185. package/lib/types/mobile/views/SessionListView.test.d.ts +0 -2
  186. package/lib/types/mobile/views/SessionListView.test.d.ts.map +0 -1
  187. package/lib/types/mobile/views/SessionListView.test.js +0 -167
  188. package/lib/types/mobile/views/WorkspaceView.d.ts +0 -21
  189. package/lib/types/mobile/views/WorkspaceView.d.ts.map +0 -1
  190. package/lib/types/mobile/views/WorkspaceView.js +0 -93
  191. package/lib/types/mobile/views/WorkspaceView.test.d.ts +0 -2
  192. package/lib/types/mobile/views/WorkspaceView.test.d.ts.map +0 -1
  193. package/lib/types/mobile/views/WorkspaceView.test.js +0 -183
  194. package/lib/types/mobile-api.d.ts +0 -65
  195. package/lib/types/mobile-api.d.ts.map +0 -1
  196. package/lib/types/mobile-api.js +0 -404
  197. package/lib/types/mobile-pending.d.ts +0 -38
  198. package/lib/types/mobile-pending.d.ts.map +0 -1
  199. package/lib/types/mobile-pending.js +0 -59
  200. package/lib/types/mobile-pwa.d.ts +0 -6
  201. package/lib/types/mobile-pwa.d.ts.map +0 -1
  202. package/lib/types/mobile-pwa.js +0 -17
  203. package/lib/types/mobile-routes.d.ts +0 -13
  204. package/lib/types/mobile-routes.d.ts.map +0 -1
  205. package/lib/types/mobile-routes.js +0 -217
  206. package/lib/types/paired-model-catalog.d.ts +0 -17
  207. package/lib/types/paired-model-catalog.d.ts.map +0 -1
  208. package/lib/types/paired-model-catalog.js +0 -311
  209. package/src/mobile/PairRequiredView.test.tsx +0 -28
  210. package/src/mobile/PairRequiredView.tsx +0 -58
  211. package/src/mobile/api.ts +0 -229
  212. package/src/mobile/index.tsx +0 -41
  213. package/src/mobile/markdown.test.ts +0 -91
  214. package/src/mobile/markdown.ts +0 -291
  215. package/src/mobile/messages.test.ts +0 -402
  216. package/src/mobile/messages.ts +0 -677
  217. package/src/mobile/mobile-styles.ts +0 -1439
  218. package/src/mobile/mobile-theme.test.ts +0 -84
  219. package/src/mobile/mobile-theme.ts +0 -76
  220. package/src/mobile/mux.test.ts +0 -314
  221. package/src/mobile/mux.ts +0 -336
  222. package/src/mobile/pairing.ts +0 -77
  223. package/src/mobile/rpc.ts +0 -90
  224. package/src/mobile/theme-toggle.tsx +0 -47
  225. package/src/mobile/views/App.test.tsx +0 -62
  226. package/src/mobile/views/App.tsx +0 -213
  227. package/src/mobile/views/ChatView.stream.test.tsx +0 -263
  228. package/src/mobile/views/ChatView.test.tsx +0 -816
  229. package/src/mobile/views/ChatView.tsx +0 -1295
  230. package/src/mobile/views/SessionListView.test.tsx +0 -207
  231. package/src/mobile/views/SessionListView.tsx +0 -244
  232. package/src/mobile/views/WorkspaceView.test.tsx +0 -227
  233. package/src/mobile/views/WorkspaceView.tsx +0 -245
  234. package/src/mobile-api.ts +0 -418
  235. package/src/mobile-pending.ts +0 -88
  236. package/src/mobile-pwa.ts +0 -19
  237. package/src/mobile-routes.ts +0 -231
  238. package/src/paired-model-catalog.ts +0 -272
package/src/routes.ts CHANGED
@@ -3,15 +3,21 @@
3
3
  * under /api: the webserver matches exact paths before the connection
4
4
  * plugin's /api prefix, so these handlers own the full response lifecycle
5
5
  * and apply their own trust fence (loopback-only for control endpoints;
6
- * loopback-or-LAN for the phone-facing accept/heartbeat/status). The
7
- * cookie set on accept is the device identity the api/gate listener checks
8
- * on every other /api request.
6
+ * loopback-or-LAN for the phone-facing accept/heartbeat/status). The cookie
7
+ * set on accept is the device identity the plugin's own surfaces enforce:
8
+ * the /remote channel gate and the api/gate listener. Alongside the JSON
9
+ * family sit the phone-facing top-level pages (exact routes /pair-accept,
10
+ * /pair-app, /pair-app.sw.js) with their own LAN-or-public fence. Note that
11
+ * on the 0.1.2-alpha.2 cohort nothing emits api/gate — direct /api is
12
+ * governed by the harness fence + browser auth — while the /remote channel
13
+ * always enforces the pairing cookie itself.
9
14
  */
10
15
 
11
16
  import type { IncomingMessage, ServerResponse } from 'node:http'
12
17
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
13
18
  import { z, type ZodType } from 'zod'
14
19
  import { UnknownLanAddressError, type PairingService, type PairingSnapshot } from './pairing.ts'
20
+ import { isLoopbackAddress } from './loopback.ts'
15
21
  import { isLoopbackClient, readCookie } from './gate.ts'
16
22
  import { readJsonBody, writeJson } from './http.ts'
17
23
 
@@ -63,6 +69,22 @@ export function isTrustedApiRequest(request: IncomingMessage, trustedHosts: read
63
69
  /** Cap on pairing request bodies (tokens and workspace ids are tiny). */
64
70
  const MAX_BODY_BYTES = 4096
65
71
 
72
+ /**
73
+ * The rate-limit bucket key for one accept attempt (pure; unit-tested).
74
+ * The first client-visible XFF hop separates buckets behind the auto-tunnel
75
+ * (every internet client arrives from 127.0.0.1 there) — but only for
76
+ * loopback peers, since a direct LAN client can rotate the header freely.
77
+ * @param socketIp - the socket peer address.
78
+ * @param forwarded - the first XFF hop, already trimmed, if any.
79
+ * @param bucket - page (GET /pair-accept) vs api (POST /api/pair/accept).
80
+ */
81
+ export function acceptLimitKey(socketIp: string, forwarded: string | undefined, bucket: 'page' | 'api'): string {
82
+ if (isLoopbackAddress(socketIp) && forwarded !== undefined && forwarded !== '') {
83
+ return `${bucket}|${socketIp}|${forwarded}`
84
+ }
85
+ return `${bucket}|${socketIp}`
86
+ }
87
+
66
88
  /**
67
89
  * The host authority of a configured public base URL, e.g. `foo.trycloudflare.com`
68
90
  * from `https://foo.trycloudflare.com`. Undefined when the URL does not parse —
@@ -83,6 +105,130 @@ export function publicHostOf(url: string | undefined): string | undefined {
83
105
  /** Cookie lifetime: one year; revoked sessions die at the gate regardless. */
84
106
  const COOKIE_MAX_AGE_SEC = 365 * 24 * 60 * 60
85
107
 
108
+ /**
109
+ * The cookieless device credential: pass the device id from the /pair-app
110
+ * URL into sessionStorage (and localStorage for tab reloads) before any app
111
+ * script runs - same key the boot patch and the channel gate read. The
112
+ * replaceState to '/' hides the credential URL from the address bar and
113
+ * leaves the SPA at its canonical root path. The reopen service worker is
114
+ * registered in the same breath: later navigations to '/' (history,
115
+ * bookmark, tab restore) must not fall through to the harness index gate,
116
+ * which the cookieless flow can never satisfy.
117
+ */
118
+ export const APP_DEVICE_STORAGE_KEY = 'dsh-remote-device'
119
+
120
+ export function appShellCaptureScript(deviceId: string): string {
121
+ const safeId = JSON.stringify(deviceId)
122
+ const register = `try{if('serviceWorker' in navigator){navigator.serviceWorker.register(${JSON.stringify(PAIR_PATHS.appServiceWorker)},{scope:'/'}).catch(function(e){})}}catch(e){}`
123
+ return `<script>(function(){try{sessionStorage.setItem(${JSON.stringify(APP_DEVICE_STORAGE_KEY)},${safeId});localStorage.setItem(${JSON.stringify(APP_DEVICE_STORAGE_KEY)},${safeId});}catch(e){}try{history.replaceState(null,'','/')}catch(e){}${register}})()</script>`
124
+ }
125
+
126
+ /** Patch the official index document with the device-capture script. */
127
+ export function patchAppShell(html: string, deviceId: string): string {
128
+ const script = appShellCaptureScript(deviceId)
129
+ const marker = '</head>'
130
+ const at = html.indexOf(marker)
131
+ if (at === -1) return script + html
132
+ return html.slice(0, at) + script + html.slice(at)
133
+ }
134
+
135
+ /**
136
+ * The reopen service worker served at PAIR_PATHS.appServiceWorker. A paired
137
+ * phone comes back to `/` from history, bookmarks, or tab restore; the
138
+ * harness fallback seat answers that navigation with its browser-auth 401,
139
+ * and the cookieless mobile flow never holds a browser-auth cookie. The
140
+ * worker owns navigations to `/` instead: network-first through /pair-app
141
+ * (which validates the device cookie and refreshes its presence), the
142
+ * cached shell for offline opens, and a pass-through of the original
143
+ * request when the plugin no longer answers. Plain-HTTP LAN origins are not
144
+ * secure contexts, so the worker never registers there — the LAN reopen
145
+ * path stays "scan a fresh QR", which is cheap in-network.
146
+ *
147
+ * Kept as a plain string (same pattern as the capture script): it must load
148
+ * with no build step, run on every JS engine that ships service workers,
149
+ * and be assertable as source in tests. Bump SHELL_CACHE when the storage
150
+ * layout changes so old caches are pruned on activate.
151
+ */
152
+ export function appServiceWorkerScript(): string {
153
+ return `'use strict';
154
+ var SHELL_CACHE = 'dsh-remote-shell-v1';
155
+ var SHELL_KEY = '/dsh-remote-shell';
156
+ var APP_URL = '/pair-app';
157
+ self.addEventListener('install', function (event) {
158
+ event.waitUntil(refreshShell().then(function () { return self.skipWaiting(); }));
159
+ });
160
+ self.addEventListener('activate', function (event) {
161
+ event.waitUntil(self.caches.keys().then(function (names) {
162
+ return Promise.all(names.map(function (name) {
163
+ return name === SHELL_CACHE ? Promise.resolve() : self.caches.delete(name);
164
+ }));
165
+ }).then(function () { return self.clients.claim(); }));
166
+ });
167
+ self.addEventListener('fetch', function (event) {
168
+ var request = event.request;
169
+ if (request.method !== 'GET' || request.mode !== 'navigate') return;
170
+ var path;
171
+ try { path = new URL(request.url).pathname; } catch (error) { return; }
172
+ if (path !== '/') return;
173
+ event.respondWith(reopen(event));
174
+ });
175
+ /* Network-first: a live pairing gets the current shell (the /pair-app check
176
+ refreshes lastSeenAt, so every reopen also keeps the session alive); a
177
+ refused shell passes the original navigation through for the harness's
178
+ own response; an unreachable server falls back to the cached shell. */
179
+ function reopen(event) {
180
+ var request = event.request;
181
+ return fetch(APP_URL, { credentials: 'same-origin', cache: 'no-store' }).then(function (response) {
182
+ if (!response.ok) return fetch(request);
183
+ var copy = response.clone();
184
+ event.waitUntil(self.caches.open(SHELL_CACHE).then(function (cache) {
185
+ return cache.put(SHELL_KEY, copy);
186
+ }));
187
+ return response;
188
+ }, function () {
189
+ return self.caches.match(SHELL_KEY).then(function (cached) {
190
+ return cached !== undefined ? cached : fetch(request);
191
+ });
192
+ });
193
+ }
194
+ /* Best-effort shell warm-up and refresh; never rejects. */
195
+ function refreshShell() {
196
+ return fetch(APP_URL, { credentials: 'same-origin', cache: 'no-store' }).then(function (response) {
197
+ if (!response.ok) return undefined;
198
+ var copy = response.clone();
199
+ return self.caches.open(SHELL_CACHE).then(function (cache) {
200
+ return cache.put(SHELL_KEY, copy);
201
+ });
202
+ }, function () { return undefined; });
203
+ }`
204
+ }
205
+
206
+ /**
207
+ * The dead-end guard for a failed /pair-accept and for a dead reopen (the
208
+ * reopen service worker serves this page at `/` when the pairing no longer
209
+ * passes): a device without a live pairing has no harness browser-auth
210
+ * cookie, so a redirect to bare `/` would land on the harness browser-auth
211
+ * 401 page ("authentication required"), which reads like a broken server.
212
+ * Serve a plain bilingual explanation instead; an already-paired device (live
213
+ * device cookie, browser credential redeemed during its first scan) keeps
214
+ * the old behavior and is sent on to the app.
215
+ */
216
+ function pairingFailurePage(): string {
217
+ return [
218
+ '<!doctype html><html><head><meta charset="utf-8">',
219
+ '<meta name="viewport" content="width=device-width,initial-scale=1">',
220
+ '<meta name="referrer" content="no-referrer">',
221
+ '<title>Pairing link invalid</title></head>',
222
+ '<body style="font-family:system-ui,sans-serif;padding:24px;max-width:32em;margin:0 auto;line-height:1.6">',
223
+ '<p><strong>配对已失效,或配对链接已过期。</strong></p>',
224
+ '<p>请在桌面端打开远程控制面板,刷新二维码后重新扫码;重新配对后本机即可恢复访问。</p>',
225
+ '<hr style="border:none;border-top:1px solid #ccc;margin:16px 0">',
226
+ '<p><strong>This pairing has expired or the link is no longer valid.</strong></p>',
227
+ '<p>Open the remote panel on the desktop, refresh the QR code, and scan again — re-pairing restores access on this device.</p>',
228
+ '</body></html>',
229
+ ].join('')
230
+ }
231
+
86
232
  /** Route paths (exact matches under /api). */
87
233
  export const PAIR_PATHS = {
88
234
  issue: '/api/pair/issue',
@@ -92,6 +238,16 @@ export const PAIR_PATHS = {
92
238
  heartbeat: '/api/pair/heartbeat',
93
239
  status: '/api/pair/status',
94
240
  events: '/api/pair/events',
241
+ lanBind: '/api/pair/lan-bind',
242
+ /** Top-level accept-and-redirect entry the QR link points at. */
243
+ acceptPage: '/pair-accept',
244
+ /** The cookieless app landing: serves the official shell for a paired device. */
245
+ appPage: '/pair-app',
246
+ /**
247
+ * The reopen service worker (registered by the capture script). Root-level
248
+ * path so its default script-directory scope already covers `/`.
249
+ */
250
+ appServiceWorker: '/pair-app.sw.js',
95
251
  } as const
96
252
 
97
253
  /**
@@ -103,7 +259,6 @@ export const PAIR_PATHS = {
103
259
  * are tolerated exactly as the previous manual reads ignored them.
104
260
  */
105
261
  export const issuePayloadSchema = z.object({
106
- workspaceId: z.string().min(1).optional(),
107
262
  address: z.string().min(1).optional(),
108
263
  })
109
264
  export const acceptPayloadSchema = z.object({
@@ -188,6 +343,22 @@ export interface PairRoutesDeps {
188
343
  lanAddresses: readonly string[]
189
344
  /** Current desktop gate policy, re-read for every status response. */
190
345
  requirePairingForLan?: boolean | (() => boolean)
346
+ /**
347
+ * LAN-bind facts for the settings card (managed patch block state, live
348
+ * bind host/port, firewall summary). Re-read per request so a hot-reloaded
349
+ * rebind and a fresh toggle round are both reflected without a restart.
350
+ * The route is loopback-only; undefined drops it (tests).
351
+ */
352
+ lanBindStatus?: () => Record<string, unknown>
353
+ /**
354
+ * The official index document to serve on the /pair-app landing. The
355
+ * plugin fetches it from the inner loopback with its own credential and
356
+ * patches it with the device-capture script, so a paired device loads
357
+ * the app shell WITHOUT passing the harness index gate - the mobile flow
358
+ * then needs no browser cookie at all (the channel gate accepts the
359
+ * cookieless header/query credential). Undefined drops the route (tests).
360
+ */
361
+ indexDocument?: (deviceId: string) => Promise<string | undefined>
191
362
  }
192
363
 
193
364
  /**
@@ -204,10 +375,13 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
204
375
 
205
376
  /** Loopback-only fence: the desktop panel's control endpoints. */
206
377
  const loopbackFence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, [])
207
- /** Phone-facing fence: loopback, the derived LAN literals, or the configured public host. */
378
+ /** Phone-facing fence: loopback, the service's live LAN literals, or the configured public host. */
208
379
  const lanFence = (req: IncomingMessage): boolean => {
209
380
  const publicHost = publicHostOf(service.publicBaseUrl)
210
- return isTrustedApiRequest(req, publicHost === undefined ? lanAddresses : [...lanAddresses, publicHost])
381
+ // The service's LAN bases re-read per request: a hot rebind (the lan-bind
382
+ // toggle) updates them mid-process, and the fence must follow.
383
+ const bases = service.lanAddresses
384
+ return isTrustedApiRequest(req, publicHost === undefined ? bases : [...bases, publicHost])
211
385
  }
212
386
 
213
387
  const requireMethod = (req: IncomingMessage, res: ServerResponse, method: string): boolean => {
@@ -221,17 +395,20 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
221
395
  const acceptAttempts = new Map<string, { count: number; windowStart: number }>()
222
396
  const ACCEPT_MAX_ATTEMPTS = 10
223
397
  const ACCEPT_WINDOW_MS = 30_000
224
- const rateLimitAccept = (req: IncomingMessage): boolean => {
398
+ /**
399
+ * @param bucket - the POST /api/pair/accept and the GET /pair-accept flows
400
+ * count separately: a QR re-scan (page navigation) must not consume a
401
+ * brute-force budget that belongs to token guessing (and vice versa).
402
+ */
403
+ const rateLimitAccept = (req: IncomingMessage, bucket: 'page' | 'api'): boolean => {
225
404
  const socketIp = (req.socket as { remoteAddress?: string } | undefined)?.remoteAddress ?? 'unknown'
226
- // Behind the auto-tunnel every internet client arrives from 127.0.0.1,
227
- // so a single shared bucket would let one attacker keep the legitimate
228
- // owner rate-limited. Partition the availability bucket by the first
229
- // client-visible XFF hop (set by the tunnel edge): XFF is untrusted for
230
- // authentication and only separates buckets, it never grants access.
405
+ // XFF is honored only for loopback peers (the tunnel edge); see
406
+ // acceptLimitKey. It is untrusted for authentication and never grants
407
+ // access.
231
408
  const forwarded = typeof req.headers['x-forwarded-for'] === 'string'
232
409
  ? (req.headers['x-forwarded-for'].split(',')[0] ?? '').trim()
233
410
  : undefined
234
- const ip = forwarded === undefined || forwarded === '' ? socketIp : socketIp + '|' + forwarded
411
+ const ip = acceptLimitKey(socketIp, forwarded, bucket)
235
412
  const nowMs = Date.now()
236
413
  // The map lives as long as the plugin: prune expired windows once the
237
414
  // table grows past a modest size so distinct source IPs (LAN clients,
@@ -262,18 +439,21 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
262
439
  writeJson(res, 400, { ok: false, code: 'bad-payload' })
263
440
  return
264
441
  }
265
- const { workspaceId, address } = payload
442
+ const { address } = payload
266
443
  try {
267
- const { token, expiresAt } = service.issue(workspaceId, address)
444
+ // The QR link is the official Web GUI itself: after the accept round
445
+ // trip every device — phone or PC — boots the desktop SPA (phones get
446
+ // the injected portrait adaptation, PCs the full desktop UI), so the
447
+ // remote surface can never drift from the official one.
448
+ const { token, expiresAt } = service.issue(undefined, address)
268
449
  // The default base is the public (tunneled) URL when configured — a
269
450
  // phone anywhere can reach it — and the first LAN interface otherwise.
270
451
  // An explicit address always names a LAN literal.
271
452
  const base = address === undefined ? (service.publicBaseUrl ?? service.lanBaseUrl) : service.lanBaseUrlFor(address)
272
453
  if (base === undefined) throw new Error('remote-web-ui: base unavailable')
273
- const workspaceQuery = workspaceId === undefined ? '' : `&workspace=${encodeURIComponent(workspaceId)}`
274
454
  writeJson(res, 200, {
275
455
  ok: true,
276
- url: `${base}/m/?pair=${token}${workspaceQuery}`,
456
+ url: `${base}/pair-accept?pair=${token}`,
277
457
  token,
278
458
  expiresAt,
279
459
  // Every constructible base, so a multi-homed panel can switch the
@@ -300,7 +480,7 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
300
480
  writeJson(res, 403, { ok: false, code: 'forbidden' })
301
481
  return
302
482
  }
303
- if (rateLimitAccept(req)) {
483
+ if (rateLimitAccept(req, 'api')) {
304
484
  writeJson(res, 429, { ok: false, code: 'rate-limited' })
305
485
  return
306
486
  }
@@ -412,7 +592,148 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
412
592
  events.push(service.snapshot())
413
593
  }
414
594
 
415
- return [
595
+ /** LAN-bind facts for the settings card; loopback-only, read per request. */
596
+ const handleLanBind = (req: IncomingMessage, res: ServerResponse): void => {
597
+ if (!requireMethod(req, res, 'GET')) return
598
+ if (!loopbackFence(req)) {
599
+ writeJson(res, 403, { ok: false, code: 'forbidden' })
600
+ return
601
+ }
602
+ writeJson(res, 200, { ok: true, ...(deps.lanBindStatus?.() ?? {}) })
603
+ }
604
+
605
+ /**
606
+ * The QR entry: navigate here with ?pair=<token>. Sets the device cookie,
607
+ * then redirects to the authenticated home (the connection service's
608
+ * launch-token URL), so a LAN device that has never seen this authority
609
+ * clears the browser-auth gate and boots the paired official UI in one
610
+ * chain: /pair-accept → /?token=<launch> → /. A device that is already
611
+ * authenticated (or loopback) skips straight through the same way.
612
+ */
613
+ const handleAcceptPage = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
614
+ if (!requireMethod(req, res, 'GET')) return
615
+ if (!lanFence(req)) {
616
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
617
+ res.end('forbidden')
618
+ return
619
+ }
620
+ if (rateLimitAccept(req, 'page')) {
621
+ res.writeHead(429, { 'content-type': 'text/plain; charset=utf-8' })
622
+ res.end('rate limited')
623
+ return
624
+ }
625
+ const url = new URL(req.url ?? '/', 'http://pair.invalid')
626
+ const token = url.searchParams.get('pair') ?? ''
627
+ const ua = req.headers['user-agent']
628
+ const result = token === '' ? { ok: false as const, code: 'invalid' as const } : service.accept(token, typeof ua === 'string' ? ua : undefined)
629
+ if (!result.ok) {
630
+ // accept() now refuses only expired/unknown/stopped tokens: a consumed
631
+ // token stays re-usable until its expiry or replacement, so a mobile
632
+ // flow that split across cookie contexts (camera preview, in-app
633
+ // browser, system browser) can re-pair from the same link. A failure
634
+ // here means the link is truly dead: an already-paired device is sent
635
+ // on to the app, everyone else gets the bilingual explanation page
636
+ // instead of the harness 401 dead end.
637
+ const deviceId = readCookie(req.headers.cookie, service.config.cookieName)
638
+ if (deviceId !== undefined && service.hasDevice(deviceId)) {
639
+ // An already-paired device re-opening a dead link goes straight to
640
+ // the cookieless app landing with its live device credential.
641
+ res.writeHead(303, { location: `${appOrigin(req)}/pair-app?device=${encodeURIComponent(deviceId)}`, 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' })
642
+ res.end()
643
+ return
644
+ }
645
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' })
646
+ res.end(pairingFailurePage())
647
+ return
648
+ }
649
+ // Land the paired device on the cookieless app page: the official shell
650
+ // served by this plugin, with the device id in the URL. No harness index
651
+ // gate, no browser-auth cookie hop - the channel gate accepts the device
652
+ // credential whether or not the browser stores cookies.
653
+ res.writeHead(303, {
654
+ location: `${appOrigin(req)}/pair-app?device=${encodeURIComponent(result.deviceId)}`,
655
+ 'cache-control': 'no-store',
656
+ 'referrer-policy': 'no-referrer',
657
+ 'set-cookie': [
658
+ `${service.config.cookieName}=${result.deviceId}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}`,
659
+ ],
660
+ })
661
+ res.end()
662
+ }
663
+
664
+ /**
665
+ * The cookieless app landing. A paired device (device query or live
666
+ * pairing cookie) receives the official index shell patched with the
667
+ * device-capture script; the shell itself is the official document and
668
+ * carries no data, so serving it needs only the device credential.
669
+ */
670
+ const handleAppPage = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
671
+ if (!requireMethod(req, res, 'GET')) return
672
+ if (!lanFence(req)) {
673
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
674
+ res.end('forbidden')
675
+ return
676
+ }
677
+ if (rateLimitAccept(req, 'page')) {
678
+ res.writeHead(429, { 'content-type': 'text/plain; charset=utf-8' })
679
+ res.end('rate limited')
680
+ return
681
+ }
682
+ const url = new URL(req.url ?? '/', 'http://pair.invalid')
683
+ const device = url.searchParams.get('device') ?? ''
684
+ const cookieDevice = readCookie(req.headers.cookie, service.config.cookieName)
685
+ const id = (device !== '' && service.touchDevice(device))
686
+ ? device
687
+ : (cookieDevice !== undefined && service.touchDevice(cookieDevice))
688
+ ? cookieDevice
689
+ : undefined
690
+ if (id === undefined) {
691
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' })
692
+ res.end(pairingFailurePage())
693
+ return
694
+ }
695
+ const html = await deps.indexDocument?.(id).catch(() => undefined)
696
+ if (html === undefined) {
697
+ res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
698
+ res.end('remote device app unavailable')
699
+ return
700
+ }
701
+ res.writeHead(200, {
702
+ 'content-type': 'text/html; charset=utf-8',
703
+ 'cache-control': 'no-store',
704
+ 'referrer-policy': 'no-referrer',
705
+ 'set-cookie': [
706
+ `${service.config.cookieName}=${id}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${String(COOKIE_MAX_AGE_SEC)}`,
707
+ ],
708
+ })
709
+ res.end(patchAppShell(html, id))
710
+ }
711
+
712
+ /**
713
+ * The reopen service worker script. Same phone-facing fence as the app
714
+ * page but no rate limit and no device check: the script is inert logic
715
+ * (no secrets, no data), and browsers re-fetch it on navigations for
716
+ * update checks, which must not trip a brute-force budget. no-store keeps
717
+ * the update check meaningful across deploys.
718
+ */
719
+ const handleAppServiceWorker = (req: IncomingMessage, res: ServerResponse): void => {
720
+ if (!requireMethod(req, res, 'GET')) return
721
+ if (!lanFence(req)) {
722
+ res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
723
+ res.end('forbidden')
724
+ return
725
+ }
726
+ res.writeHead(200, {
727
+ 'content-type': 'text/javascript; charset=utf-8',
728
+ 'cache-control': 'no-store',
729
+ // Defensive: the root-level script path already allows a `/` scope,
730
+ // the header keeps that explicit for engines that want it stated.
731
+ 'service-worker-allowed': '/',
732
+ })
733
+ res.end(appServiceWorkerScript())
734
+ }
735
+
736
+ const routes: WebRoute[] = [
416
737
  { kind: 'exact', path: PAIR_PATHS.issue, handler: handleIssue },
417
738
  { kind: 'exact', path: PAIR_PATHS.accept, handler: handleAccept },
418
739
  { kind: 'exact', path: PAIR_PATHS.stop, handler: handleStop },
@@ -421,4 +742,22 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
421
742
  { kind: 'exact', path: PAIR_PATHS.status, handler: handleStatus },
422
743
  { kind: 'exact', path: PAIR_PATHS.events, handler: handleEvents },
423
744
  ]
745
+ if (deps.lanBindStatus !== undefined) {
746
+ routes.push({ kind: 'exact', path: PAIR_PATHS.lanBind, handler: handleLanBind })
747
+ }
748
+ routes.push({ kind: 'exact', path: PAIR_PATHS.acceptPage, handler: handleAcceptPage })
749
+ if (deps.indexDocument !== undefined) {
750
+ routes.push({ kind: 'exact', path: PAIR_PATHS.appPage, handler: handleAppPage })
751
+ // The reopen worker is only meaningful when the app landing it
752
+ // re-serves exists (same condition as /pair-app).
753
+ routes.push({ kind: 'exact', path: PAIR_PATHS.appServiceWorker, handler: handleAppServiceWorker })
754
+ }
755
+ return routes
756
+ }
757
+
758
+ /** The request origin (https when a tunnel edge says so). */
759
+ function appOrigin(req: IncomingMessage): string {
760
+ const proto = req.headers['x-forwarded-proto'] === 'https' ? 'https' : 'http'
761
+ const origin = `http://${req.headers.host ?? '127.0.0.1'}`
762
+ return proto === 'https' ? origin.replace('http://', 'https://') : origin
424
763
  }
package/src/tunnel.ts CHANGED
@@ -60,7 +60,13 @@ async function defaultEnsureBinary(): Promise<void> {
60
60
  function defaultFactory(targetUrl: string): TunnelHandle {
61
61
  // `--no-autoupdate`: the binary must never upgrade itself out from under
62
62
  // the manager (a self-updated binary would break the pinned lifecycle).
63
- return Tunnel.quick(targetUrl, { '--no-autoupdate': true })
63
+ // `--protocol http2`: the default `auto` prefers QUIC (UDP 7844), which
64
+ // wedges on fake-ip TUN proxies and QUIC-hostile networks — the connector
65
+ // holds the hostname but reports readyConnections: 0 forever (edge 1033),
66
+ // and the auto fallback never fires on a half-open QUIC handshake. http2
67
+ // rides TCP through the same paths reliably (verified live: auto stuck at
68
+ // ready 0 on two fresh registrations; http2 ready immediately).
69
+ return Tunnel.quick(targetUrl, { '--no-autoupdate': true, '--protocol': 'http2' })
64
70
  }
65
71
 
66
72
  /** Node timers. */
Binary file
Binary file
Binary file
@@ -1,63 +0,0 @@
1
- /* PWA worker for the standalone /m mobile shell. */
2
- const CACHE_NAME = 'dsh-remote-mobile-shell-v2'
3
- const OFFLINE_URL = '/m/offline.html'
4
- const SHELL_PATHS = new Set([
5
- '/m/',
6
- '/m/manifest.webmanifest',
7
- '/m/apple-touch-icon.png',
8
- '/m/icon-192.png',
9
- '/m/icon-512.png',
10
- OFFLINE_URL,
11
- ])
12
-
13
- self.addEventListener('install', event => {
14
- event.waitUntil(caches.open(CACHE_NAME).then(cache => cache.add(OFFLINE_URL)))
15
- })
16
-
17
- self.addEventListener('activate', event => {
18
- event.waitUntil(caches.keys().then(keys => Promise.all(
19
- keys
20
- .filter(key => key.startsWith('dsh-remote-mobile-shell-') && key !== CACHE_NAME)
21
- .map(key => caches.delete(key)),
22
- )))
23
- })
24
-
25
- self.addEventListener('fetch', event => {
26
- const request = event.request
27
- if (request.method !== 'GET') return
28
-
29
- const url = new URL(request.url)
30
- if (url.origin !== self.location.origin) return
31
- if (url.pathname === '/api' || url.pathname.startsWith('/api/') || url.pathname === '/m/api' || url.pathname.startsWith('/m/api/')) return
32
-
33
- const isMobileNavigation = request.mode === 'navigate' && (url.pathname === '/m/' || url.pathname === '/m')
34
- if (isMobileNavigation) {
35
- event.respondWith(networkFirst(request, OFFLINE_URL, false))
36
- return
37
- }
38
-
39
- if (SHELL_PATHS.has(url.pathname)) event.respondWith(networkFirst(request, url.pathname))
40
- })
41
-
42
- async function networkFirst(request, fallbackPath, allowCachedResponse = true) {
43
- try {
44
- const response = await fetch(request)
45
- if (response.status >= 500) throw new Error('mobile shell unavailable')
46
- if (response.ok && new URL(request.url).search === '') {
47
- const cache = await caches.open(CACHE_NAME)
48
- await cache.put(request, response.clone())
49
- }
50
- return response
51
- } catch {
52
- const cache = await caches.open(CACHE_NAME)
53
- if (allowCachedResponse) {
54
- const cached = await cache.match(request)
55
- if (cached !== undefined) return cached
56
- }
57
-
58
- const fallback = await cache.match(fallbackPath)
59
- if (fallback !== undefined) return fallback
60
-
61
- return new Response('', { status: 503, statusText: 'Service Unavailable' })
62
- }
63
- }