dsh-jace-remote 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/devices.js ADDED
@@ -0,0 +1,105 @@
1
+ // dsh-jace-remote — 设备会话:token 签发 / 校验 / 撤销 + state.json 持久化
2
+ import { createHash, randomBytes } from 'node:crypto'
3
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
4
+ import { dirname } from 'node:path'
5
+
6
+ const hash = (token) => createHash('sha256').update(token).digest('hex')
7
+
8
+ export class DeviceStore {
9
+ /**
10
+ * @param {object} opts
11
+ * @param {string} opts.filePath state.json 路径(0600)
12
+ * @param {number} [opts.sessionTtlHours=168] 会话有效期(滑动续期)
13
+ * @param {() => number} [opts.now]
14
+ */
15
+ constructor(opts) {
16
+ this.filePath = opts.filePath
17
+ this.ttlMs = (opts.sessionTtlHours ?? 168) * 3_600_000
18
+ this.now = opts.now ?? (() => Date.now())
19
+ /** @type {Array<{id:string, tokenHash:string, name:string, ip:string, createdAt:number, lastSeenAt:number, expiresAt:number}>} */
20
+ this.devices = []
21
+ this.load()
22
+ }
23
+
24
+ load() {
25
+ try {
26
+ if (!existsSync(this.filePath)) return
27
+ const raw = JSON.parse(readFileSync(this.filePath, 'utf8'))
28
+ if (Array.isArray(raw?.devices)) this.devices = raw.devices.filter((d) => d && typeof d.tokenHash === 'string')
29
+ } catch {
30
+ this.devices = []
31
+ }
32
+ }
33
+
34
+ save() {
35
+ mkdirSync(dirname(this.filePath), { recursive: true, mode: 0o700 })
36
+ writeFileSync(this.filePath, JSON.stringify({ version: 1, devices: this.devices }, null, 2), { mode: 0o600 })
37
+ try { chmodSync(this.filePath, 0o600) } catch { /* best effort */ }
38
+ }
39
+
40
+ /** 清理过期设备;返回被清理数量。 */
41
+ prune() {
42
+ const now = this.now()
43
+ const before = this.devices.length
44
+ this.devices = this.devices.filter((d) => d.expiresAt > now)
45
+ return before - this.devices.length
46
+ }
47
+
48
+ /**
49
+ * 配对成功 → 签发设备 token(只存哈希)。
50
+ * @returns {{token: string, device: object}}
51
+ */
52
+ issue({ name = 'device', ip = '' } = {}) {
53
+ const token = randomBytes(32).toString('base64url')
54
+ const tokenHash = hash(token)
55
+ const now = this.now()
56
+ const device = {
57
+ id: tokenHash.slice(0, 12),
58
+ tokenHash,
59
+ name,
60
+ ip,
61
+ createdAt: now,
62
+ lastSeenAt: now,
63
+ expiresAt: now + this.ttlMs,
64
+ }
65
+ this.devices.push(device)
66
+ this.save()
67
+ return { token, device }
68
+ }
69
+
70
+ /** 校验 token(滑动续期)。返回 device 或 null。 */
71
+ verify(token) {
72
+ if (typeof token !== 'string' || token.length < 20) return null
73
+ const tokenHash = hash(token)
74
+ const device = this.devices.find((d) => d.tokenHash === tokenHash)
75
+ if (!device) return null
76
+ const now = this.now()
77
+ if (device.expiresAt <= now) {
78
+ this.revoke(device.id)
79
+ return null
80
+ }
81
+ device.lastSeenAt = now
82
+ device.expiresAt = now + this.ttlMs
83
+ this.save()
84
+ return device
85
+ }
86
+
87
+ list() {
88
+ return this.devices.map(({ id, name, ip, createdAt, lastSeenAt, expiresAt }) => ({ id, name, ip, createdAt, lastSeenAt, expiresAt }))
89
+ }
90
+
91
+ revoke(id) {
92
+ const before = this.devices.length
93
+ this.devices = this.devices.filter((d) => d.id !== id)
94
+ const removed = before !== this.devices.length
95
+ if (removed) this.save()
96
+ return removed
97
+ }
98
+
99
+ revokeAll() {
100
+ const n = this.devices.length
101
+ this.devices = []
102
+ this.save()
103
+ return n
104
+ }
105
+ }
package/src/gateway.js ADDED
@@ -0,0 +1,286 @@
1
+ // dsh-jace-remote — 局域网网关:配对入口 + 反代 dsh web(HTTP/SSE/WebSocket)
2
+ //
3
+ // 设计要点见 DESIGN.md:
4
+ // · LAN 客户端只持我方 cookie(jace_remote);dsh 的会话 cookie 由 UpstreamAuth 内部换出并注入
5
+ // · 只接受私网源地址;/__jace/* 为网关内部路由,不透传
6
+ // · WebSocket 走裸 TCP pipe(重写握手头),保证会话流可用
7
+ import http from 'node:http'
8
+ import net from 'node:net'
9
+ import { pairPage } from './html/pair.html.js'
10
+
11
+ const COOKIE_NAME = 'jace_remote'
12
+ const HOP_BY_HOP = new Set([
13
+ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
14
+ 'te', 'trailer', 'transfer-encoding', 'upgrade',
15
+ ])
16
+
17
+ export function createGateway({
18
+ host = '0.0.0.0',
19
+ port = 3081,
20
+ upstreamPort,
21
+ pairing,
22
+ devices,
23
+ upstreamAuth,
24
+ log = () => {},
25
+ allowPrivateOnly = true,
26
+ maxBodyBytes = 10 * 1024 * 1024,
27
+ }) {
28
+ const upstreamOrigin = `http://127.0.0.1:${upstreamPort}`
29
+
30
+ // ───────────────────────────── HTTP ─────────────────────────────
31
+ const server = http.createServer((req, res) => {
32
+ handleHttp(req, res).catch((err) => {
33
+ log(`gateway error: ${err?.stack || err}`)
34
+ if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
35
+ res.end('gateway error')
36
+ })
37
+ })
38
+
39
+ async function handleHttp(req, res) {
40
+ const ip = normalizeIp(req.socket.remoteAddress)
41
+ if (allowPrivateOnly && !isPrivateIp(ip)) return deny(res, 403, 'forbidden: private network only')
42
+
43
+ const url = new URL(req.url, 'http://gateway.invalid')
44
+ // 设置页状态接口:只对「已配对」设备放行(未配对拿不到配对码)
45
+ if (url.pathname.startsWith('/__jace/remote/')) {
46
+ if (!authenticate(req)) {
47
+ res.writeHead(401, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
48
+ return res.end(pairPage())
49
+ }
50
+ const body = await readBody(req, maxBodyBytes)
51
+ return proxy(req, res, body, { retry: true })
52
+ }
53
+ if (url.pathname.startsWith('/__jace/')) return internalRoute(req, res, url, ip)
54
+
55
+ const device = authenticate(req)
56
+ if (!device) {
57
+ res.writeHead(401, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
58
+ return res.end(pairPage())
59
+ }
60
+ const body = await readBody(req, maxBodyBytes)
61
+ return proxy(req, res, body, { retry: true })
62
+ }
63
+
64
+ async function internalRoute(req, res, url, ip) {
65
+ const p = url.pathname
66
+ if (p === '/__jace/health') {
67
+ return json(res, 200, { ok: true, gateway: 'dsh-jace-remote' })
68
+ }
69
+ if (p === '/__jace/me') {
70
+ const device = authenticate(req)
71
+ return json(res, 200, { paired: Boolean(device), deviceId: device?.id ?? null })
72
+ }
73
+ if (p === '/__jace/pair' && req.method === 'GET') {
74
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
75
+ return res.end(pairPage())
76
+ }
77
+ if (p === '/__jace/pair' && req.method === 'POST') {
78
+ const body = (await readBody(req, 4096)).toString('utf8')
79
+ const code = new URLSearchParams(body).get('code') ?? ''
80
+ const result = pairing.verify(code, ip)
81
+ if (!result.ok) {
82
+ const limited = result.reason === 'rate-limited'
83
+ log(`pair attempt rejected (${result.reason}) from ${ip}`)
84
+ res.writeHead(limited ? 429 : 401, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' })
85
+ return res.end(pairPage({
86
+ error: limited ? '尝试次数过多,请稍后再试。' : '配对码不正确或已过期,请看本机显示的最新码。',
87
+ }))
88
+ }
89
+ const { token, device } = devices.issue({ name: summarizeUa(req.headers['user-agent']), ip })
90
+ log(`paired new device ${device.id} from ${ip}`)
91
+ res.writeHead(303, {
92
+ location: '/',
93
+ 'set-cookie': serializeCookie(token),
94
+ 'cache-control': 'no-store',
95
+ })
96
+ return res.end()
97
+ }
98
+ if (p === '/__jace/unpair' && req.method === 'POST') {
99
+ res.writeHead(303, { location: '/', 'set-cookie': `${COOKIE_NAME}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax` })
100
+ return res.end()
101
+ }
102
+ return deny(res, 404, 'not found')
103
+ }
104
+
105
+ // ──────────────────────────── 反代 ────────────────────────────
106
+ async function proxy(req, res, body, { retry }) {
107
+ const cookie = await upstreamAuth.ensure()
108
+ const headers = upstreamHeaders(req.headers, cookie)
109
+ const upstreamReq = http.request(
110
+ { host: '127.0.0.1', port: upstreamPort, method: req.method, path: req.url, headers },
111
+ (upstreamRes) => {
112
+ if (upstreamRes.statusCode === 401 && retry) {
113
+ upstreamRes.resume()
114
+ upstreamAuth.refresh().then(() => proxy(req, res, body, { retry: false })).catch((err) => {
115
+ log(`re-auth failed: ${err?.message || err}`)
116
+ deny(res, 502, 'upstream re-auth failed')
117
+ })
118
+ return
119
+ }
120
+ const outHeaders = { ...upstreamRes.headers }
121
+ for (const h of HOP_BY_HOP) delete outHeaders[h]
122
+ delete outHeaders['set-cookie'] // 不把 dsh 的 authority 绑定 cookie 泄给 LAN 客户端
123
+ res.writeHead(upstreamRes.statusCode ?? 502, outHeaders)
124
+ upstreamRes.pipe(res)
125
+ },
126
+ )
127
+ upstreamReq.on('error', (err) => {
128
+ log(`upstream error: ${err?.message || err}`)
129
+ if (!res.headersSent) deny(res, 502, 'upstream unreachable')
130
+ else res.end()
131
+ })
132
+ if (body.length > 0) upstreamReq.write(body)
133
+ upstreamReq.end()
134
+ }
135
+
136
+ function upstreamHeaders(clientHeaders, cookie) {
137
+ const headers = {}
138
+ for (const [k, v] of Object.entries(clientHeaders)) {
139
+ const key = k.toLowerCase()
140
+ if (HOP_BY_HOP.has(key) || key === 'host' || key === 'cookie' || key === 'origin' || key === 'referer') continue
141
+ headers[k] = v
142
+ }
143
+ headers.host = `127.0.0.1:${upstreamPort}`
144
+ headers.origin = upstreamOrigin
145
+ if (clientHeaders.referer) headers.referer = upstreamOrigin + '/'
146
+ if (cookie) headers.cookie = cookie
147
+ return headers
148
+ }
149
+
150
+ // WebSocket 握手:必须保留 Upgrade / Connection / Sec-WebSocket-* (它们是协议本体,不是 hop-by-hop)
151
+ function upstreamUpgradeHeaders(clientHeaders, cookie) {
152
+ const headers = {}
153
+ for (const [k, v] of Object.entries(clientHeaders)) {
154
+ const key = k.toLowerCase()
155
+ if (key === 'host' || key === 'cookie' || key === 'origin' || key === 'referer') continue
156
+ headers[key] = v
157
+ }
158
+ headers.host = `127.0.0.1:${upstreamPort}`
159
+ headers.origin = upstreamOrigin
160
+ if (cookie) headers.cookie = cookie
161
+ return headers
162
+ }
163
+
164
+ // ─────────────────────────── WebSocket ───────────────────────────
165
+ server.on('upgrade', (req, socket, head) => {
166
+ const ip = normalizeIp(socket.remoteAddress)
167
+ if (allowPrivateOnly && !isPrivateIp(ip)) return socket.destroy()
168
+ if (!authenticate(req)) {
169
+ socket.write('HTTP/1.1 401 Unauthorized\r\nconnection: close\r\n\r\n')
170
+ return socket.destroy()
171
+ }
172
+ upstreamAuth.ensure().then((cookie) => {
173
+ const upstream = net.connect(upstreamPort, '127.0.0.1', () => {
174
+ const lines = [`${req.method} ${req.url} HTTP/1.1`]
175
+ for (const [k, v] of Object.entries(upstreamUpgradeHeaders(req.headers, cookie))) {
176
+ for (const item of Array.isArray(v) ? v : [v]) lines.push(`${k}: ${item}`)
177
+ }
178
+ upstream.write(lines.join('\r\n') + '\r\n\r\n')
179
+ if (head?.length) upstream.write(head)
180
+ upstream.pipe(socket)
181
+ socket.pipe(upstream)
182
+ })
183
+ const bail = () => { upstream.destroy(); socket.destroy() }
184
+ upstream.on('error', bail)
185
+ socket.on('error', bail)
186
+ }).catch((err) => {
187
+ log(`ws auth failed: ${err?.message || err}`)
188
+ socket.destroy()
189
+ })
190
+ })
191
+
192
+ // ──────────────────────────── 工具 ────────────────────────────
193
+ function authenticate(req) {
194
+ const cookies = parseCookies(req.headers.cookie)
195
+ const token = cookies[COOKIE_NAME]
196
+ return token ? devices.verify(token) : null
197
+ }
198
+
199
+ return {
200
+ get port() { return server.address()?.port ?? port },
201
+ start() {
202
+ return new Promise((resolve, reject) => {
203
+ server.once('error', reject)
204
+ server.listen(port, host, () => {
205
+ server.off('error', reject)
206
+ log(`gateway listening on ${host}:${server.address().port} → 127.0.0.1:${upstreamPort}`)
207
+ resolve({ host, port: server.address().port })
208
+ })
209
+ })
210
+ },
211
+ stop() {
212
+ return new Promise((resolve) => {
213
+ server.closeAllConnections?.()
214
+ server.close(() => resolve())
215
+ })
216
+ },
217
+ }
218
+ }
219
+
220
+ // ───────────────────────────── 辅助 ─────────────────────────────
221
+ function readBody(req, limit) {
222
+ return new Promise((resolve, reject) => {
223
+ const chunks = []
224
+ let size = 0
225
+ req.on('data', (c) => {
226
+ size += c.length
227
+ if (size > limit) { reject(new Error('request body too large')); req.destroy(); return }
228
+ chunks.push(c)
229
+ })
230
+ req.on('end', () => resolve(Buffer.concat(chunks)))
231
+ req.on('error', reject)
232
+ })
233
+ }
234
+
235
+ function parseCookies(header) {
236
+ const out = {}
237
+ if (typeof header !== 'string') return out
238
+ for (const part of header.split(';')) {
239
+ const i = part.indexOf('=')
240
+ if (i < 0) continue
241
+ out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim())
242
+ }
243
+ return out
244
+ }
245
+
246
+ function serializeCookie(value) {
247
+ const maxAge = 7 * 24 * 3600
248
+ return `${COOKIE_NAME}=${encodeURIComponent(value)}; Path=/; Max-Age=${maxAge}; HttpOnly; SameSite=Lax`
249
+ }
250
+
251
+ function normalizeIp(addr) {
252
+ if (!addr) return ''
253
+ return addr.startsWith('::ffff:') ? addr.slice(7) : addr
254
+ }
255
+
256
+ export function isPrivateIp(ip) {
257
+ if (ip === '127.0.0.1' || ip === '::1') return true
258
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(ip)
259
+ if (!m) return /^f[cd][0-9a-f]{2}:/i.test(ip) // fc00::/7(ULA)
260
+ const [a, b] = [Number(m[1]), Number(m[2])]
261
+ if (a === 10 || a === 127) return true
262
+ if (a === 192 && b === 168) return true
263
+ if (a === 172 && b >= 16 && b <= 31) return true
264
+ if (a === 169 && b === 254) return true
265
+ return false
266
+ }
267
+
268
+ function summarizeUa(ua) {
269
+ if (typeof ua !== 'string' || ua === '') return 'device'
270
+ if (/iPhone/i.test(ua)) return 'iPhone'
271
+ if (/iPad/i.test(ua)) return 'iPad'
272
+ if (/Android/i.test(ua)) return 'Android'
273
+ if (/Macintosh/i.test(ua)) return 'Mac'
274
+ if (/Windows/i.test(ua)) return 'Windows'
275
+ return 'device'
276
+ }
277
+
278
+ function json(res, status, payload) {
279
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
280
+ res.end(JSON.stringify(payload))
281
+ }
282
+
283
+ function deny(res, status, message) {
284
+ res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8' })
285
+ res.end(message)
286
+ }
@@ -0,0 +1,45 @@
1
+ // dsh-jace-remote — 配对页(零依赖,内联样式;配对码绝不出现在本页)
2
+ export function pairPage({ error = '' } = {}) {
3
+ const err = error
4
+ ? `<p class="err">${error}</p>`
5
+ : ''
6
+ return `<!doctype html>
7
+ <html lang="zh-CN">
8
+ <head>
9
+ <meta charset="utf-8">
10
+ <meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1">
11
+ <title>dsh-jace-remote · 配对</title>
12
+ <style>
13
+ :root { color-scheme: dark; }
14
+ * { box-sizing: border-box; }
15
+ body { margin:0; min-height:100vh; display:flex; align-items:center; justify-content:center;
16
+ background:#0b0f19; color:#e6e9ef; font:16px/1.5 -apple-system,BlinkMacSystemFont,"PingFang SC",sans-serif; padding:24px; }
17
+ .card { width:100%; max-width:360px; background:#141a29; border:1px solid #232b3f; border-radius:16px; padding:28px 24px; }
18
+ h1 { margin:0 0 6px; font-size:18px; }
19
+ p.sub { margin:0 0 22px; color:#8b94a8; font-size:13px; }
20
+ label { display:block; font-size:13px; color:#8b94a8; margin-bottom:8px; }
21
+ input { width:100%; padding:14px 16px; font-size:28px; letter-spacing:12px; text-align:center;
22
+ background:#0b0f19; color:#fff; border:1px solid #2c3550; border-radius:12px; outline:none; }
23
+ input:focus { border-color:#4c7dff; }
24
+ button { width:100%; margin-top:16px; padding:14px; font-size:16px; font-weight:600; color:#fff;
25
+ background:#4c7dff; border:0; border-radius:12px; }
26
+ button:active { transform:translateY(1px); }
27
+ .err { margin:0 0 16px; padding:10px 12px; font-size:13px; color:#ffb4b4; background:#3a1d22;
28
+ border:1px solid #5a2a33; border-radius:10px; }
29
+ .hint { margin-top:18px; font-size:12px; color:#6b7488; }
30
+ </style>
31
+ </head>
32
+ <body>
33
+ <form class="card" method="POST" action="/__jace/pair" autocomplete="off">
34
+ <h1>🃏 dsh-jace-remote</h1>
35
+ <p class="sub">输入本机显示的 4 位配对码</p>
36
+ ${err}
37
+ <label for="code">配对码</label>
38
+ <input id="code" name="code" inputmode="numeric" pattern="[0-9]{4}" maxlength="4"
39
+ autocomplete="one-time-code" autofocus required placeholder="0000">
40
+ <button type="submit">配对</button>
41
+ <p class="hint">配对码会过期并自动更换;配对成功后此设备可在局域网内直接访问。</p>
42
+ </form>
43
+ </body>
44
+ </html>`
45
+ }