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.
- package/AGENTS.md +51 -0
- package/LICENSE +38 -0
- package/README.md +237 -0
- package/assets/home.png +0 -0
- package/assets/info.png +0 -0
- package/assets/pairing.png +0 -0
- package/assets/session.png +0 -0
- package/assets/sheet.png +0 -0
- package/cordis.patch.yml +17 -0
- package/cordis.patch.yml.example +16 -0
- package/docs/interface.md +200 -0
- package/docs/remote-access.en.md +335 -0
- package/docs/remote-access.md +422 -0
- package/dsh-push.mjs +213 -0
- package/lan-gate.mjs +78 -0
- package/lib/client.js +6615 -0
- package/lib/index.js +310 -0
- package/lib/index.js.map +1 -0
- package/lib/lan-gate-server.cjs +626 -0
- package/lib/types/client/MobileAttachButton.d.ts +25 -0
- package/lib/types/client/MobileAttachButton.d.ts.map +1 -0
- package/lib/types/client/MobileAttachChips.d.ts +32 -0
- package/lib/types/client/MobileAttachChips.d.ts.map +1 -0
- package/lib/types/client/MobileDrawerFooter.d.ts +21 -0
- package/lib/types/client/MobileDrawerFooter.d.ts.map +1 -0
- package/lib/types/client/MobileHome.d.ts +24 -0
- package/lib/types/client/MobileHome.d.ts.map +1 -0
- package/lib/types/client/MobileHomeChips.d.ts +76 -0
- package/lib/types/client/MobileHomeChips.d.ts.map +1 -0
- package/lib/types/client/MobileNavOverlay.d.ts +15 -0
- package/lib/types/client/MobileNavOverlay.d.ts.map +1 -0
- package/lib/types/client/MobileNavToggle.d.ts +18 -0
- package/lib/types/client/MobileNavToggle.d.ts.map +1 -0
- package/lib/types/client/MobileSessionHeader.d.ts +58 -0
- package/lib/types/client/MobileSessionHeader.d.ts.map +1 -0
- package/lib/types/client/MobileSessionInfo.d.ts +41 -0
- package/lib/types/client/MobileSessionInfo.d.ts.map +1 -0
- package/lib/types/client/attach-upload.d.ts +78 -0
- package/lib/types/client/attach-upload.d.ts.map +1 -0
- package/lib/types/client/chips-store.d.ts +13 -0
- package/lib/types/client/chips-store.d.ts.map +1 -0
- package/lib/types/client/debug.d.ts +9 -0
- package/lib/types/client/debug.d.ts.map +1 -0
- package/lib/types/client/effects/aionui-compat.d.ts +4 -0
- package/lib/types/client/effects/aionui-compat.d.ts.map +1 -0
- package/lib/types/client/effects/gestures.d.ts +7 -0
- package/lib/types/client/effects/gestures.d.ts.map +1 -0
- package/lib/types/client/effects/header-status.d.ts +15 -0
- package/lib/types/client/effects/header-status.d.ts.map +1 -0
- package/lib/types/client/effects/phone-chrome.d.ts +106 -0
- package/lib/types/client/effects/phone-chrome.d.ts.map +1 -0
- package/lib/types/client/effects/turn-fold.d.ts +25 -0
- package/lib/types/client/effects/turn-fold.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +18 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/lib/types/client/locales.d.ts +58 -0
- package/lib/types/client/locales.d.ts.map +1 -0
- package/lib/types/client/nav-store.d.ts +73 -0
- package/lib/types/client/nav-store.d.ts.map +1 -0
- package/lib/types/client/session-dot.d.ts +12 -0
- package/lib/types/client/session-dot.d.ts.map +1 -0
- package/lib/types/client/styles/base.css.d.ts +2 -0
- package/lib/types/client/styles/base.css.d.ts.map +1 -0
- package/lib/types/client/styles/chips.css.d.ts +2 -0
- package/lib/types/client/styles/chips.css.d.ts.map +1 -0
- package/lib/types/client/styles/compat.css.d.ts +2 -0
- package/lib/types/client/styles/compat.css.d.ts.map +1 -0
- package/lib/types/client/styles/composer.css.d.ts +2 -0
- package/lib/types/client/styles/composer.css.d.ts.map +1 -0
- package/lib/types/client/styles/header.css.d.ts +2 -0
- package/lib/types/client/styles/header.css.d.ts.map +1 -0
- package/lib/types/client/styles/home.css.d.ts +2 -0
- package/lib/types/client/styles/home.css.d.ts.map +1 -0
- package/lib/types/client/styles/index.d.ts +15 -0
- package/lib/types/client/styles/index.d.ts.map +1 -0
- package/lib/types/client/styles/info.css.d.ts +2 -0
- package/lib/types/client/styles/info.css.d.ts.map +1 -0
- package/lib/types/client/styles/layout.css.d.ts +2 -0
- package/lib/types/client/styles/layout.css.d.ts.map +1 -0
- package/lib/types/client/styles/misc.css.d.ts +2 -0
- package/lib/types/client/styles/misc.css.d.ts.map +1 -0
- package/lib/types/client/styles/turn-fold.css.d.ts +2 -0
- package/lib/types/client/styles/turn-fold.css.d.ts.map +1 -0
- package/lib/types/index.d.ts +83 -0
- package/lib/types/index.d.ts.map +1 -0
- package/llms.txt +20 -0
- package/package.json +128 -0
- package/pwa/app.css +95 -0
- package/pwa/icons/icon-192.png +0 -0
- package/pwa/icons/icon-512.png +0 -0
- package/pwa/icons/icon-maskable-512.png +0 -0
- package/pwa/icons/icon-maskable.svg +1 -0
- package/pwa/icons/icon.svg +1 -0
- package/pwa/inject.js +190 -0
- package/pwa/manifest.json +29 -0
- package/pwa/offline.html +38 -0
- package/pwa/sw.js +176 -0
- package/pwa/touch-gestures.js +94 -0
- package/scripts/build-client.mjs +101 -0
- package/scripts/cdp-probe.mjs +64 -0
- package/scripts/check-attach-upload.mjs +99 -0
- package/scripts/check-sunk-viewport.mjs +33 -0
- package/scripts/check-upload-endpoint.mjs +168 -0
- package/src/client/MobileAttachButton.tsx +125 -0
- package/src/client/MobileAttachChips.tsx +85 -0
- package/src/client/MobileDrawerFooter.tsx +56 -0
- package/src/client/MobileHome.tsx +348 -0
- package/src/client/MobileHomeChips.tsx +426 -0
- package/src/client/MobileNavOverlay.tsx +279 -0
- package/src/client/MobileNavToggle.tsx +52 -0
- package/src/client/MobileSessionHeader.tsx +242 -0
- package/src/client/MobileSessionInfo.tsx +303 -0
- package/src/client/attach-upload.ts +140 -0
- package/src/client/chips-store.ts +41 -0
- package/src/client/debug.ts +253 -0
- package/src/client/effects/aionui-compat.ts +183 -0
- package/src/client/effects/gestures.ts +265 -0
- package/src/client/effects/header-status.ts +30 -0
- package/src/client/effects/phone-chrome.ts +265 -0
- package/src/client/effects/turn-fold.ts +258 -0
- package/src/client/index.tsx +227 -0
- package/src/client/locales.ts +109 -0
- package/src/client/nav-store.ts +78 -0
- package/src/client/session-dot.ts +17 -0
- package/src/client/styles/base.css.ts +167 -0
- package/src/client/styles/chips.css.ts +224 -0
- package/src/client/styles/compat.css.ts +681 -0
- package/src/client/styles/composer.css.ts +504 -0
- package/src/client/styles/header.css.ts +368 -0
- package/src/client/styles/home.css.ts +477 -0
- package/src/client/styles/index.ts +25 -0
- package/src/client/styles/info.css.ts +238 -0
- package/src/client/styles/layout.css.ts +501 -0
- package/src/client/styles/misc.css.ts +128 -0
- package/src/client/styles/turn-fold.css.ts +106 -0
- 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/'/g, ''').replace(/"/g, '"') }
|
|
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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")}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)
|