dsh-composer-plus 1.4.1 → 1.5.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/README.md +20 -7
- package/assets/features/keepFocus.webp +0 -0
- package/assets/features/mobileConnect.webp +0 -0
- package/client.js +188 -20
- package/index.js +101 -3
- package/locale/de.json +1 -1
- package/locale/en.json +1 -1
- package/locale/es.json +1 -1
- package/locale/fr.json +1 -1
- package/locale/hi.json +1 -1
- package/locale/id.json +1 -1
- package/locale/ja.json +1 -1
- package/locale/ko.json +1 -1
- package/locale/pt-BR.json +1 -1
- package/locale/ru.json +1 -1
- package/locale/tr.json +1 -1
- package/locale/vi.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +15 -5
- package/tether/gateway/admin.html +117 -0
- package/tether/gateway/qr.swift +12 -0
- package/tether/gateway/server.mjs +547 -0
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
// Tether gateway: LAN-only HTTPS/WSS front door for the DeepSeek Harness host running on this Mac.
|
|
2
|
+
// It runs in dsh-composer-plus's Host half while Mobile connect is on (see index.js). Phones talk to it; it talks to
|
|
3
|
+
// DSH on 127.0.0.1 with the host's own auth cookie.
|
|
4
|
+
import { createServer as createHttps, request as httpsRequest } from 'node:https'
|
|
5
|
+
import { createServer as createHttp, request as httpRequest } from 'node:http'
|
|
6
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual, X509Certificate } from 'node:crypto'
|
|
7
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
8
|
+
import { execFile, execFileSync, spawn } from 'node:child_process'
|
|
9
|
+
import { homedir, networkInterfaces } from 'node:os'
|
|
10
|
+
import { dirname, join } from 'node:path'
|
|
11
|
+
import { fileURLToPath } from 'node:url'
|
|
12
|
+
|
|
13
|
+
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
14
|
+
const TOKEN_TTL = 30 * 864e5
|
|
15
|
+
const MAX_BODY = 16 << 20
|
|
16
|
+
|
|
17
|
+
// Allowlisted DSH remote methods. Everything else is 403.
|
|
18
|
+
export const RPC = new Set([
|
|
19
|
+
'session/list', 'session/page', 'session/create', 'session/rename', 'session/prompt', 'session/cancel',
|
|
20
|
+
'session/fork', 'session/search', 'session/selectModel', 'session/updateQueue', 'session/attachment',
|
|
21
|
+
'session/projections', 'session/modelCatalog',
|
|
22
|
+
'workspace/archiveSession', 'workspace/unarchiveSession', 'workspace/pinSession', 'workspace/unpinSession',
|
|
23
|
+
'goals/get', 'goals/create', 'goals/edit', 'goals/pause', 'goals/resume', 'goals/complete', 'goals/clear',
|
|
24
|
+
'subagents/prompt', 'subagents/interruptByParent', 'userQuestions/answer',
|
|
25
|
+
'agentPresets/list', 'agentPresets/select', 'permissionPresets/catalog', 'fileUploads/upload',
|
|
26
|
+
'skills/list', 'commands/list', 'commands/execute',
|
|
27
|
+
])
|
|
28
|
+
export const STREAMS = new Set(['session/follow', 'session/control', 'workspace/follow', 'userQuestions/attachWait'])
|
|
29
|
+
export const EVENTS = new Set([
|
|
30
|
+
'agent-preset/selected', 'approval/request', 'api-session/activity', 'api-session/added', 'api-session/error',
|
|
31
|
+
'api-session/removed', 'api-session/status', 'commands/change', 'goal/activation-changed',
|
|
32
|
+
'permission-presets/catalog-changed', 'user-questions/request',
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
const sha = s => createHash('sha256').update(s).digest('hex')
|
|
36
|
+
const safeEq = (a, b) => timingSafeEqual(Buffer.from(sha(a), 'hex'), Buffer.from(sha(b), 'hex'))
|
|
37
|
+
const ip4 = s => s.split('.').reduce((n, o) => (n << 8) + Number(o), 0) >>> 0
|
|
38
|
+
export const inSubnet = (ip, address, netmask) => {
|
|
39
|
+
ip = String(ip).replace(/^::ffff:/, '')
|
|
40
|
+
if (!/^\d+\.\d+\.\d+\.\d+$/.test(ip)) return false
|
|
41
|
+
const m = ip4(netmask)
|
|
42
|
+
return ((ip4(ip) & m) >>> 0) === ((ip4(address) & m) >>> 0)
|
|
43
|
+
}
|
|
44
|
+
export const normFp = s => String(s).replace(/[^0-9a-f]/gi, '').toLowerCase()
|
|
45
|
+
// Tailscale's address space: 100.64.0.0/10 (CGNAT) and fd7a:115c:a1e0::/48.
|
|
46
|
+
export const isTailnet = ip => {
|
|
47
|
+
ip = String(ip).replace(/^::ffff:/i, '').toLowerCase()
|
|
48
|
+
if (/^\d+\.\d+\.\d+\.\d+$/.test(ip)) return inSubnet(ip, '100.64.0.0', '255.192.0.0')
|
|
49
|
+
return /^fd7a:115c:a1e0:/.test(ip)
|
|
50
|
+
}
|
|
51
|
+
// Accept LAN peers that reached us on the LAN address, or tailnet peers that reached us on our tailnet address.
|
|
52
|
+
export const allowed = (remote, local, net) =>
|
|
53
|
+
(inSubnet(remote, net.address, net.netmask) && String(local).replace(/^::ffff:/i, '') === net.address) ||
|
|
54
|
+
(isTailnet(remote) && isTailnet(local))
|
|
55
|
+
const TS_BINS = ['tailscale', '/Applications/Tailscale.app/Contents/MacOS/Tailscale']
|
|
56
|
+
export function tailscaleInfo(bins = TS_BINS) {
|
|
57
|
+
return new Promise(ok => {
|
|
58
|
+
const next = i => {
|
|
59
|
+
if (i >= bins.length) return ok({ installed: false, online: false, ip: null, name: null })
|
|
60
|
+
execFile(bins[i], ['status', '--json'], { timeout: 3000, maxBuffer: 8 << 20 }, (err, out) => {
|
|
61
|
+
if (err?.code === 'ENOENT') return next(i + 1)
|
|
62
|
+
let j = null
|
|
63
|
+
try { j = JSON.parse(out) } catch {}
|
|
64
|
+
const self = j?.Self ?? {}
|
|
65
|
+
ok({
|
|
66
|
+
installed: true, online: j?.BackendState === 'Running',
|
|
67
|
+
ip: (self.TailscaleIPs ?? []).find(a => /^\d+\.\d+\.\d+\.\d+$/.test(a) && isTailnet(a)) ?? null,
|
|
68
|
+
name: self.DNSName ? String(self.DNSName).replace(/\.$/, '') : null,
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
next(0)
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function reply(res, status, obj) {
|
|
77
|
+
if (res.headersSent || res.destroyed) return
|
|
78
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' })
|
|
79
|
+
res.end(JSON.stringify(obj))
|
|
80
|
+
}
|
|
81
|
+
function readBody(req, max) {
|
|
82
|
+
return new Promise((ok, bad) => {
|
|
83
|
+
const parts = []; let n = 0
|
|
84
|
+
req.on('data', d => { n += d.length; if (n > max) { bad(new Error('body too large')); req.destroy() } else parts.push(d) })
|
|
85
|
+
req.on('end', () => ok(Buffer.concat(parts).toString()))
|
|
86
|
+
req.on('error', bad)
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---- Minimal RFC 6455 framing: text, ping/pong, close. Binary frames close with 1003. Clients mask. ----
|
|
91
|
+
export function wsWrap(socket, client, head = Buffer.alloc(0), maxBytes = MAX_BODY) {
|
|
92
|
+
let buf = head, frags = [], fragBytes = 0, closed = false
|
|
93
|
+
const w = { socket, onText() {}, onClose() {}, lastSeen: Date.now() }
|
|
94
|
+
const frame = (op, data = Buffer.alloc(0)) => {
|
|
95
|
+
if (socket.destroyed) return
|
|
96
|
+
const len = data.length, m = client ? 0x80 : 0, parts = []
|
|
97
|
+
if (len < 126) parts.push(Buffer.from([0x80 | op, m | len]))
|
|
98
|
+
else if (len < 65536) parts.push(Buffer.from([0x80 | op, m | 126, len >> 8, len & 255]))
|
|
99
|
+
else { const h = Buffer.alloc(10); h[0] = 0x80 | op; h[1] = m | 127; h.writeBigUInt64BE(BigInt(len), 2); parts.push(h) }
|
|
100
|
+
if (client) {
|
|
101
|
+
const key = randomBytes(4); data = Buffer.from(data)
|
|
102
|
+
for (let i = 0; i < len; i++) data[i] ^= key[i & 3]
|
|
103
|
+
parts.push(key)
|
|
104
|
+
}
|
|
105
|
+
parts.push(data)
|
|
106
|
+
socket.write(Buffer.concat(parts))
|
|
107
|
+
}
|
|
108
|
+
w.send = text => { if (!closed) frame(1, Buffer.from(text)) }
|
|
109
|
+
w.ping = () => { if (!closed) frame(9) }
|
|
110
|
+
w.close = (code = 1000) => {
|
|
111
|
+
if (closed) return
|
|
112
|
+
closed = true
|
|
113
|
+
const b = Buffer.alloc(2); b.writeUInt16BE(code); frame(8, b)
|
|
114
|
+
socket.end()
|
|
115
|
+
setTimeout(() => socket.destroy(), 1000).unref()
|
|
116
|
+
}
|
|
117
|
+
const parse = () => {
|
|
118
|
+
while (buf.length >= 2 && !closed) {
|
|
119
|
+
const fin = buf[0] & 0x80, op = buf[0] & 15, masked = buf[1] & 0x80
|
|
120
|
+
let len = buf[1] & 127, off = 2
|
|
121
|
+
if (len === 126) { if (buf.length < 4) return; len = buf.readUInt16BE(2); off = 4 }
|
|
122
|
+
else if (len === 127) {
|
|
123
|
+
if (buf.length < 10) return
|
|
124
|
+
const big = buf.readBigUInt64BE(2)
|
|
125
|
+
if (big > BigInt(maxBytes)) return w.close(1009)
|
|
126
|
+
len = Number(big); off = 10
|
|
127
|
+
}
|
|
128
|
+
if (len > maxBytes) return w.close(1009)
|
|
129
|
+
if (!masked === !client) return w.close(1002) // server frames unmasked, client frames masked
|
|
130
|
+
const keyOff = off
|
|
131
|
+
if (masked) off += 4
|
|
132
|
+
if (buf.length < off + len) return
|
|
133
|
+
let data = buf.subarray(off, off + len)
|
|
134
|
+
if (masked) { data = Buffer.from(data); for (let i = 0; i < len; i++) data[i] ^= buf[keyOff + (i & 3)] }
|
|
135
|
+
buf = buf.subarray(off + len)
|
|
136
|
+
if (op === 8) return w.close()
|
|
137
|
+
if (op === 9) { frame(10, data); continue }
|
|
138
|
+
if (op === 10) continue
|
|
139
|
+
if (op !== 1 && op !== 0) return w.close(1003)
|
|
140
|
+
if (op === 0 && !frags.length) return w.close(1002)
|
|
141
|
+
frags.push(data); fragBytes += len
|
|
142
|
+
if (fragBytes > maxBytes) return w.close(1009)
|
|
143
|
+
if (fin) { const text = Buffer.concat(frags).toString(); frags = []; fragBytes = 0; w.onText(text) }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
socket.on('data', d => { w.lastSeen = Date.now(); buf = buf.length ? Buffer.concat([buf, d]) : d; parse() })
|
|
147
|
+
socket.on('close', () => { closed = true; w.onClose() })
|
|
148
|
+
socket.on('error', () => {})
|
|
149
|
+
if (buf.length) queueMicrotask(parse)
|
|
150
|
+
return w
|
|
151
|
+
}
|
|
152
|
+
export function acceptWs(req, socket, head) {
|
|
153
|
+
const key = req.headers['sec-websocket-key']
|
|
154
|
+
if (String(req.headers.upgrade).toLowerCase() !== 'websocket' || !key) { socket.destroy(); return }
|
|
155
|
+
const accept = createHash('sha1').update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64')
|
|
156
|
+
socket.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ' + accept + '\r\n\r\n')
|
|
157
|
+
return wsWrap(socket, false, head)
|
|
158
|
+
}
|
|
159
|
+
export function wsConnect(url, headers = {}, tls = {}) {
|
|
160
|
+
return new Promise((ok, bad) => {
|
|
161
|
+
const u = new URL(url), secure = u.protocol === 'wss:'
|
|
162
|
+
u.protocol = secure ? 'https:' : 'http:'
|
|
163
|
+
const req = (secure ? httpsRequest : httpRequest)(u, {
|
|
164
|
+
...tls, agent: false,
|
|
165
|
+
headers: { ...headers, Connection: 'Upgrade', Upgrade: 'websocket', 'Sec-WebSocket-Version': '13', 'Sec-WebSocket-Key': randomBytes(16).toString('base64') },
|
|
166
|
+
})
|
|
167
|
+
req.on('upgrade', (res, socket, head) => ok(wsWrap(socket, true, head)))
|
|
168
|
+
req.on('response', res => { res.resume(); bad(Object.assign(new Error('websocket upgrade failed: ' + res.statusCode), { status: res.statusCode })) })
|
|
169
|
+
req.on('error', bad)
|
|
170
|
+
req.end()
|
|
171
|
+
})
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---- Upstream: the DSH host on loopback, authenticated with the URL that dsh-composer-plus (Mobile connect) gives. ----
|
|
175
|
+
function send(method, url, body, headers = {}) {
|
|
176
|
+
return new Promise((ok, bad) => {
|
|
177
|
+
const r = httpRequest(url, { method, headers, agent: false }, res => {
|
|
178
|
+
const parts = []
|
|
179
|
+
res.on('data', d => parts.push(d))
|
|
180
|
+
res.on('end', () => ok({ status: res.statusCode, headers: res.headers, body: Buffer.concat(parts) }))
|
|
181
|
+
res.on('error', bad)
|
|
182
|
+
})
|
|
183
|
+
r.on('error', bad)
|
|
184
|
+
r.end(body)
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
function makeUpstream(upstreamUrl) {
|
|
188
|
+
let base, cookie
|
|
189
|
+
async function login() {
|
|
190
|
+
const raw = String(await upstreamUrl()).trim()
|
|
191
|
+
const u = new URL(raw)
|
|
192
|
+
if (u.protocol !== 'http:' || !['127.0.0.1', 'localhost'].includes(u.hostname)) throw new Error('upstream must be loopback http')
|
|
193
|
+
const res = await send('GET', raw) // 303 + cookie; never follow the redirect
|
|
194
|
+
const c = [res.headers['set-cookie'] ?? []].flat().map(s => s.split(';')[0]).filter(s => s.startsWith('dsh-auth'))
|
|
195
|
+
if (!c.length) throw new Error('DSH login failed (' + res.status + ')')
|
|
196
|
+
base = u.origin; cookie = c.join('; ')
|
|
197
|
+
}
|
|
198
|
+
async function retry(fn) {
|
|
199
|
+
if (!cookie) await login()
|
|
200
|
+
try { return await fn() } catch (e) {
|
|
201
|
+
if (e.status !== 401 && e.status !== 403) throw e
|
|
202
|
+
await login(); return fn()
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
rpc: (path, body) => retry(async () => {
|
|
207
|
+
const r = await send('POST', base + path, body, { 'content-type': 'application/json', cookie })
|
|
208
|
+
if (r.status === 401 || r.status === 403) throw Object.assign(new Error('upstream auth'), { status: r.status })
|
|
209
|
+
return r
|
|
210
|
+
}),
|
|
211
|
+
mux: () => retry(() => wsConnect(base.replace(/^http/, 'ws') + '/api/remote.mux', { cookie })),
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ---- Gateway ----
|
|
216
|
+
export function createGateway(opts = {}) {
|
|
217
|
+
const dir = opts.dir ?? process.env.DSH_MOBILE_DIR ?? join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'storages', 'dsh-composer-plus', 'mobile')
|
|
218
|
+
const iface = opts.iface ?? process.env.DSH_MOBILE_IFACE ?? 'en0'
|
|
219
|
+
const netInfo = opts.netInfo ?? (() => {
|
|
220
|
+
const a = (networkInterfaces()[iface] ?? []).find(i => i.family === 'IPv4' && !i.internal)
|
|
221
|
+
return a && { address: a.address, netmask: a.netmask }
|
|
222
|
+
})
|
|
223
|
+
const codeTtl = opts.codeTtlMs ?? 5 * 60e3
|
|
224
|
+
const waterfallTtl = opts.waterfallTimeoutMs ?? 10 * 60e3
|
|
225
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
|
226
|
+
const upstream = makeUpstream(opts.upstreamUrl)
|
|
227
|
+
const devFile = join(dir, 'devices.json')
|
|
228
|
+
let devices = existsSync(devFile) ? JSON.parse(readFileSync(devFile, 'utf8')) : []
|
|
229
|
+
const saveDevices = () => writeFileSync(devFile, JSON.stringify(devices, null, 2), { mode: 0o600 })
|
|
230
|
+
// Tailscale status: cached 30 s, refreshed on start and on new pairing codes. Tests inject opts.tailscale.
|
|
231
|
+
let ts = { installed: false, online: false, ip: null, name: null }, tsAt = 0
|
|
232
|
+
const refreshTs = async () => {
|
|
233
|
+
tsAt = Date.now()
|
|
234
|
+
const t = opts.tailscale
|
|
235
|
+
try { ts = t === undefined ? await tailscaleInfo() : typeof t === 'function' ? await t() : t } catch {}
|
|
236
|
+
return ts
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
let server, bound, port = opts.port ?? Number(process.env.DSH_MOBILE_PORT ?? 19390), running = false, wanted = false, lastTlsError = null
|
|
240
|
+
let key, cert, fp, code = null, qrReady = false, bonjour, upState = 'disconnected', evTimer
|
|
241
|
+
const conns = new Set(), devSockets = new Map(), pending = new Map()
|
|
242
|
+
const reqHits = new Map(), fails = new Map()
|
|
243
|
+
const evSubs = new Set(), pendingEv = new Map(), ev = { ws: null, clientId: null }
|
|
244
|
+
|
|
245
|
+
// Rate limits. shortcut: fixed in-memory windows per IP, fine for one household LAN; persist/upgrade if exposed wider.
|
|
246
|
+
const bump = (map, k, ms) => { const now = Date.now(), b = map.get(k); if (!b || now - b.t > ms) { map.set(k, { t: now, n: 1 }); return 1 } return ++b.n }
|
|
247
|
+
const blocked = ip => { const b = fails.get(ip); return b && Date.now() - b.t < 60e3 && b.n >= 10 }
|
|
248
|
+
const fail = ip => bump(fails, ip, 60e3)
|
|
249
|
+
const allowReq = ip => !blocked(ip) && bump(reqHits, ip, 10e3) <= 300
|
|
250
|
+
|
|
251
|
+
function ensureCert() {
|
|
252
|
+
const k = join(dir, 'key.pem'), c = join(dir, 'cert.pem')
|
|
253
|
+
if (!existsSync(k) || !existsSync(c)) {
|
|
254
|
+
execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-sha256', '-nodes', '-keyout', k, '-out', c, '-days', '825', '-subj', '/CN=DSH Mobile Gateway'], { stdio: 'ignore' })
|
|
255
|
+
chmodSync(k, 0o600)
|
|
256
|
+
}
|
|
257
|
+
key = readFileSync(k); cert = readFileSync(c)
|
|
258
|
+
fp = normFp(new X509Certificate(cert).fingerprint256)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function auth(req) {
|
|
262
|
+
const m = /^Bearer ([A-Za-z0-9_-]{43})$/.exec(req.headers.authorization ?? '')
|
|
263
|
+
const d = m && devices.find(d => d.tokenHash === sha(m[1]))
|
|
264
|
+
if (!d || Date.parse(d.expiresAt) <= Date.now()) return undefined
|
|
265
|
+
// Sliding renewal: an actively used phone never gets force-unpaired; one idle for TOKEN_TTL still expires.
|
|
266
|
+
if (Date.parse(d.expiresAt) - Date.now() < TOKEN_TTL / 2) { d.expiresAt = new Date(Date.now() + TOKEN_TTL).toISOString(); saveDevices() }
|
|
267
|
+
return d
|
|
268
|
+
}
|
|
269
|
+
function track(id, socket) {
|
|
270
|
+
let s = devSockets.get(id)
|
|
271
|
+
if (!s) devSockets.set(id, s = new Set())
|
|
272
|
+
if (s.has(socket)) return
|
|
273
|
+
s.add(socket); socket.once('close', () => s.delete(socket))
|
|
274
|
+
}
|
|
275
|
+
function revoke(id) {
|
|
276
|
+
const before = devices.length
|
|
277
|
+
devices = devices.filter(d => d.id !== id)
|
|
278
|
+
if (devices.length !== before) saveDevices()
|
|
279
|
+
for (const s of devSockets.get(id) ?? []) s.destroy()
|
|
280
|
+
devSockets.delete(id)
|
|
281
|
+
return devices.length !== before
|
|
282
|
+
}
|
|
283
|
+
function notify(msg) {
|
|
284
|
+
if (opts.notify === false) return
|
|
285
|
+
const safe = msg.replace(/[^\w .:'-]/g, '')
|
|
286
|
+
spawn('osascript', ['-e', 'display notification "' + safe + '" with title "Tether"'], { stdio: 'ignore' }).on('error', () => {})
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---- Pairing: one-time code (5 min) shown as QR, then explicit approval on the Mac. ----
|
|
290
|
+
function newCode() {
|
|
291
|
+
const net = bound ?? netInfo()
|
|
292
|
+
code = { value: randomBytes(16).toString('base64url'), expires: Date.now() + codeTtl }
|
|
293
|
+
code.payload = JSON.stringify({ v: 1, host: net?.address, port, fp, code: code.value, ...(ts.online && ts.ip ? { ts: ts.ip } : {}) })
|
|
294
|
+
qrReady = false
|
|
295
|
+
if (opts.qr !== false) {
|
|
296
|
+
const c = code
|
|
297
|
+
const { SDKROOT, ...env } = process.env // a stale SDKROOT (e.g. Command Line Tools) breaks the swift script; let xcrun pick the SDK
|
|
298
|
+
spawn('swift', [join(HERE, 'qr.swift'), c.payload, join(dir, 'qr.png')], { stdio: 'ignore', env })
|
|
299
|
+
.on('exit', s => { if (s === 0 && code === c) qrReady = true }).on('error', () => {})
|
|
300
|
+
}
|
|
301
|
+
return code
|
|
302
|
+
}
|
|
303
|
+
async function pair(req, res, ip) {
|
|
304
|
+
let b
|
|
305
|
+
try { b = JSON.parse(await readBody(req, 4096)) } catch { return reply(res, 400, { error: 'bad request' }) }
|
|
306
|
+
const ok = code && typeof b?.code === 'string' && Date.now() < code.expires && safeEq(b.code, code.value)
|
|
307
|
+
if (!ok) { fail(ip); return reply(res, 403, { error: 'invalid or expired pairing code' }) }
|
|
308
|
+
code = null // one-time: a replayed code is rejected
|
|
309
|
+
const p = { id: randomUUID(), name: String(b.name ?? 'iPhone').replace(/[\u0000-\u001f]/g, '').slice(0, 64) || 'iPhone', ip, at: Date.now(), res }
|
|
310
|
+
pending.set(p.id, p)
|
|
311
|
+
notify('Pairing request from ' + p.name)
|
|
312
|
+
const t = setTimeout(() => settle(p.id, false), 120e3)
|
|
313
|
+
res.on('close', () => { clearTimeout(t); pending.delete(p.id) })
|
|
314
|
+
}
|
|
315
|
+
function settle(id, approve) {
|
|
316
|
+
const p = pending.get(id)
|
|
317
|
+
if (!p) return false
|
|
318
|
+
pending.delete(id)
|
|
319
|
+
if (!approve) { reply(p.res, 403, { error: 'pairing denied' }); return true }
|
|
320
|
+
const token = randomBytes(32).toString('base64url') // 256-bit; only its hash is stored
|
|
321
|
+
const d = { id: randomUUID(), name: p.name, tokenHash: sha(token), createdAt: new Date().toISOString(), expiresAt: new Date(Date.now() + TOKEN_TTL).toISOString() }
|
|
322
|
+
devices.push(d); saveDevices()
|
|
323
|
+
reply(p.res, 200, { token, deviceId: d.id, name: d.name, expiresAt: d.expiresAt })
|
|
324
|
+
return true
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---- Forwarded events: one upstream $events stream shared by all phones. ----
|
|
328
|
+
const toPhones = frame => { for (const s of evSubs) s.ws.send(JSON.stringify({ type: 'item', streamId: s.streamId, value: frame })) }
|
|
329
|
+
const answer = (eventId, outcome) => upstream.rpc('/api/$events/result', JSON.stringify({
|
|
330
|
+
type: 'client-request', rpcId: randomUUID(), method: '$events/result',
|
|
331
|
+
payload: { args: { clientId: ev.clientId, eventId, outcome } },
|
|
332
|
+
}))
|
|
333
|
+
const dropPending = (id, outcome) => {
|
|
334
|
+
const p = pendingEv.get(id)
|
|
335
|
+
if (!p) return false
|
|
336
|
+
clearTimeout(p.timer); pendingEv.delete(id)
|
|
337
|
+
if (outcome) answer(id, outcome).catch(() => {})
|
|
338
|
+
return true
|
|
339
|
+
}
|
|
340
|
+
function onEventFrame(text) {
|
|
341
|
+
let f
|
|
342
|
+
try { f = JSON.parse(text) } catch { return }
|
|
343
|
+
if (f.streamId !== 'events') return
|
|
344
|
+
if (f.type !== 'item') { ev.ws?.close(); return }
|
|
345
|
+
const v = f.value ?? {}
|
|
346
|
+
if (v.type === 'ready') { ev.clientId = v.clientId; upState = 'connected'; return } // host.home is dropped here
|
|
347
|
+
if (v.type === 'emit' && EVENTS.has(v.event)) return toPhones({ type: 'emit', event: v.event, args: v.args })
|
|
348
|
+
if (v.type === 'cancel' && dropPending(v.eventId)) return toPhones({ type: 'cancel', eventId: v.eventId })
|
|
349
|
+
if (v.type !== 'waterfall') return
|
|
350
|
+
if (!EVENTS.has(v.event) || !evSubs.size) return answer(v.eventId, { kind: 'next' }).catch(() => {})
|
|
351
|
+
const frame = { type: 'waterfall', event: v.event, eventId: v.eventId, agentId: v.agentId, request: v.request }
|
|
352
|
+
// shortcut: unanswered phone prompts fall back to 'next' (DSH desktop still decides) after a fixed timeout; make it configurable per event if users want longer.
|
|
353
|
+
const timer = setTimeout(() => { if (dropPending(v.eventId, { kind: 'next' })) toPhones({ type: 'cancel', eventId: v.eventId }) }, waterfallTtl)
|
|
354
|
+
pendingEv.set(v.eventId, { frame, timer })
|
|
355
|
+
toPhones(frame)
|
|
356
|
+
}
|
|
357
|
+
function connectEvents() {
|
|
358
|
+
if (!running) return
|
|
359
|
+
upstream.mux().then(u => {
|
|
360
|
+
if (!running) return u.close()
|
|
361
|
+
ev.ws = u
|
|
362
|
+
u.onText = onEventFrame
|
|
363
|
+
u.onClose = () => {
|
|
364
|
+
ev.ws = null; ev.clientId = null; upState = 'disconnected'
|
|
365
|
+
for (const id of [...pendingEv.keys()]) { dropPending(id); toPhones({ type: 'cancel', eventId: id }) }
|
|
366
|
+
if (running) evTimer = setTimeout(connectEvents, 3000)
|
|
367
|
+
}
|
|
368
|
+
u.send(JSON.stringify({ type: 'open', streamId: 'events', endpoint: '$events', payload: { args: {} } }))
|
|
369
|
+
}, e => {
|
|
370
|
+
upState = 'unavailable: ' + e.message
|
|
371
|
+
if (running) evTimer = setTimeout(connectEvents, 3000)
|
|
372
|
+
})
|
|
373
|
+
}
|
|
374
|
+
async function eventResult(body, res) {
|
|
375
|
+
const a = body.payload?.args, o = a?.outcome
|
|
376
|
+
const outcome = o?.kind === 'next' ? { kind: 'next' }
|
|
377
|
+
: o?.kind === 'result' ? (Object.hasOwn(o, 'value') ? { kind: 'result', value: o.value } : { kind: 'result' })
|
|
378
|
+
: o?.kind === 'rejected' ? { kind: 'rejected', error: o.error } : null
|
|
379
|
+
if (!outcome || typeof a?.eventId !== 'string' || !dropPending(a.eventId)) {
|
|
380
|
+
return reply(res, 200, { type: 'server-response', rpcId: body.rpcId, result: { ok: false, error: { code: 'mobile/event-not-pending', message: 'event is not pending' } } })
|
|
381
|
+
}
|
|
382
|
+
toPhones({ type: 'cancel', eventId: a.eventId }) // first answer wins; other phones drop the prompt
|
|
383
|
+
try {
|
|
384
|
+
const up = await answer(a.eventId, outcome)
|
|
385
|
+
res.writeHead(up.status, { 'content-type': up.headers['content-type'] ?? 'application/json' }); res.end(up.body)
|
|
386
|
+
} catch { reply(res, 502, { error: 'DSH host unavailable' }) }
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ---- Phone-facing HTTPS ----
|
|
390
|
+
async function onRequest(req, res) {
|
|
391
|
+
const ip = req.socket.remoteAddress
|
|
392
|
+
if (!allowReq(ip)) return reply(res, 429, { error: 'rate limited' })
|
|
393
|
+
const path = new URL(req.url, 'https://x').pathname
|
|
394
|
+
if (req.method === 'POST' && path === '/pair') return pair(req, res, ip)
|
|
395
|
+
const dev = auth(req)
|
|
396
|
+
if (!dev) { fail(ip); return reply(res, 401, { error: 'unauthorized' }) }
|
|
397
|
+
track(dev.id, req.socket)
|
|
398
|
+
if (req.method === 'GET' && path === '/me') return reply(res, 200, { deviceId: dev.id, name: dev.name, expiresAt: dev.expiresAt, upstream: upState, tailscale: ts.online ? ts.ip : null })
|
|
399
|
+
if (req.method !== 'POST' || !path.startsWith('/api/')) return reply(res, 404, { error: 'not found' })
|
|
400
|
+
const method = path.slice(5)
|
|
401
|
+
let body
|
|
402
|
+
try { body = JSON.parse(await readBody(req, MAX_BODY)) } catch { return reply(res, 400, { error: 'bad request' }) }
|
|
403
|
+
if (body?.type !== 'client-request' || body.method !== method || typeof body.rpcId !== 'string') return reply(res, 400, { error: 'bad request' })
|
|
404
|
+
if (method === '$events/result') return eventResult(body, res)
|
|
405
|
+
if (!RPC.has(method)) return reply(res, 403, { error: 'endpoint not allowed' })
|
|
406
|
+
try {
|
|
407
|
+
const up = await upstream.rpc('/api/' + method, JSON.stringify(body))
|
|
408
|
+
res.writeHead(up.status, { 'content-type': up.headers['content-type'] ?? 'application/json' }); res.end(up.body)
|
|
409
|
+
} catch { reply(res, 502, { error: 'DSH host unavailable' }) }
|
|
410
|
+
}
|
|
411
|
+
function onUpgrade(req, socket, head) {
|
|
412
|
+
const ip = socket.remoteAddress
|
|
413
|
+
if (!allowReq(ip)) return socket.end('HTTP/1.1 429 Too Many Requests\r\nConnection: close\r\n\r\n')
|
|
414
|
+
if (new URL(req.url, 'https://x').pathname !== '/api/remote.mux') return socket.end('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n')
|
|
415
|
+
const dev = auth(req)
|
|
416
|
+
if (!dev) { fail(ip); return socket.end('HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n') }
|
|
417
|
+
const ws = acceptWs(req, socket, head)
|
|
418
|
+
if (!ws) return
|
|
419
|
+
track(dev.id, socket)
|
|
420
|
+
phoneMux(ws)
|
|
421
|
+
}
|
|
422
|
+
const isId = v => typeof v === 'string' && v.length > 0 && v.length <= 128
|
|
423
|
+
const keys = (f, ...k) => { const o = Object.keys(f); return o.length === k.length && k.every(x => Object.hasOwn(f, x)) }
|
|
424
|
+
const validFrame = f => f && typeof f === 'object' && isId(f.streamId) && (
|
|
425
|
+
(f.type === 'open' && keys(f, 'type', 'streamId', 'endpoint', 'payload') && typeof f.endpoint === 'string') ||
|
|
426
|
+
(f.type === 'item' && (keys(f, 'type', 'streamId') || keys(f, 'type', 'streamId', 'value'))) ||
|
|
427
|
+
((f.type === 'end' || f.type === 'cancel') && keys(f, 'type', 'streamId')))
|
|
428
|
+
function phoneMux(ws) {
|
|
429
|
+
const mine = new Set(), subs = new Map()
|
|
430
|
+
let upP, up
|
|
431
|
+
// A failed connect (DSH restarting) is forgotten so the next stream open retries instead of failing forever.
|
|
432
|
+
const getUp = () => upP ??= upstream.mux().then(u => { up = u; u.onText = t => ws.send(t); u.onClose = () => ws.close(1011); return u }, e => { upP = null; throw e })
|
|
433
|
+
const err = (streamId, message) => ws.send(JSON.stringify({ type: 'error', streamId, error: { code: 'mobile/forbidden', message } }))
|
|
434
|
+
const unsub = id => { const s = subs.get(id); if (!s) return; subs.delete(id); evSubs.delete(s); if (!evSubs.size) for (const e of [...pendingEv.keys()]) dropPending(e, { kind: 'next' }) }
|
|
435
|
+
ws.onText = async text => {
|
|
436
|
+
let f
|
|
437
|
+
try { f = JSON.parse(text) } catch { return ws.close(1007) }
|
|
438
|
+
if (!validFrame(f)) return ws.close(1008)
|
|
439
|
+
if (f.type === 'open') {
|
|
440
|
+
if (mine.has(f.streamId)) return ws.close(1008)
|
|
441
|
+
if (mine.size >= 64) return err(f.streamId, 'too many streams')
|
|
442
|
+
if (f.endpoint === '$events') {
|
|
443
|
+
mine.add(f.streamId)
|
|
444
|
+
const s = { ws, streamId: f.streamId }
|
|
445
|
+
subs.set(f.streamId, s); evSubs.add(s)
|
|
446
|
+
ws.send(JSON.stringify({ type: 'item', streamId: f.streamId, value: { type: 'ready', clientId: 'mobile' } }))
|
|
447
|
+
for (const p of pendingEv.values()) ws.send(JSON.stringify({ type: 'item', streamId: f.streamId, value: p.frame }))
|
|
448
|
+
return
|
|
449
|
+
}
|
|
450
|
+
if (!STREAMS.has(f.endpoint)) return err(f.streamId, 'endpoint not allowed')
|
|
451
|
+
mine.add(f.streamId)
|
|
452
|
+
} else if (!mine.has(f.streamId)) return
|
|
453
|
+
else if (subs.has(f.streamId)) { if (f.type !== 'item') { unsub(f.streamId); mine.delete(f.streamId) } return }
|
|
454
|
+
try { (await getUp()).send(text) } catch { err(f.streamId, 'DSH host unavailable') }
|
|
455
|
+
}
|
|
456
|
+
ws.onClose = () => { for (const id of [...subs.keys()]) unsub(id); up?.close(); if (!up) upP?.then(u => u.close(), () => {}) }
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function start() {
|
|
460
|
+
wanted = true
|
|
461
|
+
if (running) return
|
|
462
|
+
const net = netInfo()
|
|
463
|
+
// shortcut: Mac'te Wi-Fi/LAN IPv4 yoksa gateway başlamaz, sadece-Tailscale Mac'ler gerekirse kaldır
|
|
464
|
+
if (!net) throw new Error('no IPv4 address on ' + iface + ' (Wi-Fi off?)')
|
|
465
|
+
ensureCert()
|
|
466
|
+
await refreshTs()
|
|
467
|
+
const s = createHttps({ key, cert, minVersion: 'TLSv1.2' }, onRequest)
|
|
468
|
+
s.on('connection', sock => {
|
|
469
|
+
if (!allowed(sock.remoteAddress, sock.localAddress, net)) return sock.destroy() // LAN or tailnet only, decided by the socket's own addresses
|
|
470
|
+
conns.add(sock); sock.once('close', () => conns.delete(sock))
|
|
471
|
+
})
|
|
472
|
+
s.on('upgrade', onUpgrade)
|
|
473
|
+
// Kept for diagnosis only (no secrets): why a phone's TLS handshake died, shown in admin /state.
|
|
474
|
+
s.on('tlsClientError', (e, sock) => { lastTlsError = { ip: sock.remoteAddress ?? null, error: String(e?.code ?? e?.message ?? e), at: new Date().toISOString() }; sock.destroy() })
|
|
475
|
+
await new Promise((ok, bad) => { s.once('error', bad); s.listen(port, net.bind ?? '::', ok) })
|
|
476
|
+
server = s; bound = net; port = s.address().port; running = true
|
|
477
|
+
if (opts.bonjour !== false) {
|
|
478
|
+
bonjour = spawn('dns-sd', ['-R', 'Tether', '_dshmobile._tcp', 'local', String(port), 'fp=' + fp], { stdio: 'ignore' })
|
|
479
|
+
bonjour.on('error', () => {})
|
|
480
|
+
}
|
|
481
|
+
connectEvents()
|
|
482
|
+
}
|
|
483
|
+
async function stop(keepWanted = false) {
|
|
484
|
+
wanted = keepWanted
|
|
485
|
+
if (!running) return
|
|
486
|
+
running = false
|
|
487
|
+
clearTimeout(evTimer)
|
|
488
|
+
bonjour?.kill(); bonjour = null
|
|
489
|
+
ev.ws?.close()
|
|
490
|
+
for (const sock of conns) sock.destroy()
|
|
491
|
+
await new Promise(ok => server.close(ok))
|
|
492
|
+
server = null; bound = null; upState = 'disconnected'
|
|
493
|
+
}
|
|
494
|
+
// Network watch: rebind (and drop every connection) whenever the Wi-Fi address changes; also expire tokens.
|
|
495
|
+
const watch = setInterval(async () => {
|
|
496
|
+
for (const d of devices) if (Date.parse(d.expiresAt) <= Date.now()) revoke(d.id)
|
|
497
|
+
const now = Date.now()
|
|
498
|
+
for (const m of [reqHits, fails]) for (const [k, b] of m) if (now - b.t > 60e3) m.delete(k)
|
|
499
|
+
for (const set of devSockets.values()) for (const s of set) if (s.destroyed) set.delete(s)
|
|
500
|
+
if (now - tsAt > 30e3) refreshTs()
|
|
501
|
+
const n = netInfo()
|
|
502
|
+
if (running && (!n || n.address !== bound.address || n.netmask !== bound.netmask)) await stop(true).catch(() => {})
|
|
503
|
+
if (wanted && !running && n) await start().catch(() => {})
|
|
504
|
+
}, opts.watchMs ?? 5000)
|
|
505
|
+
watch.unref()
|
|
506
|
+
|
|
507
|
+
const state = () => ({
|
|
508
|
+
running, address: bound?.address ?? netInfo()?.address ?? null, port, tls: running ? 'TLS 1.2+, self-signed' : 'off',
|
|
509
|
+
fingerprint: fp ?? null, upstream: upState, tailscale: ts, lastTlsError,
|
|
510
|
+
code: code && Date.now() < code.expires ? { payload: code.payload, expiresAt: new Date(code.expires).toISOString(), qr: qrReady } : null,
|
|
511
|
+
pending: [...pending.values()].map(({ id, name, ip, at }) => ({ id, name, ip, at })),
|
|
512
|
+
devices: devices.map(({ tokenHash, ...d }) => ({ ...d, connections: devSockets.get(d.id)?.size ?? 0 })),
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
// ---- Mac admin page: loopback only, plain HTTP. ----
|
|
516
|
+
let adminServer
|
|
517
|
+
async function onAdmin(req, res) {
|
|
518
|
+
const a = adminServer.address().port
|
|
519
|
+
if (req.headers.host !== '127.0.0.1:' + a && req.headers.host !== 'localhost:' + a) return reply(res, 403, { error: 'bad host' }) // DNS-rebinding guard
|
|
520
|
+
const path = new URL(req.url, 'http://x').pathname
|
|
521
|
+
if (req.method === 'GET' && path === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(readFileSync(join(HERE, 'admin.html'))) }
|
|
522
|
+
if (req.method === 'GET' && path === '/state') return reply(res, 200, state())
|
|
523
|
+
if (req.method === 'GET' && path === '/qr.png' && qrReady) { res.writeHead(200, { 'content-type': 'image/png', 'cache-control': 'no-store' }); return res.end(readFileSync(join(dir, 'qr.png'))) }
|
|
524
|
+
// Custom header forces a CORS preflight we never grant, so other web pages cannot drive these.
|
|
525
|
+
if (req.method !== 'POST' || req.headers['x-dsh-admin'] !== '1') return reply(res, 404, { error: 'not found' })
|
|
526
|
+
let b = {}
|
|
527
|
+
try { b = JSON.parse((await readBody(req, 4096)) || '{}') } catch { return reply(res, 400, { error: 'bad request' }) }
|
|
528
|
+
try {
|
|
529
|
+
if (path === '/pair') { if (!running) await start(); else await refreshTs(); newCode() }
|
|
530
|
+
else if (path === '/approve') settle(b.id, true)
|
|
531
|
+
else if (path === '/deny') settle(b.id, false)
|
|
532
|
+
else if (path === '/revoke') revoke(b.id)
|
|
533
|
+
else if (path === '/start') await start()
|
|
534
|
+
else if (path === '/stop') await stop()
|
|
535
|
+
else return reply(res, 404, { error: 'not found' })
|
|
536
|
+
reply(res, 200, state())
|
|
537
|
+
} catch (e) { reply(res, 500, { error: e.message }) }
|
|
538
|
+
}
|
|
539
|
+
function listenAdmin(p = opts.adminPort ?? Number(process.env.DSH_MOBILE_ADMIN_PORT ?? 19391)) {
|
|
540
|
+
adminServer = createHttp(onAdmin)
|
|
541
|
+
return new Promise((ok, bad) => { adminServer.once('error', bad); adminServer.listen(p, '127.0.0.1', () => ok(adminServer.address().port)) })
|
|
542
|
+
}
|
|
543
|
+
async function close() { clearInterval(watch); await stop(); for (const p of pending.values()) p.res.destroy(); adminServer?.close(); adminServer?.closeAllConnections() }
|
|
544
|
+
|
|
545
|
+
return { start, stop, close, state, newCode, settle, revoke, listenAdmin, get port() { return port }, get fingerprint() { return fp } }
|
|
546
|
+
}
|
|
547
|
+
|