dsh-composer-plus 1.4.0 → 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.
@@ -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
+