dsh-zen-remote 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/AGENTS.md +51 -0
  2. package/LICENSE +38 -0
  3. package/README.md +237 -0
  4. package/assets/home.png +0 -0
  5. package/assets/info.png +0 -0
  6. package/assets/pairing.png +0 -0
  7. package/assets/session.png +0 -0
  8. package/assets/sheet.png +0 -0
  9. package/cordis.patch.yml +17 -0
  10. package/cordis.patch.yml.example +16 -0
  11. package/docs/interface.md +200 -0
  12. package/docs/remote-access.en.md +335 -0
  13. package/docs/remote-access.md +422 -0
  14. package/dsh-push.mjs +213 -0
  15. package/lan-gate.mjs +78 -0
  16. package/lib/client.js +6615 -0
  17. package/lib/index.js +310 -0
  18. package/lib/index.js.map +1 -0
  19. package/lib/lan-gate-server.cjs +626 -0
  20. package/lib/types/client/MobileAttachButton.d.ts +25 -0
  21. package/lib/types/client/MobileAttachButton.d.ts.map +1 -0
  22. package/lib/types/client/MobileAttachChips.d.ts +32 -0
  23. package/lib/types/client/MobileAttachChips.d.ts.map +1 -0
  24. package/lib/types/client/MobileDrawerFooter.d.ts +21 -0
  25. package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -0
  26. package/lib/types/client/MobileHome.d.ts +24 -0
  27. package/lib/types/client/MobileHome.d.ts.map +1 -0
  28. package/lib/types/client/MobileHomeChips.d.ts +76 -0
  29. package/lib/types/client/MobileHomeChips.d.ts.map +1 -0
  30. package/lib/types/client/MobileNavOverlay.d.ts +15 -0
  31. package/lib/types/client/MobileNavOverlay.d.ts.map +1 -0
  32. package/lib/types/client/MobileNavToggle.d.ts +18 -0
  33. package/lib/types/client/MobileNavToggle.d.ts.map +1 -0
  34. package/lib/types/client/MobileSessionHeader.d.ts +58 -0
  35. package/lib/types/client/MobileSessionHeader.d.ts.map +1 -0
  36. package/lib/types/client/MobileSessionInfo.d.ts +41 -0
  37. package/lib/types/client/MobileSessionInfo.d.ts.map +1 -0
  38. package/lib/types/client/attach-upload.d.ts +78 -0
  39. package/lib/types/client/attach-upload.d.ts.map +1 -0
  40. package/lib/types/client/chips-store.d.ts +13 -0
  41. package/lib/types/client/chips-store.d.ts.map +1 -0
  42. package/lib/types/client/debug.d.ts +9 -0
  43. package/lib/types/client/debug.d.ts.map +1 -0
  44. package/lib/types/client/effects/aionui-compat.d.ts +4 -0
  45. package/lib/types/client/effects/aionui-compat.d.ts.map +1 -0
  46. package/lib/types/client/effects/gestures.d.ts +7 -0
  47. package/lib/types/client/effects/gestures.d.ts.map +1 -0
  48. package/lib/types/client/effects/header-status.d.ts +15 -0
  49. package/lib/types/client/effects/header-status.d.ts.map +1 -0
  50. package/lib/types/client/effects/phone-chrome.d.ts +106 -0
  51. package/lib/types/client/effects/phone-chrome.d.ts.map +1 -0
  52. package/lib/types/client/effects/turn-fold.d.ts +25 -0
  53. package/lib/types/client/effects/turn-fold.d.ts.map +1 -0
  54. package/lib/types/client/index.d.ts +18 -0
  55. package/lib/types/client/index.d.ts.map +1 -0
  56. package/lib/types/client/locales.d.ts +58 -0
  57. package/lib/types/client/locales.d.ts.map +1 -0
  58. package/lib/types/client/nav-store.d.ts +73 -0
  59. package/lib/types/client/nav-store.d.ts.map +1 -0
  60. package/lib/types/client/session-dot.d.ts +12 -0
  61. package/lib/types/client/session-dot.d.ts.map +1 -0
  62. package/lib/types/client/styles/base.css.d.ts +2 -0
  63. package/lib/types/client/styles/base.css.d.ts.map +1 -0
  64. package/lib/types/client/styles/chips.css.d.ts +2 -0
  65. package/lib/types/client/styles/chips.css.d.ts.map +1 -0
  66. package/lib/types/client/styles/compat.css.d.ts +2 -0
  67. package/lib/types/client/styles/compat.css.d.ts.map +1 -0
  68. package/lib/types/client/styles/composer.css.d.ts +2 -0
  69. package/lib/types/client/styles/composer.css.d.ts.map +1 -0
  70. package/lib/types/client/styles/header.css.d.ts +2 -0
  71. package/lib/types/client/styles/header.css.d.ts.map +1 -0
  72. package/lib/types/client/styles/home.css.d.ts +2 -0
  73. package/lib/types/client/styles/home.css.d.ts.map +1 -0
  74. package/lib/types/client/styles/index.d.ts +15 -0
  75. package/lib/types/client/styles/index.d.ts.map +1 -0
  76. package/lib/types/client/styles/info.css.d.ts +2 -0
  77. package/lib/types/client/styles/info.css.d.ts.map +1 -0
  78. package/lib/types/client/styles/layout.css.d.ts +2 -0
  79. package/lib/types/client/styles/layout.css.d.ts.map +1 -0
  80. package/lib/types/client/styles/misc.css.d.ts +2 -0
  81. package/lib/types/client/styles/misc.css.d.ts.map +1 -0
  82. package/lib/types/client/styles/turn-fold.css.d.ts +2 -0
  83. package/lib/types/client/styles/turn-fold.css.d.ts.map +1 -0
  84. package/lib/types/index.d.ts +83 -0
  85. package/lib/types/index.d.ts.map +1 -0
  86. package/llms.txt +20 -0
  87. package/package.json +128 -0
  88. package/pwa/app.css +95 -0
  89. package/pwa/icons/icon-192.png +0 -0
  90. package/pwa/icons/icon-512.png +0 -0
  91. package/pwa/icons/icon-maskable-512.png +0 -0
  92. package/pwa/icons/icon-maskable.svg +1 -0
  93. package/pwa/icons/icon.svg +1 -0
  94. package/pwa/inject.js +190 -0
  95. package/pwa/manifest.json +29 -0
  96. package/pwa/offline.html +38 -0
  97. package/pwa/sw.js +176 -0
  98. package/pwa/touch-gestures.js +94 -0
  99. package/scripts/build-client.mjs +101 -0
  100. package/scripts/cdp-probe.mjs +64 -0
  101. package/scripts/check-attach-upload.mjs +99 -0
  102. package/scripts/check-sunk-viewport.mjs +33 -0
  103. package/scripts/check-upload-endpoint.mjs +168 -0
  104. package/src/client/MobileAttachButton.tsx +125 -0
  105. package/src/client/MobileAttachChips.tsx +85 -0
  106. package/src/client/MobileDrawerFooter.tsx +56 -0
  107. package/src/client/MobileHome.tsx +348 -0
  108. package/src/client/MobileHomeChips.tsx +426 -0
  109. package/src/client/MobileNavOverlay.tsx +279 -0
  110. package/src/client/MobileNavToggle.tsx +52 -0
  111. package/src/client/MobileSessionHeader.tsx +242 -0
  112. package/src/client/MobileSessionInfo.tsx +303 -0
  113. package/src/client/attach-upload.ts +140 -0
  114. package/src/client/chips-store.ts +41 -0
  115. package/src/client/debug.ts +253 -0
  116. package/src/client/effects/aionui-compat.ts +183 -0
  117. package/src/client/effects/gestures.ts +265 -0
  118. package/src/client/effects/header-status.ts +30 -0
  119. package/src/client/effects/phone-chrome.ts +265 -0
  120. package/src/client/effects/turn-fold.ts +258 -0
  121. package/src/client/index.tsx +227 -0
  122. package/src/client/locales.ts +109 -0
  123. package/src/client/nav-store.ts +78 -0
  124. package/src/client/session-dot.ts +17 -0
  125. package/src/client/styles/base.css.ts +167 -0
  126. package/src/client/styles/chips.css.ts +224 -0
  127. package/src/client/styles/compat.css.ts +681 -0
  128. package/src/client/styles/composer.css.ts +504 -0
  129. package/src/client/styles/header.css.ts +368 -0
  130. package/src/client/styles/home.css.ts +477 -0
  131. package/src/client/styles/index.ts +25 -0
  132. package/src/client/styles/info.css.ts +238 -0
  133. package/src/client/styles/layout.css.ts +501 -0
  134. package/src/client/styles/misc.css.ts +128 -0
  135. package/src/client/styles/turn-fold.css.ts +106 -0
  136. package/src/index.ts +343 -0
@@ -0,0 +1,626 @@
1
+ /* dsh-mobile-pwa · lib/lan-gate-server.cjs
2
+ *
3
+ * Public-facing gateway for DeepSeek Harness (DSH), designed to sit BEHIND a
4
+ * TLS-terminating reverse proxy (nginx/Caddy) owned by the user.
5
+ *
6
+ * Trust model (v2, rework/public-auth-push):
7
+ * - Device identity = pairing token (`lg_device` cookie), NOT source IP.
8
+ * - New devices see a pairing page; they redeem a short-lived one-time code
9
+ * generated from the local admin page. Failed attempts are rate-limited
10
+ * and locked out.
11
+ * - The ONLY IP-based trust left: a loopback socket carrying no
12
+ * X-Forwarded-* headers is the local user (admin + pairing-code
13
+ * generation + push trigger). Proxied requests always carry forwarded
14
+ * headers, so they can never look local.
15
+ * - X-Forwarded-For / X-Forwarded-Proto are honored only when the socket
16
+ * peer is loopback (same-host proxy) or listed in
17
+ * LAN_GATE_TRUSTED_PROXIES.
18
+ *
19
+ * PWA features (manifest, service worker, push) require the HTTPS the proxy
20
+ * provides. Push is real Web Push: VAPID + aes128gcm via the `web-push` dep
21
+ * (the one runtime dependency; everything else is Node stdlib).
22
+ */
23
+
24
+ const os = require('node:os')
25
+ const crypto = require('node:crypto')
26
+ const http = require('node:http')
27
+ const net = require('node:net')
28
+ const fs = require('node:fs')
29
+ const path = require('node:path')
30
+ const webpush = require('web-push')
31
+
32
+ // Optional config file: <DSH_HOME>/lan-gate.config.json — same keys as the
33
+ // cordis config ({port, host, targetPort, rateLimit, trustedProxies,
34
+ // vapidSubject, pushEvents, pushDebounceMs, pushSummary}). Explicit env wins.
35
+ function configFile() {
36
+ try {
37
+ var home = process.env.DSH_HOME || require('node:path').join(require('node:os').homedir(), '.dsh')
38
+ var raw = JSON.parse(require('node:fs').readFileSync(require('node:path').join(home, 'lan-gate.config.json'), 'utf8'))
39
+ return raw !== null && typeof raw === 'object' ? raw : {}
40
+ } catch (e) { return {} }
41
+ }
42
+ const FILE_CONFIG = configFile()
43
+ function cfg(envName, fileKey, fallback) {
44
+ if (process.env[envName] !== undefined) return process.env[envName]
45
+ if (FILE_CONFIG[fileKey] !== undefined && FILE_CONFIG[fileKey] !== null) return String(FILE_CONFIG[fileKey])
46
+ return fallback
47
+ }
48
+
49
+ var PROXY_PORT = Number(cfg('LAN_GATE_PORT', 'port', 3088))
50
+ const LISTEN_HOST = cfg('LAN_GATE_HOST', 'host', '127.0.0.1')
51
+ const RATE_LIMIT_PER_MIN = Number(cfg('LAN_GATE_RATE_LIMIT', 'rateLimit', 120))
52
+ const TARGET_HOST = '127.0.0.1'
53
+ const TARGET_PORT = Number(cfg('LAN_GATE_TARGET_PORT', 'targetPort', 3080))
54
+ const TRUSTED_PROXIES = String(cfg('LAN_GATE_TRUSTED_PROXIES', 'trustedProxies', '')).split(',').map(function (s) { return s.trim() }).filter(Boolean)
55
+
56
+ const COOKIE_NAME = 'lg_device'
57
+ const PAIR_CODE_TTL_MS = 10 * 60 * 1000
58
+ const PAIR_MAX_FAILS = 5
59
+ const PAIR_LOCK_MS = 15 * 60 * 1000
60
+ const MAX_PUSH_SUBSCRIPTIONS = 20
61
+ const HTML_BUFFER_MAX = 2 * 1024 * 1024
62
+
63
+ const PKG_ROOT = path.dirname(__dirname)
64
+ const PWA_DIR = path.join(PKG_ROOT, 'pwa')
65
+
66
+ // ---- state (v2) -------------------------------------------------------------
67
+ // { version: 2,
68
+ // devices: { <id>: { id, token, name, kind, createdAt, lastSeen, ua } },
69
+ // vapid: { publicKey, privateKey },
70
+ // pushSubscriptions: { <deviceId>: { endpoint, keys, at, ua } } }
71
+ // Pairing codes are memory-only on purpose: a restart voids outstanding codes.
72
+ function dshHome() { return process.env.DSH_HOME || path.join(os.homedir(), '.dsh') }
73
+ function stateFile() { return path.join(dshHome(), 'lan-gate-state.json') }
74
+
75
+ function loadState() {
76
+ var raw
77
+ try { raw = JSON.parse(fs.readFileSync(stateFile(), 'utf8')) } catch (e) { raw = null }
78
+ if (raw && typeof raw === 'object' && raw.version === 2) {
79
+ return {
80
+ version: 2,
81
+ devices: (raw.devices && typeof raw.devices === 'object') ? raw.devices : {},
82
+ vapid: (raw.vapid && raw.vapid.publicKey && raw.vapid.privateKey) ? raw.vapid : null,
83
+ pushSubscriptions: (raw.pushSubscriptions && typeof raw.pushSubscriptions === 'object') ? raw.pushSubscriptions : {}
84
+ }
85
+ }
86
+ if (raw && typeof raw === 'object' && raw.decisions) {
87
+ // v1 (per-IP approvals) is meaningless under the token model: archive it.
88
+ try { fs.renameSync(stateFile(), stateFile() + '.v1.bak'); console.log('[lan-gate] archived v1 state to lan-gate-state.json.v1.bak') } catch (e) {}
89
+ }
90
+ return { version: 2, devices: {}, vapid: null, pushSubscriptions: {} }
91
+ }
92
+ function saveState() {
93
+ try {
94
+ fs.mkdirSync(dshHome(), { recursive: true })
95
+ var tmp = stateFile() + '.tmp'
96
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf8')
97
+ fs.renameSync(tmp, stateFile())
98
+ } catch (e) {}
99
+ }
100
+
101
+ var state = loadState()
102
+ if (!state.vapid) { state.vapid = webpush.generateVAPIDKeys(); saveState() }
103
+ // Apple's push service (web.push.apple.com) rejects a VAPID subject that
104
+ // isn't a routable contact with 403 BadJwtToken — the placeholder default
105
+ // works on FCM/Mozilla but silently breaks every iOS device.
106
+ const VAPID_SUBJECT = cfg('LAN_GATE_VAPID_SUBJECT', 'vapidSubject', 'mailto:admin@localhost')
107
+ if (!/^(mailto:|https:\/\/)/.test(VAPID_SUBJECT) || /localhost|\.local(\b|$)/.test(VAPID_SUBJECT) || !/\./.test(VAPID_SUBJECT.replace(/^mailto:/, ''))) {
108
+ console.warn('[lan-gate] VAPID subject "' + VAPID_SUBJECT + '" is not a routable contact; Apple will reject pushes to iOS devices with 403 BadJwtToken. Set "vapidSubject" in <DSH_HOME>/lan-gate.config.json to a real mailto: address or https:// URL.')
109
+ }
110
+ webpush.setVapidDetails(VAPID_SUBJECT, state.vapid.publicKey, state.vapid.privateKey)
111
+
112
+ var tokenIndex = new Map() // token -> device
113
+ for (var _id in state.devices) { var _d = state.devices[_id]; if (_d && _d.token) tokenIndex.set(_d.token, _d) }
114
+
115
+ var pairing = null // { code, expiresAt }
116
+ var pairFails = new Map() // ip -> { count, lockedUntil }
117
+ var rateMap = new Map() // ip -> { started, count }
118
+
119
+ // ---- client identity --------------------------------------------------------
120
+ function normalizeIp(raw) { return String(raw || '').replace(/^::ffff:/, '') }
121
+ function isLoopbackIp(ip) { return ip === '127.0.0.1' || ip === '::1' }
122
+ function hasForwardHeaders(req) { return req.headers['x-forwarded-for'] !== undefined || req.headers['x-forwarded-proto'] !== undefined || req.headers['x-forwarded-host'] !== undefined }
123
+
124
+ // Resolve who is really talking to us. Forwarded headers are only trusted
125
+ // when the socket peer is the proxy (loopback or explicitly listed).
126
+ function resolveClient(req) {
127
+ var sockIp = normalizeIp(req.socket.remoteAddress)
128
+ var proxyTrusted = isLoopbackIp(sockIp) || TRUSTED_PROXIES.indexOf(sockIp) >= 0
129
+ var xff = req.headers['x-forwarded-for']
130
+ if (proxyTrusted && typeof xff === 'string' && xff.trim() !== '') {
131
+ var parts = xff.split(',').map(function (s) { return s.trim() }).filter(Boolean)
132
+ var proto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim()
133
+ return { ip: normalizeIp(parts[parts.length - 1]) || sockIp, viaProxy: true, https: proto === 'https' }
134
+ }
135
+ return { ip: sockIp, viaProxy: false, https: false }
136
+ }
137
+ // Local user at the keyboard: loopback socket, not proxied.
138
+ function isLocalDirect(req, client) { return !client.viaProxy && isLoopbackIp(client.ip) && !hasForwardHeaders(req) }
139
+
140
+ function deviceForReq(req) { var tok = parseCookies(req)[COOKIE_NAME]; return tok ? tokenIndex.get(tok) : undefined }
141
+
142
+ // ---- small helpers ----------------------------------------------------------
143
+ function parseCookies(req) { var out = {}, h = req.headers.cookie; if (typeof h !== 'string' || h === '') return out; var parts = h.split(';'); for (var i = 0; i < parts.length; i++) { var idx = parts[i].indexOf('='); if (idx < 0) continue; var key = parts[i].slice(0, idx).trim(), value = parts[i].slice(idx + 1).trim(); if (key !== '') out[key] = value } return out }
144
+ function esc(s) { return String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/'/g, '&#39;').replace(/"/g, '&quot;') }
145
+ // Responding while the request body is still in flight leaves stray bytes on
146
+ // the socket that get parsed as a bogus next request (connection poisoning,
147
+ // visible through a keep-alive reverse proxy). Drain to 'end' before replying.
148
+ function drainThen(req, res, send) {
149
+ var fire = function () { if (res.writableEnded) return; try { send() } catch (e) {} }
150
+ if (req.readableEnded) { fire(); return }
151
+ // No method shortcut: even a GET can carry a body (Content-Length), and
152
+ // replying before it drains poisons the connection.
153
+ req.resume(); req.on('end', fire); req.on('close', fire)
154
+ }
155
+ function json(req, res, code, value) { drainThen(req, res, function () { res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(JSON.stringify(value)) }) }
156
+ function sendHtml(req, res, code, html, extraHeaders) { drainThen(req, res, function () { var h = { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }; for (var k in (extraHeaders || {})) h[k] = extraHeaders[k]; res.writeHead(code, h); res.end(html) }) }
157
+ function readJsonBody(req, maxBytes, cb) {
158
+ var chunks = [], size = 0
159
+ req.on('data', function (chunk) { if (size < maxBytes) { size += chunk.length; chunks.push(chunk) } })
160
+ req.on('end', function () { var body; try { body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}') } catch (e) { cb(null); return } cb(body && typeof body === 'object' ? body : null) })
161
+ }
162
+ function overRate(ip) { var now = Date.now(), rate = rateMap.get(ip); if (!rate || now - rate.started >= 60000) { rate = { started: now, count: 0 }; rateMap.set(ip, rate) } rate.count += 1; return rate.count > RATE_LIMIT_PER_MIN }
163
+
164
+ // ---- pairing ---------------------------------------------------------------
165
+ const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789' // no 0/O/1/I/L
166
+ function newPairingCode() {
167
+ var bytes = crypto.randomBytes(8), code = ''
168
+ for (var i = 0; i < 8; i++) code += CODE_ALPHABET[bytes[i] % CODE_ALPHABET.length]
169
+ pairing = { code: code, expiresAt: Date.now() + PAIR_CODE_TTL_MS }
170
+ return pairing
171
+ }
172
+ function pairLock(ip) { var f = pairFails.get(ip); return (f && f.lockedUntil && f.lockedUntil > Date.now()) ? f.lockedUntil : 0 }
173
+ function pairFail(ip) { var f = pairFails.get(ip) || { count: 0, lockedUntil: 0 }; f.count += 1; if (f.count >= PAIR_MAX_FAILS) { f.lockedUntil = Date.now() + PAIR_LOCK_MS; f.count = 0 } pairFails.set(ip, f) }
174
+
175
+ function claimHandler(req, res, client) {
176
+ if (req.method !== 'POST') { json(req, res, 405, { ok: false, reason: 'post-only' }); return }
177
+ var locked = pairLock(client.ip)
178
+ if (locked) { json(req, res, 429, { ok: false, reason: 'locked', retryAfterMs: locked - Date.now() }); return }
179
+ readJsonBody(req, 4096, function (body) {
180
+ if (!body) { json(req, res, 400, { ok: false }); return }
181
+ var code = String(body.code || '').toUpperCase().replace(/[^A-Z0-9]/g, '')
182
+ var ok = pairing && pairing.expiresAt > Date.now() && code !== '' && code === pairing.code
183
+ if (!ok) { pairFail(client.ip); json(req, res, 403, { ok: false, reason: 'bad-code' }); return }
184
+ pairing = null // one-time
185
+ var id = crypto.randomBytes(6).toString('hex')
186
+ var token = crypto.randomBytes(32).toString('hex')
187
+ var name = String(body.name || '').slice(0, 40).trim() || ('设备 ' + id.slice(0, 4))
188
+ var device = { id: id, token: token, name: name, kind: 'auto', createdAt: Date.now(), lastSeen: Date.now(), ua: String(req.headers['user-agent'] || '').slice(0, 160) }
189
+ state.devices[id] = device
190
+ tokenIndex.set(token, device)
191
+ saveState()
192
+ var flags = 'Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000' + (client.https ? '; Secure' : '')
193
+ res.setHeader('Set-Cookie', COOKIE_NAME + '=' + token + '; ' + flags)
194
+ json(req, res, 200, { ok: true, id: id, name: name })
195
+ })
196
+ }
197
+
198
+ // ---- pages -----------------------------------------------------------------
199
+ function gatePage(title, body) {
200
+ return '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#0f1115"><title>' + title + '</title><style>*{box-sizing:border-box;-webkit-tap-highlight-color:transparent}html,body{margin:0;padding:0}body{min-height:100dvh;display:flex;align-items:center;justify-content:center;font-family:system-ui,-apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:radial-gradient(1100px 700px at 50% -10%,#1b2233 0%,#0f1115 55%);color:#e6e8ec;padding:max(20px,env(safe-area-inset-top)) max(16px,env(safe-area-inset-right)) max(20px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left));-webkit-text-size-adjust:100%;text-size-adjust:100%}.card{width:100%;max-width:460px;margin:0 auto;padding:38px 26px 30px;border:1px solid #2a2f3a;border-radius:20px;background:#161a22;text-align:center;box-shadow:0 20px 60px rgba(0,0,0,.45)}.logo{width:56px;height:56px;margin:0 auto 18px;border-radius:16px;background:linear-gradient(135deg,#4c8dff,#7a5cff);display:flex;align-items:center;justify-content:center;font-size:24px;font-weight:700;color:#fff}h1{font-size:21px;margin:0 0 6px}.sub{font-size:14px;color:#9aa3b2;margin:0 0 16px}p{font-size:14px;line-height:1.8;color:#9aa3b2;margin:8px 0}input{width:100%;padding:13px 14px;margin:6px 0;font-size:16px;border:1px solid #2f3748;border-radius:12px;background:#0b0e14;color:#e6e8ec;text-align:center}input#code{font-family:ui-monospace,Consolas,monospace;font-size:22px;letter-spacing:6px;text-transform:uppercase}.btn{display:inline-block;width:100%;margin-top:14px;padding:13px 30px;font-size:15px;font-weight:600;border:1px solid #4c8dff;color:#9cc0ff;background:rgba(76,141,255,.08);border-radius:12px;cursor:pointer;text-decoration:none;touch-action:manipulation;user-select:none}.btn:active{background:rgba(76,141,255,.22)}.bad{color:#f0716f;min-height:1.5em}</style></head><body><div class="card">' + body + '</div></body></html>'
201
+ }
202
+ function pairingPage() {
203
+ return gatePage('设备配对 · DSH', '<div class="logo">DSH</div><h1>设备配对</h1><p class="sub">在电脑上打开管理页生成配对码,输入后即可使用</p>' +
204
+ '<input id="code" maxlength="8" autocomplete="one-time-code" inputmode="text" placeholder="配对码">' +
205
+ '<input id="name" maxlength="40" placeholder="设备名(可选),如:我的手机">' +
206
+ '<button class="btn" id="go">配对</button><p class="bad" id="err"></p>' +
207
+ '<p>管理页:本机浏览器访问<br>http://127.0.0.1:' + PROXY_PORT + '/lan-gate/admin</p>' +
208
+ '<script>document.getElementById("go").addEventListener("click",function(){var b=this;b.disabled=true;fetch("/lan-gate/pair/claim",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:document.getElementById("code").value,name:document.getElementById("name").value})}).then(function(r){return r.json().then(function(j){return{s:r.status,j:j}})}).then(function(r){if(r.j&&r.j.ok){location.replace("/");return}b.disabled=false;var e=document.getElementById("err");e.textContent=r.s===429?"尝试次数过多,请 15 分钟后再试":"配对码不正确或已过期"}).catch(function(){b.disabled=false;document.getElementById("err").textContent="网络错误,请重试"})});</script>')
209
+ }
210
+ function rateLimitPage(ip) { return gatePage('请求过于频繁 · DSH', '<div class="logo">DSH</div><h1>请求过于频繁</h1><p class="bad">已超过每分钟 ' + RATE_LIMIT_PER_MIN + ' 次的限制,请稍候。</p>') }
211
+
212
+ function adminPage() {
213
+ return '<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#0f1115"><title>DSH 网关 · 管理</title><style>*{box-sizing:border-box}html,body{margin:0;padding:0}body{min-height:100dvh;font-family:system-ui,-apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;background:radial-gradient(1100px 700px at 50% -10%,#1b2233 0%,#0f1115 55%);color:#e6e8ec;padding:20px 16px 30px}.card{width:100%;max-width:560px;margin:0 auto;padding:28px 24px 24px;border:1px solid #2a2f3a;border-radius:20px;background:#161a22;box-shadow:0 20px 60px rgba(0,0,0,.45)}.head{display:flex;align-items:center;gap:12px;margin-bottom:18px}.logo{width:44px;height:44px;flex:none;border-radius:13px;background:linear-gradient(135deg,#4c8dff,#7a5cff);display:flex;align-items:center;justify-content:center;font-size:19px;font-weight:700;color:#fff}h1{font-size:19px;margin:0}.sub{font-size:13px;color:#9aa3b2;margin:2px 0 0}.badge{display:inline-flex;align-items:center;gap:6px;font-size:12px;border-radius:999px;padding:3px 10px;border:1px solid #2a2f3a;color:#9aa3b2;margin:0 0 14px}.badge b{width:8px;height:8px;border-radius:50%;background:#34d399;box-shadow:0 0 8px #34d399}.label{font-size:13px;font-weight:600;margin:18px 0 8px}.code{font-family:ui-monospace,Consolas,monospace;font-size:26px;letter-spacing:6px;color:#9cc0ff;background:#0b0e14;border:1px solid #232a37;border-radius:10px;padding:12px 16px;text-align:center;margin:6px 0}.item{padding:10px 0;border-bottom:1px solid #232a37}.item:last-child{border-bottom:none}.name{font-size:14px;font-weight:600}.muted{font-size:12px;color:#9aa3b2;margin:3px 0;word-break:break-all}.row{display:flex;align-items:center;gap:8px;margin-top:8px;flex-wrap:wrap}.btn{font-size:13px;border:1px solid #2f3748;background:transparent;color:#e6e8ec;border-radius:9px;padding:7px 14px;cursor:pointer}.btn:active{background:#222838}.btn-primary{border-color:#4c8dff;color:#9cc0ff;background:rgba(76,141,255,.08)}.btn-danger{border-color:#f0716f;color:#f0716f}.kind{font-size:12px;border:1px solid #2f3748;background:transparent;color:#9aa3b2;border-radius:999px;padding:5px 12px;cursor:pointer}.kind.on{border-color:#4c8dff;color:#9cc0ff;background:rgba(76,141,255,.08)}.empty{font-size:13px;color:#9aa3b2;text-align:center;padding:12px 0}.note{font-size:12px;color:#6b7280;line-height:1.7;margin:16px 0 0}.err{color:#f0716f;font-size:13px}</style></head><body><div class="card"><div class="head"><div class="logo">DSH</div><div><h1>网关管理</h1><p class="sub">配对码 + 设备令牌 · 经反代对外</p></div></div><span class="badge"><b></b>运行中 · 127.0.0.1:' + PROXY_PORT + ' → DSH ' + TARGET_PORT + '</span>' +
214
+ '<div class="label">配对码</div><div id="pair" class="empty">尚未生成</div><div class="row"><button class="btn btn-primary" data-act="new-code">生成配对码</button></div><p class="note">配对码 10 分钟有效、一次性。在新设备的配对页输入即可完成配对。</p>' +
215
+ '<div class="label">已配对设备</div><div id="devices" class="empty">加载中…</div>' +
216
+ '<div class="row" style="margin-top:14px"><button class="btn btn-danger" data-act="revoke-all">全部吊销</button></div>' +
217
+ '<p class="note">访问方式:手机=紧凑排版+PWA,电脑=桌面布局,自动=按设备自适应。吊销立即生效并同时删除该设备的推送订阅。</p><div id="err"></div></div><script>' +
218
+ 'var KIND={auto:"自动",phone:"手机",desktop:"电脑"};function post(body){return fetch("/lan-gate/action",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(body)}).then(function(){refresh()}).catch(function(){})}function esc(s){return String(s==null?"":s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}function fmt(ms){return ms?new Date(ms).toLocaleString():""}' +
219
+ 'function render(s){var pairEl=document.getElementById("pair");if(s.pairing&&s.pairing.code){pairEl.innerHTML="<div class=\\"code\\">"+esc(s.pairing.code)+"</div><div class=\\"muted\\">有效至 "+fmt(s.pairing.expiresAt)+"</div>";pairEl.className=""}else{pairEl.textContent="尚未生成(或已使用/过期)";pairEl.className="empty"}' +
220
+ 'var ds=s.devices||[],h="";if(!ds.length)h="<div class=\\"empty\\">暂无已配对设备</div>";for(var i=0;i<ds.length;i++){var d=ds[i];var kb="";var ks=["auto","phone","desktop"];for(var j=0;j<ks.length;j++){var k=ks[j];kb+="<button class=\\"kind"+(d.kind===k?" on":"")+"\\" data-act=\\"set-kind\\" data-id=\\""+esc(d.id)+"\\" data-kind=\\""+k+"\\">"+KIND[k]+"</button>"}h+="<div class=\\"item\\"><div class=\\"name\\">"+esc(d.name)+(d.hasPush?" · 🔔":"")+"</div><div class=\\"muted\\">"+esc(d.ua||"")+"</div><div class=\\"muted\\">配对 "+fmt(d.createdAt)+" · 最近 "+fmt(d.lastSeen)+"</div><div class=\\"row\\">"+kb+"<button class=\\"btn btn-danger\\" data-act=\\"revoke\\" data-id=\\""+esc(d.id)+"\\">吊销</button></div></div>"}document.getElementById("devices").innerHTML=h}' +
221
+ 'function refresh(){fetch("/lan-gate/status").then(function(r){return r.json()}).then(render).catch(function(){var e=document.getElementById("err");if(e)e.innerHTML="<div class=\\"err\\">无法读取状态</div>"})}' +
222
+ 'document.addEventListener("click",function(ev){var el=ev.target;if(!el||!el.getAttribute)return;var act=el.getAttribute("data-act");if(!act)return;if(act==="new-code"){post({action:"new-code"});return}if(act==="set-kind"){post({action:"set-kind",id:el.getAttribute("data-id"),kind:el.getAttribute("data-kind")});return}if(act==="revoke"){post({action:"revoke",id:el.getAttribute("data-id")});return}if(act==="revoke-all"){post({action:"revoke-all"});return}});refresh();setInterval(refresh,3000);' +
223
+ '</script></body></html>'
224
+ }
225
+
226
+ // ---- admin api -------------------------------------------------------------
227
+ function statusHandler(req, res) {
228
+ var devices = []
229
+ for (var id in state.devices) {
230
+ var d = state.devices[id]
231
+ devices.push({ id: d.id, name: d.name, kind: d.kind || 'auto', createdAt: d.createdAt, lastSeen: d.lastSeen, ua: d.ua, hasPush: !!state.pushSubscriptions[d.id] })
232
+ }
233
+ devices.sort(function (a, b) { return (b.lastSeen || 0) - (a.lastSeen || 0) })
234
+ json(req, res, 200, {
235
+ state: 'running', port: PROXY_PORT, target: TARGET_HOST + ':' + TARGET_PORT, pwa: true,
236
+ pairing: (pairing && pairing.expiresAt > Date.now()) ? pairing : null,
237
+ devices: devices, pushSubscriptions: Object.keys(state.pushSubscriptions).length
238
+ })
239
+ }
240
+ function revokeDevice(id) {
241
+ var d = state.devices[id]
242
+ if (!d) return
243
+ tokenIndex.delete(d.token)
244
+ delete state.devices[id]
245
+ delete state.pushSubscriptions[id]
246
+ saveState()
247
+ }
248
+ function actionHandler(req, res) {
249
+ if (req.method !== 'POST') { json(req, res, 405, { ok: false, reason: 'post-only' }); return }
250
+ readJsonBody(req, 16384, function (body) {
251
+ if (!body) { json(req, res, 400, { ok: false }); return }
252
+ var action = String(body.action || ''), id = String(body.id || '')
253
+ if (action === 'new-code') { newPairingCode() }
254
+ else if (action === 'set-kind') { var d = state.devices[id]; var kind = String(body.kind || ''); if (d && (kind === 'phone' || kind === 'desktop' || kind === 'auto')) { d.kind = kind; saveState() } }
255
+ else if (action === 'rename') { var d2 = state.devices[id]; var name = String(body.name || '').slice(0, 40).trim(); if (d2 && name) { d2.name = name; saveState() } }
256
+ else if (action === 'revoke') { revokeDevice(id) }
257
+ else if (action === 'revoke-all') { for (var k in state.devices) revokeDevice(k) }
258
+ else { json(req, res, 400, { ok: false }); return }
259
+ json(req, res, 200, { ok: true })
260
+ })
261
+ }
262
+
263
+ // ---- PWA asset serving ------------------------------------------------------
264
+ const PWA_FILE_TYPES = {
265
+ '.json': 'application/json; charset=utf-8', '.js': 'application/javascript; charset=utf-8', '.mjs': 'application/javascript; charset=utf-8',
266
+ '.css': 'text/css; charset=utf-8', '.html': 'text/html; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png',
267
+ '.webp': 'image/webp', '.ico': 'image/x-icon', '.woff2': 'font/woff2', '.woff': 'font/woff', '.ttf': 'font/ttf'
268
+ }
269
+ function servePwaAsset(req, res) {
270
+ const p = String(req.url || '').split('?')[0]
271
+ if (p.indexOf('/pwa/') !== 0) return false
272
+ const rel = p.slice('/pwa/'.length)
273
+ if (rel.indexOf('..') !== -1 || rel.indexOf('\0') !== -1) return false
274
+ const abs = path.normalize(path.join(PWA_DIR, rel))
275
+ if (abs.indexOf(path.normalize(PWA_DIR)) !== 0) return false
276
+ let body
277
+ try { body = fs.readFileSync(abs) } catch (e) { return false }
278
+ const ext = path.extname(abs).toLowerCase()
279
+ const headers = { 'Content-Type': PWA_FILE_TYPES[ext] || 'application/octet-stream', 'Cache-Control': 'no-cache' }
280
+ // sw.js is served from /pwa/ but must control the whole app (scope "/",
281
+ // matching start_url). This header is what lets a worker ask for a scope
282
+ // wider than its own script directory — paired with { scope: '/' } on the
283
+ // register() call in inject.js.
284
+ if (rel === 'sw.js') headers['Service-Worker-Allowed'] = '/'
285
+ res.writeHead(200, headers)
286
+ res.end(body)
287
+ return true
288
+ }
289
+
290
+ // ---- HTML injection ---------------------------------------------------------
291
+ const UUID_POLYFILL = 'if(!window.crypto||!window.crypto.randomUUID){window.crypto.randomUUID=function(){' +
292
+ 'var b=new Uint8Array(16);window.crypto.getRandomValues(b);b[6]=(b[6]&15)|64;b[8]=(b[8]&63)|128;' +
293
+ 'var h="";for(var i=0;i<16;i++){if(i===4||i===6||i===8||i===10)h+="-";var v=b[i].toString(16);h+=(v.length===1?"0":"")+v}' +
294
+ 'return h}}'
295
+
296
+ function injectDeviceAttr(html, kind) { if (!kind) return html; var m = html.match(/<html[^>]*/i); if (!m) return html; return html.slice(0, m.index) + m[0] + ' data-lan-device="' + kind + '"' + html.slice(m.index + m[0].length) }
297
+ function readPwaText(name) { try { return fs.readFileSync(path.join(PWA_DIR, name), 'utf8') } catch (e) { return '' } }
298
+ const INJECT_JS = readPwaText('inject.js')
299
+
300
+ // NOTE (2026-08-17): this used to be where an inline `DEVICE_CSS` <style>
301
+ // block lived, injected into every response ahead of pwa/app.css. It is GONE
302
+ // ON PURPOSE -- removed entirely, not trimmed, after auditing every rule it
303
+ // held against the app.css cleanup in commit 3ec8f38 and the gate fix in
304
+ // 19881fe:
305
+ // - Every selector in it was rooted at the literal `[data-lan-device="phone"]`
306
+ // value, which the gateway only ever stamps for an explicit admin
307
+ // override. A real paired phone defaults to kind "auto" and never
308
+ // carries that attribute at all (see injectDeviceAttr below), so on
309
+ // every actual device in the field none of these rules ever fired --
310
+ // they only activated when someone visited the admin page and manually
311
+ // pinned a device to "phone" for testing. That literal-phone/no-op gap
312
+ // is exactly what caused the "phone-mode buttons look stretched" report:
313
+ // the `min-height:44px` touch-target rule only ever fired under manual
314
+ // testing, never in normal use, so it went unnoticed for a long time.
315
+ // - Font-size compression, composer pill compaction (incl. the hashed
316
+ // `.Sh0Q9G_triggerLabel` selector), and the model-menu popover fixed
317
+ // positioning are the same "mobile UI layout" that commit 3ec8f38 handed
318
+ // off to @dsh-external/dsh-mobile-nav -- and the popover rule actively
319
+ // conflicted with that plugin's own bottom sheet (fixed positioning vs.
320
+ // the sheet's own layout).
321
+ // - The `input`/`textarea` font-size:16px workaround duplicated a rule
322
+ // app.css already carries under the broader `:not([data-lan-device=
323
+ // "desktop"])` gate, so it added nothing once fixed to fire on real
324
+ // phones.
325
+ // - The trailing `@media (max-width:820px)` fallback block duplicated the
326
+ // one commit 3ec8f38 already removed from app.css, for the same reason:
327
+ // dsh-mobile-nav's own media queries aren't gated on data-lan-device, so
328
+ // they cover that fallback on their own.
329
+ // Net effect: nothing here was both correct and not already covered
330
+ // elsewhere, so there is no replacement constant -- app.css (linked below)
331
+ // is now the single place mobile shell CSS lives in this repo.
332
+
333
+ // First-frame safe area. env(safe-area-inset-*) is 0 until the viewport meta
334
+ // carries viewport-fit=cover, so on a notched iPhone the standalone PWA paints
335
+ // its FIRST frame under the notch and only springs back once something
336
+ // disturbs the viewport (a drag). dsh-mobile-nav patches the same meta, but
337
+ // only after its bundle boots — too late for the cold-start frame. Patching it
338
+ // here puts it in the HTML itself.
339
+ // The whole tag is replaced (rather than the content merged): this is exactly
340
+ // the value dsh-mobile-nav installs at <=1023px, so gateway and plugin cannot
341
+ // drift apart. Applied to every device kind EXCEPT an explicit "desktop" —
342
+ // the default kind is "auto", which is what real phones are registered as, so
343
+ // a kind === 'phone' gate would be a no-op for them. On a screen without a
344
+ // display cutout viewport-fit=cover changes nothing.
345
+ const VIEWPORT_META = '<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">'
346
+ function coverViewport(html) {
347
+ const re = /<meta\b[^>]*\bname\s*=\s*["']?viewport["']?[^>]*>/i
348
+ if (re.test(html)) return html.replace(re, VIEWPORT_META)
349
+ const headOpen = html.search(/<head[^>]*>/i)
350
+ if (headOpen < 0) return html
351
+ const at = html.indexOf('>', headOpen) + 1
352
+ return html.slice(0, at) + VIEWPORT_META + html.slice(at)
353
+ }
354
+
355
+ // DSH itself already ships its own <link rel="manifest" href="/manifest.webmanifest">
356
+ // (generic name/icon, display:fullscreen). Left in place, it sits BEFORE our
357
+ // injected tag in <head> — and a document only ever honors the first
358
+ // rel="manifest" link it finds, so our mobile-tailored manifest.json (proper
359
+ // 192/512/maskable icons, "DeepSeek Harness Mobile" branding, the
360
+ // background_color the iOS dead-strip fix depends on) was silently shadowed
361
+ // and never took effect. Strip any pre-existing manifest link so ours is the
362
+ // only one and actually governs the install.
363
+ const MANIFEST_LINK_RE = /<link\b[^>]*\brel\s*=\s*["']?manifest["']?[^>]*>\s*/gi
364
+ function stripExistingManifestLink(html) { return html.replace(MANIFEST_LINK_RE, '') }
365
+ const MANIFEST_LINK = '<link rel="manifest" href="/pwa/manifest.json">'
366
+ const THEME_META = '<meta name="theme-color" content="#0f1115">'
367
+ const APPLE_META = '<meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"><meta name="apple-mobile-web-app-title" content="DSH">'
368
+ const APPLE_TOUCH = '<link rel="apple-touch-icon" href="/pwa/icons/icon-192.png">'
369
+
370
+ function pwaBoot() { return '<script>window.__DSH_PWA__={vapid:"' + esc(state.vapid.publicKey) + '"};' + INJECT_JS + '</script>' }
371
+
372
+ // Small fixed entry for the local user browsing through the gateway port —
373
+ // remote/paired devices never get this (admin surface is local-only anyway).
374
+ const ADMIN_CHIP = '<a href="/lan-gate/admin" title="设备配对管理" style="position:fixed;right:14px;bottom:14px;z-index:2147483000;font:12px system-ui;padding:6px 12px;border-radius:999px;background:rgba(22,26,34,.9);color:#9cc0ff;border:1px solid #2a2f3a;text-decoration:none;opacity:.8">⚙ 配对管理</a>'
375
+
376
+ function injectHtml(html, kind, adminEntry) {
377
+ html = injectDeviceAttr(html, kind)
378
+ if (kind !== 'desktop') html = coverViewport(html)
379
+ html = stripExistingManifestLink(html)
380
+ const headOpen = html.search(/<head[^>]*>/i)
381
+ if (headOpen < 0) return html
382
+ const headClose = html.search(/<\/head>/i)
383
+ const headInsert = html.indexOf('>', headOpen) + 1
384
+ const headTail = headClose > headInsert ? headClose : html.length
385
+ const inject = '<script>' + UUID_POLYFILL + '</script>' +
386
+ MANIFEST_LINK + THEME_META + APPLE_META + APPLE_TOUCH +
387
+ '<link rel="stylesheet" href="/pwa/app.css">' +
388
+ pwaBoot()
389
+ html = html.slice(0, headTail) + inject + html.slice(headTail)
390
+ if (adminEntry) {
391
+ var bodyClose = html.search(/<\/body>/i)
392
+ html = bodyClose >= 0 ? html.slice(0, bodyClose) + ADMIN_CHIP + html.slice(bodyClose) : html + ADMIN_CHIP
393
+ }
394
+ return html
395
+ }
396
+
397
+ // ---- push ------------------------------------------------------------------
398
+ function pushSubscribeHandler(req, res, device) {
399
+ if (req.method !== 'POST') { json(req, res, 405, { ok: false, reason: 'post-only' }); return }
400
+ readJsonBody(req, 32768, function (body) {
401
+ if (!body) { json(req, res, 400, { ok: false }); return }
402
+ var sub = body.subscription
403
+ // Real push services are always https; the loopback-http exception exists for tests.
404
+ var allowHttpLoopback = process.env.LAN_GATE_ALLOW_HTTP_PUSH === '1' && typeof (sub && sub.endpoint) === 'string' && /^http:\/\/127\.0\.0\.1[:/]/.test(sub.endpoint)
405
+ if (!sub || typeof sub.endpoint !== 'string' || (!/^https:\/\//.test(sub.endpoint) && !allowHttpLoopback)) { json(req, res, 400, { ok: false, reason: 'bad-subscription' }); return }
406
+ var isNew = !state.pushSubscriptions[device.id]
407
+ if (isNew && Object.keys(state.pushSubscriptions).length >= MAX_PUSH_SUBSCRIPTIONS) { json(req, res, 429, { ok: false, reason: 'too-many-subscriptions' }); return }
408
+ state.pushSubscriptions[device.id] = { endpoint: sub.endpoint, keys: sub.keys || {}, at: Date.now(), ua: String(req.headers['user-agent'] || '').slice(0, 160) }
409
+ saveState()
410
+ json(req, res, 200, { ok: true })
411
+ })
412
+ }
413
+ // web-push does the hard part (aes128gcm encryption + VAPID JWT) via
414
+ // generateRequestDetails; we dispatch the HTTP call ourselves so the endpoint
415
+ // scheme is honored (real push services are https; tests use http loopback).
416
+ function deliverPush(subscription, payload, cb) {
417
+ var details
418
+ try { details = webpush.generateRequestDetails(subscription, payload, { TTL: 3600 }) } catch (e) { cb(e); return }
419
+ var u
420
+ try { u = new URL(details.endpoint) } catch (e) { cb(e); return }
421
+ var mod = u.protocol === 'https:' ? require('node:https') : http
422
+ var pReq = mod.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method: details.method, headers: details.headers }, function (pRes) {
423
+ var chunks = []
424
+ pRes.on('data', function (c) { if (chunks.length < 8) chunks.push(c) })
425
+ pRes.on('end', function () {
426
+ var code = pRes.statusCode
427
+ if (code >= 200 && code < 300) { cb(null); return }
428
+ var err = new Error('push status ' + code)
429
+ err.statusCode = code
430
+ // The provider explains the refusal in the body (e.g. Apple's
431
+ // {"reason":"BadJwtToken"}); swallowing it made failures unreadable.
432
+ err.body = Buffer.concat(chunks).toString('utf8').slice(0, 200)
433
+ cb(err)
434
+ })
435
+ })
436
+ pReq.on('error', function (e) { cb(e) })
437
+ pReq.end(details.body)
438
+ }
439
+
440
+ function pushSendHandler(req, res) {
441
+ if (req.method !== 'POST') { json(req, res, 405, { ok: false, reason: 'post-only' }); return }
442
+ readJsonBody(req, 16384, function (body) {
443
+ if (!body) { json(req, res, 400, { ok: false }); return }
444
+ // Deliberately no conversation content: title + optional session label only.
445
+ var payload = JSON.stringify({ title: String(body.title || 'DSH 任务完成').slice(0, 80), body: String(body.body || '').slice(0, 120), tag: String(body.tag || 'dsh-agent-done'), data: { url: '/' } })
446
+ var ids = Object.keys(state.pushSubscriptions)
447
+ if (!ids.length) { json(req, res, 200, { ok: true, sent: 0, failed: 0 }); return }
448
+ var sent = 0, failed = 0, pending = ids.length, dirty = false
449
+ ids.forEach(function (id) {
450
+ var sub = state.pushSubscriptions[id]
451
+ deliverPush({ endpoint: sub.endpoint, keys: sub.keys }, payload, function (err) {
452
+ if (!err) sent++
453
+ else {
454
+ failed++
455
+ var code = err.statusCode
456
+ var host = ''
457
+ try { host = new URL(sub.endpoint).host } catch (e2) { host = '?' }
458
+ console.warn('[lan-gate] push to ' + host + ' failed: ' + (code || '-') + ' ' + String(err.body || err.message || '').replace(/\s+/g, ' '))
459
+ if (code === 404 || code === 410) { delete state.pushSubscriptions[id]; dirty = true } // expired subscription
460
+ }
461
+ pending--
462
+ if (pending === 0) { if (dirty) saveState(); json(req, res, 200, { ok: true, sent: sent, failed: failed }) }
463
+ })
464
+ })
465
+ })
466
+ }
467
+
468
+ // ---- proxy -----------------------------------------------------------------
469
+ function cleanHeaders(req, clientIp) {
470
+ var headers = req.headers
471
+ var drop = { host: 1, origin: 1, connection: 1, 'proxy-connection': 1, 'keep-alive': 1, te: 1, trailer: 1, 'transfer-encoding': 1, upgrade: 1, 'proxy-authorization': 1, 'proxy-authenticate': 1 }
472
+ // For HTML navigations we buffer + modify the body, so ask upstream for
473
+ // identity encoding (otherwise we'd corrupt gzip).
474
+ var wantsHtml = String(headers.accept || '').indexOf('text/html') >= 0
475
+ var out = {}
476
+ for (var k in headers) { var lk = String(k).toLowerCase(); if (drop[lk]) continue; if (wantsHtml && lk === 'accept-encoding') continue; out[k] = headers[k] }
477
+ var targetOrigin = 'http://' + TARGET_HOST + ':' + TARGET_PORT
478
+ // DSH's /api trust fence validates browser Origin/Referer against its own
479
+ // origin; dropping Origin (the old behavior) or leaking the public domain
480
+ // breaks state-changing plugin calls (e.g. dshmarket installs). Requests
481
+ // reaching here already passed the device-token gate — and SameSite=Lax
482
+ // keeps cross-site POSTs cookie-less — so presenting them to DSH as
483
+ // same-origin does not reopen CSRF.
484
+ if (headers.origin !== undefined) out['origin'] = targetOrigin
485
+ if (typeof headers.referer === 'string') {
486
+ try { var refUrl = new URL(headers.referer); out['referer'] = targetOrigin + refUrl.pathname + refUrl.search } catch (e) { delete out['referer'] }
487
+ }
488
+ out['host'] = TARGET_HOST + ':' + TARGET_PORT
489
+ out['x-forwarded-for'] = clientIp
490
+ return out
491
+ }
492
+
493
+ function forwardRequest(req, res, client, device, isLocal) {
494
+ var headers = cleanHeaders(req, client.ip)
495
+ var upstream = http.request({ host: TARGET_HOST, port: TARGET_PORT, method: req.method, path: req.url, headers: headers }, function (upRes) {
496
+ var outHeaders = {}; for (var k in upRes.headers) outHeaders[k] = upRes.headers[k]
497
+ // upRes is already de-chunked by the http client; forwarding hop-by-hop
498
+ // framing headers would produce an invalid (or double-framed) response.
499
+ delete outHeaders['transfer-encoding']; delete outHeaders['connection']; delete outHeaders['keep-alive']
500
+ var ct = String(outHeaders['content-type'] || '')
501
+ var enc = String(outHeaders['content-encoding'] || '')
502
+ var isHtml = ct.indexOf('text/html') >= 0 && (enc === '' || enc === 'identity')
503
+ var kind = device && (device.kind === 'phone' || device.kind === 'desktop') ? device.kind : undefined
504
+ if (isHtml) {
505
+ outHeaders['cache-control'] = 'no-store'
506
+ var chunks = [], size = 0, done = false
507
+ upRes.on('data', function (chunk) {
508
+ if (done) { return }
509
+ size += chunk.length
510
+ chunks.push(chunk)
511
+ if (size > HTML_BUFFER_MAX) {
512
+ // Too big to buffer: pass through untouched (no injection, no corruption).
513
+ done = true
514
+ try { res.writeHead(upRes.statusCode || 502, outHeaders); for (var i = 0; i < chunks.length; i++) res.write(chunks[i]) } catch (e) {}
515
+ upRes.pipe(res)
516
+ }
517
+ })
518
+ upRes.on('end', function () {
519
+ if (done) return
520
+ done = true
521
+ try {
522
+ var html = injectHtml(Buffer.concat(chunks).toString('utf8'), kind, isLocal === true)
523
+ delete outHeaders['content-length']
524
+ outHeaders['content-length'] = String(Buffer.byteLength(html))
525
+ res.writeHead(upRes.statusCode || 502, outHeaders)
526
+ res.end(html)
527
+ } catch (e) { try { res.end() } catch (e2) {} }
528
+ })
529
+ upRes.on('error', function () { if (!done) { done = true; try { res.end() } catch (e) {} } })
530
+ return
531
+ }
532
+ try { res.writeHead(upRes.statusCode || 502, outHeaders) } catch (e) {}
533
+ upRes.pipe(res)
534
+ })
535
+ upstream.on('error', function () { try { if (!res.headersSent) res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' }); res.end('Bad Gateway') } catch (e) {} })
536
+ res.on('close', function () { try { upstream.destroy() } catch (e) {} })
537
+ req.pipe(upstream)
538
+ }
539
+
540
+ // ---- server ----------------------------------------------------------------
541
+ var lastSeenDirty = false
542
+ function touchDevice(device) { device.lastSeen = Date.now(); lastSeenDirty = true } // persisted by sweep + shutdown
543
+
544
+ var server = http.createServer(function (req, res) {
545
+ var client = resolveClient(req)
546
+ var pathname = String(req.url || '/').split('?')[0]
547
+ var local = isLocalDirect(req, client)
548
+ var device = local ? undefined : deviceForReq(req)
549
+ // Rate limiting protects the unauthenticated surface (pairing page/claim).
550
+ // Local users and paired devices are exempt — the DSH SPA fires dozens of
551
+ // requests per page load; their guardrail is the token + revocation.
552
+ if (!local && !device && overRate(client.ip)) { sendHtml(req, res, 429, rateLimitPage(client.ip), { 'Retry-After': '60' }); return }
553
+
554
+ // Local-only surface: admin UI, admin API, pairing-code generation, push trigger.
555
+ if (pathname === '/lan-gate/status') { if (!local) { json(req, res, 403, { ok: false }); return } statusHandler(req, res); return }
556
+ if (pathname === '/lan-gate/action') { if (!local) { json(req, res, 403, { ok: false }); return } actionHandler(req, res); return }
557
+ if (pathname === '/lan-gate/admin') { if (!local) { json(req, res, 403, { ok: false }); return } sendHtml(req, res, 200, adminPage()); return }
558
+ if (pathname === '/lan-gate/pair') { if (!local) { json(req, res, 403, { ok: false }); return } if (req.method !== 'POST') { json(req, res, 405, { ok: false }); return } json(req, res, 200, { ok: true, code: newPairingCode().code, expiresAt: pairing.expiresAt }); return }
559
+
560
+ if (pathname === '/lan-gate/pair/claim') { claimHandler(req, res, client); return } // reachable by anyone, guarded by code + lockout
561
+ if (pathname === '/pwa/push/send') { if (!local) { json(req, res, 403, { ok: false }); return } pushSendHandler(req, res); return }
562
+
563
+ // The web-app manifest and its icons must be readable WITHOUT the device
564
+ // cookie: browsers fetch the manifest (and every icon it lists)
565
+ // credential-less by spec, so behind the pairing wall Chrome silently got
566
+ // the 401 pairing page instead — no install prompt, and the install name
567
+ // fell back to the upstream <title> ("DeepSeek Harness"). These files carry
568
+ // no user data. Everything else under /pwa/ stays behind the wall.
569
+ if (pathname === '/pwa/manifest.json' || pathname.indexOf('/pwa/icons/') === 0) { if (servePwaAsset(req, res)) return }
570
+
571
+ if (local || device) {
572
+ if (device) {
573
+ touchDevice(device)
574
+ if (pathname === '/pwa/push/subscribe') { pushSubscribeHandler(req, res, device); return }
575
+ }
576
+ if (pathname.indexOf('/pwa/') === 0 && pathname.indexOf('/pwa/push/') !== 0) { if (servePwaAsset(req, res)) return }
577
+ forwardRequest(req, res, client, device, local)
578
+ return
579
+ }
580
+
581
+ // Unpaired remote: everything funnels into the pairing page.
582
+ if (pathname.indexOf('/api/') === 0 || String(req.headers.accept || '').indexOf('application/json') >= 0) { json(req, res, 401, { ok: false, reason: 'unpaired' }); return }
583
+ sendHtml(req, res, 401, pairingPage())
584
+ })
585
+
586
+ server.on('upgrade', function (req, socket, head) {
587
+ var client = resolveClient(req)
588
+ var local = isLocalDirect(req, client)
589
+ var device = local ? undefined : deviceForReq(req)
590
+ if (!local && !device && overRate(client.ip)) { try { socket.end('HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\n\r\n') } catch (e) {} return }
591
+ var ok = local || device !== undefined
592
+ if (!ok) { try { socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n') } catch (e) {} return }
593
+ if (device) touchDevice(device)
594
+ var headers = cleanHeaders(req, client.ip)
595
+ headers['upgrade'] = req.headers['upgrade'] || 'websocket'
596
+ headers['connection'] = 'Upgrade'
597
+ var upstream = net.connect(TARGET_PORT, TARGET_HOST, function () {
598
+ var raw = req.method + ' ' + req.url + ' HTTP/1.1\r\n'
599
+ for (var k in headers) { var v = headers[k]; if (Array.isArray(v)) { for (var i = 0; i < v.length; i++) raw += k + ': ' + v[i] + '\r\n' } else { raw += k + ': ' + v + '\r\n' } }
600
+ raw += '\r\n'
601
+ var kill = function () { try { socket.destroy() } catch (e) {} try { upstream.destroy() } catch (e) {} }
602
+ socket.on('error', kill); upstream.on('error', kill); socket.on('close', kill); upstream.on('close', kill)
603
+ try { upstream.write(raw) } catch (e) {}
604
+ if (head && head.length > 0) { try { upstream.write(head) } catch (e) {} }
605
+ socket.pipe(upstream); upstream.pipe(socket)
606
+ })
607
+ upstream.on('error', function () { try { socket.destroy() } catch (e) {} })
608
+ })
609
+
610
+ server.on('clientError', function (e, socket) { try { socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n') } catch (e2) {} })
611
+
612
+ var maxPort = PROXY_PORT + 20
613
+ server.on('error', function (err) { if (err && err.code === 'EADDRINUSE' && PROXY_PORT < maxPort) { PROXY_PORT += 1; try { server.listen(PROXY_PORT, LISTEN_HOST) } catch (e2) { console.error('[lan-gate] listen failed: ' + String(e2 && e2.message || e2)); process.exit(1) } return } console.error('[lan-gate] server error: ' + String(err && err.message ? err.message : err)); process.exit(1) })
614
+ server.listen(PROXY_PORT, LISTEN_HOST, function () { console.log('[lan-gate] listening on ' + LISTEN_HOST + ':' + PROXY_PORT + ' -> ' + TARGET_HOST + ':' + TARGET_PORT + ' (pwa=on, auth=pairing)') })
615
+
616
+ var sweep = setInterval(function () {
617
+ var now = Date.now()
618
+ rateMap.forEach(function (rate, ip) { if (now - rate.started >= 120000) rateMap.delete(ip) })
619
+ pairFails.forEach(function (f, ip) { if (f.lockedUntil && f.lockedUntil < now && f.count === 0) pairFails.delete(ip) })
620
+ if (pairing && pairing.expiresAt < now) pairing = null
621
+ if (lastSeenDirty) { lastSeenDirty = false; saveState() }
622
+ }, 3000)
623
+ function shutdown() { clearInterval(sweep); if (lastSeenDirty) saveState(); try { server.close() } catch (e) {} process.exit(0) }
624
+ process.on('SIGTERM', shutdown)
625
+ process.on('SIGINT', shutdown)
626
+ console.log('[lan-gate] gateway starting on port ' + PROXY_PORT)