@linxin666/dsh-remote-web-ui 0.1.20 → 0.2.1

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 (126) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +56 -26
  3. package/README.zh.md +20 -15
  4. package/assets/mobile-icon-192.png +0 -0
  5. package/assets/mobile-icon-512.png +0 -0
  6. package/assets/mobile-service-worker.js +64 -0
  7. package/lib/client.js +272 -47
  8. package/lib/client.js.map +1 -1
  9. package/lib/index.js +316 -60
  10. package/lib/mobile.js +487 -56
  11. package/lib/mobile.js.map +1 -1
  12. package/lib/types/client/PluginSettingsCard.d.ts +28 -0
  13. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  14. package/lib/types/client/PluginSettingsCard.js +147 -6
  15. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  16. package/lib/types/client/RemoteEntry.js +13 -2
  17. package/lib/types/client/RemoteSettingsCard.d.ts +2 -2
  18. package/lib/types/client/RemoteSettingsCard.d.ts.map +1 -1
  19. package/lib/types/client/RemoteSettingsCard.js +1 -1
  20. package/lib/types/client/UpdateEntry.d.ts.map +1 -1
  21. package/lib/types/client/UpdateEntry.js +36 -3
  22. package/lib/types/client/UpdatePanel.d.ts.map +1 -1
  23. package/lib/types/client/UpdatePanel.js +2 -2
  24. package/lib/types/client/deep-link.js +1 -1
  25. package/lib/types/client/locales.d.ts +4 -0
  26. package/lib/types/client/locales.d.ts.map +1 -1
  27. package/lib/types/client/locales.js +4 -0
  28. package/lib/types/gate.d.ts +9 -0
  29. package/lib/types/gate.d.ts.map +1 -1
  30. package/lib/types/gate.js +17 -4
  31. package/lib/types/index.d.ts +25 -0
  32. package/lib/types/index.d.ts.map +1 -1
  33. package/lib/types/index.js +30 -13
  34. package/lib/types/mobile/PairRequiredView.d.ts +7 -0
  35. package/lib/types/mobile/PairRequiredView.d.ts.map +1 -0
  36. package/lib/types/mobile/PairRequiredView.js +28 -0
  37. package/lib/types/mobile/PairRequiredView.test.d.ts +2 -0
  38. package/lib/types/mobile/PairRequiredView.test.d.ts.map +1 -0
  39. package/lib/types/mobile/PairRequiredView.test.js +24 -0
  40. package/lib/types/mobile/api.d.ts +10 -0
  41. package/lib/types/mobile/api.d.ts.map +1 -1
  42. package/lib/types/mobile/api.js +4 -0
  43. package/lib/types/mobile/index.js +14 -1
  44. package/lib/types/mobile/messages.d.ts +24 -0
  45. package/lib/types/mobile/messages.d.ts.map +1 -1
  46. package/lib/types/mobile/messages.js +60 -23
  47. package/lib/types/mobile/messages.test.js +49 -1
  48. package/lib/types/mobile/mobile-styles.d.ts +1 -1
  49. package/lib/types/mobile/mobile-styles.d.ts.map +1 -1
  50. package/lib/types/mobile/mobile-styles.js +86 -0
  51. package/lib/types/mobile/pairing.d.ts +31 -0
  52. package/lib/types/mobile/pairing.d.ts.map +1 -0
  53. package/lib/types/mobile/pairing.js +62 -0
  54. package/lib/types/mobile/views/App.d.ts +12 -5
  55. package/lib/types/mobile/views/App.d.ts.map +1 -1
  56. package/lib/types/mobile/views/App.js +34 -7
  57. package/lib/types/mobile/views/App.test.d.ts +2 -0
  58. package/lib/types/mobile/views/App.test.d.ts.map +1 -0
  59. package/lib/types/mobile/views/App.test.js +52 -0
  60. package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
  61. package/lib/types/mobile/views/ChatView.js +34 -11
  62. package/lib/types/mobile/views/SessionListView.d.ts.map +1 -1
  63. package/lib/types/mobile/views/SessionListView.js +30 -4
  64. package/lib/types/mobile/views/SessionListView.test.js +58 -1
  65. package/lib/types/mobile/views/WorkspaceView.d.ts.map +1 -1
  66. package/lib/types/mobile/views/WorkspaceView.js +4 -2
  67. package/lib/types/mobile-api.d.ts.map +1 -1
  68. package/lib/types/mobile-api.js +10 -1
  69. package/lib/types/mobile-pwa.d.ts +6 -0
  70. package/lib/types/mobile-pwa.d.ts.map +1 -0
  71. package/lib/types/mobile-pwa.js +17 -0
  72. package/lib/types/mobile-routes.d.ts +5 -8
  73. package/lib/types/mobile-routes.d.ts.map +1 -1
  74. package/lib/types/mobile-routes.js +115 -30
  75. package/lib/types/pairing-access.d.ts +28 -0
  76. package/lib/types/pairing-access.d.ts.map +1 -0
  77. package/lib/types/pairing-access.js +26 -0
  78. package/lib/types/pairing.d.ts +27 -0
  79. package/lib/types/pairing.d.ts.map +1 -1
  80. package/lib/types/pairing.js +68 -0
  81. package/lib/types/pairing.persist.test.d.ts +2 -0
  82. package/lib/types/pairing.persist.test.d.ts.map +1 -0
  83. package/lib/types/pairing.persist.test.js +128 -0
  84. package/lib/types/routes.d.ts.map +1 -1
  85. package/lib/types/routes.js +23 -3
  86. package/lib/types/tunnel.d.ts +1 -0
  87. package/lib/types/tunnel.d.ts.map +1 -1
  88. package/lib/types/tunnel.js +7 -2
  89. package/lib/types/update.d.ts +4 -4
  90. package/lib/types/update.d.ts.map +1 -1
  91. package/lib/types/update.js +35 -12
  92. package/package.json +12 -12
  93. package/src/client/PluginSettingsCard.tsx +229 -18
  94. package/src/client/RemoteEntry.tsx +12 -2
  95. package/src/client/RemoteSettingsCard.tsx +3 -2
  96. package/src/client/UpdateEntry.tsx +36 -4
  97. package/src/client/UpdatePanel.tsx +2 -0
  98. package/src/client/deep-link.ts +1 -1
  99. package/src/client/locales.ts +4 -0
  100. package/src/client/remote.module.css +26 -0
  101. package/src/client/settings-card.module.css +90 -1
  102. package/src/gate.ts +17 -3
  103. package/src/index.ts +46 -15
  104. package/src/mobile/PairRequiredView.test.tsx +28 -0
  105. package/src/mobile/PairRequiredView.tsx +58 -0
  106. package/src/mobile/api.ts +14 -1
  107. package/src/mobile/index.tsx +15 -1
  108. package/src/mobile/messages.test.ts +52 -1
  109. package/src/mobile/messages.ts +65 -22
  110. package/src/mobile/mobile-styles.ts +86 -0
  111. package/src/mobile/pairing.ts +77 -0
  112. package/src/mobile/views/App.test.tsx +62 -0
  113. package/src/mobile/views/App.tsx +53 -6
  114. package/src/mobile/views/ChatView.tsx +33 -10
  115. package/src/mobile/views/SessionListView.test.tsx +69 -1
  116. package/src/mobile/views/SessionListView.tsx +58 -5
  117. package/src/mobile/views/WorkspaceView.tsx +4 -2
  118. package/src/mobile-api.ts +9 -1
  119. package/src/mobile-pwa.ts +19 -0
  120. package/src/mobile-routes.ts +123 -30
  121. package/src/pairing-access.ts +36 -0
  122. package/src/pairing.persist.test.ts +145 -0
  123. package/src/pairing.ts +70 -1
  124. package/src/routes.ts +23 -3
  125. package/src/tunnel.ts +7 -2
  126. package/src/update.ts +42 -14
@@ -1,11 +1,8 @@
1
1
  /**
2
- * The mobile surface's page routes: `/m` serves the standalone phone UI
3
- * (an independent bundle, built to lib/mobile.js by the mobile tsdown
4
- * entry), `/m/mobile.js` serves the bundle itself. The page talks to the
5
- * host exclusively through the shared /api transport (paired-device cookie
6
- * already crosses the api/gate fence), so no host-side data plumbing is
7
- * needed here — only static serving, loopback+paired-fence via the normal
8
- * webserver route registration.
2
+ * The mobile surface's page routes: /m canonicalizes to /m/, which serves the
3
+ * standalone phone UI. The page and worker live in the /m/ scope so the PWA
4
+ * can cache only its static shell; the paired-device data channel remains at
5
+ * /m/api and is never handled by the worker.
9
6
  */
10
7
 
11
8
  import { readFile } from 'node:fs/promises'
@@ -14,17 +11,23 @@ import { fileURLToPath } from 'node:url'
14
11
  import type { IncomingMessage, ServerResponse } from 'node:http'
15
12
  import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
16
13
 
14
+ const MOBILE_ROOT = '/m/'
15
+ const MOBILE_BUNDLE_URL = '/m/mobile.js'
16
+ const MOBILE_MANIFEST_URL = '/m/manifest.webmanifest'
17
+ const MOBILE_WORKER_URL = '/m/service-worker.js'
18
+ const MOBILE_OFFLINE_URL = '/m/offline.html'
19
+
17
20
  /** The standalone mobile bundle (built artifact, next to this file's own lib output). */
18
21
  function mobileBundlePath(): string {
19
22
  return fileURLToPath(new URL('../lib/mobile.js', import.meta.url))
20
23
  }
21
24
 
22
- /** The apple-touch-icon shipped with the package (iOS home-screen icon, 180x180 PNG). */
23
- function appleTouchIconPath(): string {
24
- return fileURLToPath(new URL('../assets/apple-touch-icon.png', import.meta.url))
25
+ /** A package asset available to the host after a registry or git install. */
26
+ function mobileAssetPath(name: string): string {
27
+ return fileURLToPath(new URL('../assets/' + name, import.meta.url))
25
28
  }
26
29
 
27
- /** The mobile page shell: minimal, offline-safe, no external assets. */
30
+ /** The mobile page shell: self-contained, with no document-level user data. */
28
31
  function pageHtml(bundleUrl: string): string {
29
32
  return [
30
33
  '<!doctype html>',
@@ -34,31 +37,78 @@ function pageHtml(bundleUrl: string): string {
34
37
  '<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">',
35
38
  '<meta name="theme-color" content="#f3f5f9">',
36
39
  '<meta name="referrer" content="no-referrer">',
37
- // iOS "Add to Home Screen" uses apple-touch-icon for the tile icon;
38
- // the PNG ships with the package (opaque background — iOS composites
39
- // transparency onto black, so the icon avoids transparent regions).
40
+ '<meta name="mobile-web-app-capable" content="yes">',
41
+ '<meta name="apple-mobile-web-app-capable" content="yes">',
42
+ '<meta name="apple-mobile-web-app-title" content="DSH Remote">',
43
+ '<link rel="manifest" href="/m/manifest.webmanifest">',
40
44
  '<link rel="apple-touch-icon" href="/m/apple-touch-icon.png">',
41
45
  '<title>移动端远程控制</title>',
42
46
  '</head>',
43
47
  '<body>',
44
48
  '<div id="root"></div>',
45
- `<script type="module" src="${bundleUrl}"></script>`,
49
+ '<script type="module" src="' + bundleUrl + '"></script>',
46
50
  '</body>',
47
51
  '</html>',
48
52
  ].join('')
49
53
  }
50
54
 
51
- /** Send a small static body with cache headers (the bundle is content-hashed by rebuild). */
55
+ /** The installable app identity and icon declarations for the /m scope. */
56
+ function manifestJson(): string {
57
+ return JSON.stringify({
58
+ id: MOBILE_ROOT,
59
+ name: 'DSH Remote',
60
+ short_name: 'DSH Remote',
61
+ start_url: MOBILE_ROOT,
62
+ scope: MOBILE_ROOT,
63
+ display: 'standalone',
64
+ background_color: '#151424',
65
+ theme_color: '#f3f5f9',
66
+ icons: [
67
+ { src: '/m/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
68
+ { src: '/m/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' },
69
+ ],
70
+ }, null, 2)
71
+ }
72
+
73
+ /** A static fallback that contains no paired-device, session, or workspace data. */
74
+ function offlineHtml(): string {
75
+ return [
76
+ '<!doctype html>',
77
+ '<html lang="zh-CN">',
78
+ '<head>',
79
+ '<meta charset="utf-8">',
80
+ '<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">',
81
+ '<meta name="theme-color" content="#151424">',
82
+ '<title>DSH Remote</title>',
83
+ '<style>body{margin:0;background:#151424;color:#f3f5f9;font:16px system-ui,sans-serif}main{box-sizing:border-box;display:grid;min-height:100vh;place-content:center;padding:32px;text-align:center}h1{margin:0 0 12px;font-size:24px}p{margin:0;color:#c4c7d8;line-height:1.5}</style>',
84
+ '</head>',
85
+ '<body><main><h1>DSH Remote</h1><p>Cannot reach the running DSH host. Restore the connection and reopen the app.</p></main></body>',
86
+ '</html>',
87
+ ].join('')
88
+ }
89
+
90
+ /** Send a small static UTF-8 body with revalidation headers. */
52
91
  function writeStatic(res: ServerResponse, status: number, type: string, body: string): void {
53
92
  res.writeHead(status, {
54
- 'content-type': `${type}; charset=utf-8`,
93
+ 'content-type': type + '; charset=utf-8',
94
+ 'cache-control': 'no-cache',
95
+ 'referrer-policy': 'no-referrer',
96
+ })
97
+ res.end(body)
98
+ }
99
+
100
+ /** Send the worker with the same scope it registers for, without HTTP caching. */
101
+ function writeServiceWorker(res: ServerResponse, body: string): void {
102
+ res.writeHead(200, {
103
+ 'content-type': 'text/javascript; charset=utf-8',
55
104
  'cache-control': 'no-cache',
56
105
  'referrer-policy': 'no-referrer',
106
+ 'service-worker-allowed': MOBILE_ROOT,
57
107
  })
58
108
  res.end(body)
59
109
  }
60
110
 
61
- /** Send the PNG icon as a binary body (unlike text assets, the PNG must not be decoded to utf-8). */
111
+ /** Send a PNG body without decoding it to UTF-8. */
62
112
  function writePng(res: ServerResponse, status: number, body: Buffer): void {
63
113
  res.writeHead(status, {
64
114
  'content-type': 'image/png',
@@ -68,16 +118,34 @@ function writePng(res: ServerResponse, status: number, body: Buffer): void {
68
118
  res.end(body)
69
119
  }
70
120
 
121
+ /** Canonicalize the old /m route while retaining pair and workspace query parameters. */
122
+ function redirectToMobileRoot(req: IncomingMessage, res: ServerResponse): void {
123
+ const url = new URL(req.url ?? '/m', 'http://x')
124
+ res.writeHead(308, {
125
+ location: MOBILE_ROOT + url.search,
126
+ 'cache-control': 'no-store',
127
+ 'referrer-policy': 'no-referrer',
128
+ })
129
+ res.end()
130
+ }
131
+
71
132
  /**
72
133
  * Build the mobile page routes.
73
- * @returns the two exact routes to register on webServer.
134
+ * @returns Exact routes for the canonical page, static shell, and PWA assets.
74
135
  */
75
136
  export function makeMobileRoutes(): WebRoute[] {
76
137
  const handlePage = (_req: IncomingMessage, res: ServerResponse): void => {
77
- writeStatic(res, 200, 'text/html', pageHtml('/m/mobile.js'))
138
+ writeStatic(res, 200, 'text/html', pageHtml(MOBILE_BUNDLE_URL))
139
+ }
140
+ const handleManifest = (_req: IncomingMessage, res: ServerResponse): void => {
141
+ writeStatic(res, 200, 'application/manifest+json', manifestJson())
78
142
  }
143
+ const handleOffline = (_req: IncomingMessage, res: ServerResponse): void => {
144
+ writeStatic(res, 200, 'text/html', offlineHtml())
145
+ }
146
+
79
147
  // The bundle is immutable for the process lifetime (a rebuild requires a
80
- // host restart), so the ~456KB body is read from disk once, not per phone.
148
+ // host restart), so the body is read from disk once, not per phone.
81
149
  let bundleBody: string | undefined
82
150
  const handleBundle = async (_req: IncomingMessage, res: ServerResponse): Promise<void> => {
83
151
  if (bundleBody === undefined) {
@@ -95,22 +163,47 @@ export function makeMobileRoutes(): WebRoute[] {
95
163
  }
96
164
  writeStatic(res, 200, 'text/javascript', bundleBody)
97
165
  }
98
- const handleIcon = async (_req: IncomingMessage, res: ServerResponse): Promise<void> => {
99
- const path = appleTouchIconPath()
166
+
167
+ let workerBody: string | undefined
168
+ const handleWorker = async (_req: IncomingMessage, res: ServerResponse): Promise<void> => {
169
+ if (workerBody === undefined) {
170
+ const path = mobileAssetPath('mobile-service-worker.js')
171
+ if (!existsSync(path)) {
172
+ writeStatic(res, 503, 'text/plain', 'mobile service worker not found')
173
+ return
174
+ }
175
+ try {
176
+ workerBody = await readFile(path, 'utf8')
177
+ } catch {
178
+ writeStatic(res, 500, 'text/plain', 'failed to read the mobile service worker')
179
+ return
180
+ }
181
+ }
182
+ writeServiceWorker(res, workerBody)
183
+ }
184
+
185
+ const makeIconHandler = (asset: string): WebRoute['handler'] => async (_req, res) => {
186
+ const path = mobileAssetPath(asset)
100
187
  if (!existsSync(path)) {
101
- writeStatic(res, 404, 'text/plain', 'apple-touch-icon not found')
188
+ writeStatic(res, 404, 'text/plain', asset + ' not found')
102
189
  return
103
190
  }
104
191
  try {
105
- const body = await readFile(path)
106
- writePng(res, 200, body)
192
+ writePng(res, 200, await readFile(path))
107
193
  } catch {
108
- writeStatic(res, 500, 'text/plain', 'failed to read the apple-touch-icon')
194
+ writeStatic(res, 500, 'text/plain', 'failed to read ' + asset)
109
195
  }
110
196
  }
197
+
111
198
  return [
112
- { kind: 'exact', path: '/m', handler: handlePage },
113
- { kind: 'exact', path: '/m/mobile.js', handler: handleBundle },
114
- { kind: 'exact', path: '/m/apple-touch-icon.png', handler: handleIcon },
199
+ { kind: 'exact', path: '/m', handler: redirectToMobileRoot },
200
+ { kind: 'exact', path: MOBILE_ROOT, handler: handlePage },
201
+ { kind: 'exact', path: MOBILE_BUNDLE_URL, handler: handleBundle },
202
+ { kind: 'exact', path: MOBILE_MANIFEST_URL, handler: handleManifest },
203
+ { kind: 'exact', path: MOBILE_WORKER_URL, handler: handleWorker },
204
+ { kind: 'exact', path: MOBILE_OFFLINE_URL, handler: handleOffline },
205
+ { kind: 'exact', path: '/m/apple-touch-icon.png', handler: makeIconHandler('apple-touch-icon.png') },
206
+ { kind: 'exact', path: '/m/icon-192.png', handler: makeIconHandler('mobile-icon-192.png') },
207
+ { kind: 'exact', path: '/m/icon-512.png', handler: makeIconHandler('mobile-icon-512.png') },
115
208
  ]
116
209
  }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Optional cordis service other host plugins can ctx.get without taking a
3
+ * package dependency: a live paired-device cookie is an allow path on
4
+ * routes that sit outside /api (and therefore outside api/gate).
5
+ */
6
+ import type { IncomingMessage } from 'node:http'
7
+ import { Service, type Context } from '@deepseek-ai/cordis'
8
+
9
+ /** Named lookup key sibling plugins pass to ctx.get. */
10
+ export const REMOTE_WEB_UI_PAIRING = 'remoteWebUiPairing'
11
+
12
+ /**
13
+ * Pairing identity for one HTTP request. Structural: consumers must not
14
+ * import this class, only the method shape.
15
+ */
16
+ export class RemoteWebUiPairing extends Service {
17
+ /**
18
+ * @param ctx - host plugin context.
19
+ * @param check - live cookie + session predicate (re-read per request).
20
+ */
21
+ constructor(
22
+ ctx: Context,
23
+ private readonly check: (request: IncomingMessage) => boolean,
24
+ ) {
25
+ super(ctx, REMOTE_WEB_UI_PAIRING)
26
+ }
27
+
28
+ /**
29
+ * Whether the request carries a live paired-device cookie.
30
+ * @param request - the incoming HTTP request.
31
+ * @returns true when the session is live and was refreshed.
32
+ */
33
+ isPairedDevice(request: IncomingMessage): boolean {
34
+ return this.check(request)
35
+ }
36
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * PairingService device-session persistence: with `devicesFile` set, sessions
3
+ * written on accept/stop survive a process restart (a new PairingService
4
+ * instance), so a previously paired phone — whose cookie already lives 365
5
+ * days — never needs to re-scan the QR after `dsh web` restarts.
6
+ */
7
+ import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
8
+ import { tmpdir } from 'node:os'
9
+ import { join } from 'node:path'
10
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
11
+ import { pairingConfigOf } from './index.ts'
12
+ import { PairingService, type PairingClock, type PairingConfig } from './pairing.ts'
13
+
14
+ const BASE_CONFIG: Omit<PairingConfig, 'devicesFile'> = {
15
+ tokenTtlMs: 60_000,
16
+ offlineAfterMs: 25_000,
17
+ maxDevices: 4,
18
+ cookieName: 'dsh_pair',
19
+ }
20
+
21
+ /** Deterministic clock: tokens are issued in a fixed, readable sequence. */
22
+ function makeClock(): PairingClock {
23
+ let n = 0
24
+ return {
25
+ now: () => 1_000_000 + n,
26
+ randomToken: () => `tok${(n++).toString().padStart(6, '0')}`,
27
+ }
28
+ }
29
+
30
+ /** Pair a device on a service: issue a token and immediately accept it. */
31
+ function pairDevice(service: PairingService): string {
32
+ service.setPublicBaseUrl('https://pairing.example.trycloudflare.com')
33
+ const { token } = service.issue()
34
+ const result = service.accept(token)
35
+ if (!result.ok) throw new Error(`pair failed: ${result.code}`)
36
+ return result.deviceId
37
+ }
38
+
39
+ describe('PairingService device persistence', () => {
40
+ let dir: string
41
+
42
+ beforeEach(() => {
43
+ dir = mkdtempSync(join(tmpdir(), 'remote-web-ui-persist-'))
44
+ })
45
+
46
+ afterEach(() => {
47
+ rmSync(dir, { recursive: true, force: true })
48
+ })
49
+
50
+ it('restores paired sessions across a process restart', () => {
51
+ const file = join(dir, 'devices.json')
52
+ const first = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())
53
+ const deviceId = pairDevice(first)
54
+ expect(readFileSync(file, 'utf8')).toContain(deviceId)
55
+ // Device ids are session credentials: the persisted file is 0600.
56
+ expect(statSync(file).mode & 0o777).toBe(0o600)
57
+
58
+ // Simulated restart: a brand-new service instance reading the same file.
59
+ const second = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())
60
+ expect(second.hasDevice(deviceId)).toBe(true)
61
+ })
62
+
63
+ it('carries devicesFile through the config mapping used by sync() (regression)', () => {
64
+ const file = join(dir, 'devices.json')
65
+ const mapped = pairingConfigOf({
66
+ tokenTtlMs: 60_000,
67
+ offlineAfterMs: 25_000,
68
+ maxDevices: 4,
69
+ cookieName: 'dsh_pair',
70
+ devicesFile: file,
71
+ })
72
+ expect(mapped.devicesFile).toBe(file)
73
+ })
74
+
75
+ it('keeps persisting after a sync-style service.config rebuild', () => {
76
+ const file = join(dir, 'devices.json')
77
+ const service = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())
78
+ // applyImpl/sync() reassigns service.config from the resolved config on
79
+ // mount and on every settings change; the rebuild must not drop devicesFile.
80
+ service.config = pairingConfigOf({
81
+ tokenTtlMs: 60_000,
82
+ offlineAfterMs: 25_000,
83
+ maxDevices: 4,
84
+ cookieName: 'dsh_pair',
85
+ devicesFile: file,
86
+ })
87
+ const deviceId = pairDevice(service)
88
+ const restarted = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())
89
+ expect(restarted.hasDevice(deviceId)).toBe(true)
90
+ })
91
+
92
+ it('clamps a persisted table to maxDevices when the file holds more sessions', () => {
93
+ const file = join(dir, 'devices.json')
94
+ writeFileSync(file, JSON.stringify({
95
+ dev1: { createdAt: 1_000, lastSeenAt: 1_000 },
96
+ dev2: { createdAt: 2_000, lastSeenAt: 2_000 },
97
+ dev3: { createdAt: 3_000, lastSeenAt: 3_000 },
98
+ }))
99
+ const service = new PairingService({ ...BASE_CONFIG, maxDevices: 2, devicesFile: file }, makeClock())
100
+ expect(service.hasDevice('dev3')).toBe(true)
101
+ expect(service.hasDevice('dev2')).toBe(true)
102
+ expect(service.hasDevice('dev1')).toBe(false)
103
+ })
104
+
105
+ it('keeps sessions memory-only when devicesFile is unset', () => {
106
+ const first = new PairingService({ ...BASE_CONFIG }, makeClock())
107
+ const deviceId = pairDevice(first)
108
+ // No file was written; a fresh instance without the option knows nothing.
109
+ const second = new PairingService({ ...BASE_CONFIG }, makeClock())
110
+ expect(second.hasDevice(deviceId)).toBe(false)
111
+ })
112
+
113
+ it('persists revocation by stop() across a restart', () => {
114
+ const file = join(dir, 'devices.json')
115
+ const first = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())
116
+ const deviceId = pairDevice(first)
117
+ first.stop()
118
+
119
+ const second = new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())
120
+ expect(second.hasDevice(deviceId)).toBe(false)
121
+ })
122
+
123
+ it('persists FIFO eviction when the device cap is reached', () => {
124
+ const file = join(dir, 'devices.json')
125
+ const first = new PairingService({ ...BASE_CONFIG, maxDevices: 2, devicesFile: file }, makeClock())
126
+ const firstDevice = pairDevice(first)
127
+ const secondDevice = pairDevice(first)
128
+ const thirdDevice = pairDevice(first) // evicts firstDevice
129
+ expect(first.hasDevice(firstDevice)).toBe(false)
130
+
131
+ const second = new PairingService({ ...BASE_CONFIG, maxDevices: 2, devicesFile: file }, makeClock())
132
+ expect(second.hasDevice(secondDevice)).toBe(true)
133
+ expect(second.hasDevice(thirdDevice)).toBe(true)
134
+ expect(second.hasDevice(firstDevice)).toBe(false)
135
+ })
136
+
137
+ it('tolerates a corrupt or missing file instead of refusing to boot', () => {
138
+ const file = join(dir, 'devices.json')
139
+ writeFileSync(file, '{ this is not json !!!')
140
+ expect(() => new PairingService({ ...BASE_CONFIG, devicesFile: file }, makeClock())).not.toThrow()
141
+
142
+ const ghost = join(dir, 'does-not-exist.json')
143
+ expect(() => new PairingService({ ...BASE_CONFIG, devicesFile: ghost }, makeClock())).not.toThrow()
144
+ })
145
+ })
package/src/pairing.ts CHANGED
@@ -16,6 +16,8 @@
16
16
  */
17
17
 
18
18
  import { randomBytes } from 'node:crypto'
19
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
20
+ import { dirname } from 'node:path'
19
21
 
20
22
  /** The observable pairing phases the panel renders. */
21
23
  export type PairingPhase =
@@ -95,6 +97,13 @@ export interface PairingConfig {
95
97
  maxDevices: number
96
98
  /** Cookie name carrying the device id. */
97
99
  cookieName: string
100
+ /**
101
+ * Path to a JSON file where paired device sessions are persisted. When
102
+ * set, sessions survive process restarts (the phone keeps its 365-day
103
+ * cookie), so re-pairing after a dsh web restart is not required. When
104
+ * unset (default), sessions stay memory-only — the previous behavior.
105
+ */
106
+ devicesFile?: string
98
107
  }
99
108
 
100
109
  /** Result of one accept() attempt. */
@@ -154,7 +163,65 @@ export class PairingService {
154
163
  constructor(
155
164
  public config: PairingConfig,
156
165
  private readonly clock: PairingClock = defaultClock,
157
- ) {}
166
+ ) {
167
+ this.loadPersisted()
168
+ }
169
+
170
+ /**
171
+ * Restore device sessions persisted by a previous process run. A corrupt
172
+ * or missing file is tolerated (an empty device table, never a throw) —
173
+ * persistence is an availability convenience, not a security boundary.
174
+ */
175
+ private loadPersisted(): void {
176
+ const file = this.config.devicesFile
177
+ if (file === undefined) return
178
+ try {
179
+ const saved = JSON.parse(readFileSync(file, 'utf8')) as unknown
180
+ if (typeof saved !== 'object' || saved === null) return
181
+ for (const [deviceId, session] of Object.entries(saved)) {
182
+ if (typeof deviceId !== 'string') continue
183
+ if (typeof session !== 'object' || session === null) continue
184
+ const { createdAt, lastSeenAt } = session as { createdAt?: unknown; lastSeenAt?: unknown }
185
+ if (typeof createdAt !== 'number' || typeof lastSeenAt !== 'number') continue
186
+ this.devices.set(deviceId, { createdAt, lastSeenAt })
187
+ }
188
+ this.clampToMaxDevices()
189
+ } catch {
190
+ // Unreadable/corrupt: start empty rather than refusing to boot.
191
+ }
192
+ }
193
+
194
+ /** FIFO-cap the device table (a persisted file may outlive a lowered cap). */
195
+ private clampToMaxDevices(): void {
196
+ if (this.devices.size <= this.config.maxDevices) return
197
+ const overflow = this.devices.size - this.config.maxDevices
198
+ const ordered = [...this.devices.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt)
199
+ for (const [id] of ordered.slice(0, overflow)) this.devices.delete(id)
200
+ }
201
+
202
+ /**
203
+ * Write the current device table to the configured file. Called on the
204
+ * mutation boundaries that change the set of live sessions (accept and
205
+ * stop); `lastSeenAt` refreshes are deliberately not persisted here so a
206
+ * per-request write storm is avoided — after a restart the phone's first
207
+ * heartbeat re-warms its own session.
208
+ *
209
+ * Device ids are session credentials (the gate authorizes requests by the
210
+ * cookie's device id), so the file is written 0600 via a temp file and
211
+ * atomic rename; a crash mid-write can never leave a half-written store.
212
+ */
213
+ private persist(): void {
214
+ const file = this.config.devicesFile
215
+ if (file === undefined) return
216
+ try {
217
+ mkdirSync(dirname(file), { recursive: true })
218
+ const temp = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`
219
+ writeFileSync(temp, JSON.stringify(Object.fromEntries(this.devices)), { mode: 0o600 })
220
+ renameSync(temp, file)
221
+ } catch (error) {
222
+ console.error('remote-web-ui: failed to persist paired devices', error)
223
+ }
224
+ }
158
225
 
159
226
  /** The default LAN base URL (the first interface; undefined when not LAN-reachable). */
160
227
  get lanBaseUrl(): string | undefined {
@@ -254,6 +321,7 @@ export class PairingService {
254
321
  if (oldest !== undefined) this.devices.delete(oldest.id)
255
322
  }
256
323
  this.devices.set(deviceId, { createdAt: now, lastSeenAt: now })
324
+ this.persist()
257
325
  this.notify()
258
326
  return { ok: true, deviceId }
259
327
  }
@@ -266,6 +334,7 @@ export class PairingService {
266
334
  stop(): void {
267
335
  this.tokens.clear()
268
336
  this.devices.clear()
337
+ this.persist()
269
338
  this.stopped = true
270
339
  this.notify()
271
340
  }
package/src/routes.ts CHANGED
@@ -222,7 +222,16 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
222
222
  const ACCEPT_MAX_ATTEMPTS = 10
223
223
  const ACCEPT_WINDOW_MS = 30_000
224
224
  const rateLimitAccept = (req: IncomingMessage): boolean => {
225
- const ip = (req.socket as { remoteAddress?: string } | undefined)?.remoteAddress ?? 'unknown'
225
+ 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.
231
+ const forwarded = typeof req.headers['x-forwarded-for'] === 'string'
232
+ ? (req.headers['x-forwarded-for'].split(',')[0] ?? '').trim()
233
+ : undefined
234
+ const ip = forwarded === undefined || forwarded === '' ? socketIp : socketIp + '|' + forwarded
226
235
  const nowMs = Date.now()
227
236
  // The map lives as long as the plugin: prune expired windows once the
228
237
  // table grows past a modest size so distinct source IPs (LAN clients,
@@ -264,7 +273,7 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
264
273
  const workspaceQuery = workspaceId === undefined ? '' : `&workspace=${encodeURIComponent(workspaceId)}`
265
274
  writeJson(res, 200, {
266
275
  ok: true,
267
- url: `${base}/?pair=${token}${workspaceQuery}`,
276
+ url: `${base}/m/?pair=${token}${workspaceQuery}`,
268
277
  token,
269
278
  expiresAt,
270
279
  // Every constructible base, so a multi-homed panel can switch the
@@ -306,6 +315,9 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
306
315
  writeJson(res, result.code === 'used' ? 409 : 404, { ok: false, code: result.code })
307
316
  return
308
317
  }
318
+ // No Secure attribute: LAN pairing runs over plain HTTP (the cookie must
319
+ // work there), and the same cookie rides HTTPS on the tunnel. Lax keeps
320
+ // top-level navigations working while blocking cross-site subrequests.
309
321
  res.writeHead(200, {
310
322
  'content-type': 'application/json; charset=utf-8',
311
323
  'set-cookie': [
@@ -356,7 +368,15 @@ export function makeRoutes(deps: PairRoutesDeps): WebRoute[] {
356
368
  return
357
369
  }
358
370
  const deviceId = readCookie(req.headers.cookie, service.config.cookieName)
359
- writeJson(res, 200, { ok: true, paired: deviceId !== undefined && service.hasDevice(deviceId), ...service.snapshot() })
371
+ const paired = deviceId !== undefined && service.hasDevice(deviceId)
372
+ const snapshot = service.snapshot()
373
+ // Unpaired LAN/tunnel clients get only the pairing-relevant fields; the
374
+ // token expiry, device roster, and public tunnel URL are an oracle for
375
+ // targeting and timing and stay behind a live device cookie.
376
+ const visible = paired
377
+ ? snapshot
378
+ : { phase: snapshot.phase, lanAvailable: snapshot.lanAvailable, lanAddresses: snapshot.lanAddresses }
379
+ writeJson(res, 200, { ok: true, paired, ...visible })
360
380
  }
361
381
 
362
382
  const handleEvents = (req: IncomingMessage, res: ServerResponse): void => {
package/src/tunnel.ts CHANGED
@@ -86,6 +86,9 @@ export class TunnelManager {
86
86
  private urlTimer: unknown | undefined
87
87
  private restartTimer: unknown | undefined
88
88
  private attempts = 0
89
+ // Generation counter: a stale ensureBinary resolution from an earlier
90
+ // start() must not spawn a second handle after a stop/start cycle.
91
+ private generation = 0
89
92
  private stopping = false
90
93
  private readonly urlListeners = new Set<(url: string) => void>()
91
94
  private readonly phaseListeners = new Set<(info: TunnelInfo) => void>()
@@ -123,6 +126,7 @@ export class TunnelManager {
123
126
  this.stopping = false
124
127
  this.targetUrl = targetUrl
125
128
  this.attempts = 0
129
+ this.generation += 1
126
130
  this.attempt()
127
131
  }
128
132
 
@@ -153,12 +157,13 @@ export class TunnelManager {
153
157
 
154
158
  private attempt(): void {
155
159
  if (this.stopping || this.targetUrl === undefined) return
160
+ const gen = this.generation
156
161
  this.setPhase('starting')
157
162
  this.handle = undefined
158
163
  this.url = undefined
159
164
  this.error = undefined
160
165
  void this.ensureBinary().then(() => {
161
- if (this.stopping || this.targetUrl === undefined) return
166
+ if (this.stopping || this.targetUrl === undefined || gen !== this.generation) return
162
167
  const handle = this.factory(this.targetUrl)
163
168
  this.handle = handle
164
169
  this.urlTimer = this.timer.setTimeout(() => {
@@ -181,7 +186,7 @@ export class TunnelManager {
181
186
  })
182
187
  }).catch((value: unknown) => {
183
188
  // Binary install failed (no network, no platform build): report it.
184
- if (this.stopping || this.targetUrl === undefined) return
189
+ if (this.stopping || this.targetUrl === undefined || gen !== this.generation) return
185
190
  const message = value instanceof Error ? value.message : String(value)
186
191
  this.fail(`could not obtain the cloudflared binary: ${message}`)
187
192
  })