dsh-composer-plus 1.21.0 → 1.22.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.de.md +6 -5
- package/README.es.md +6 -5
- package/README.fr.md +6 -5
- package/README.hi.md +6 -5
- package/README.id.md +6 -5
- package/README.ja.md +6 -5
- package/README.ko.md +6 -5
- package/README.md +6 -5
- package/README.pt-BR.md +6 -5
- package/README.ru.md +6 -5
- package/README.tr.md +6 -5
- package/README.vi.md +6 -5
- package/README.zh.md +6 -5
- package/client.js +107 -40
- package/index.js +70 -8
- package/package.json +2 -2
- package/release-notes.json +14 -14
- package/tether/gateway/server.mjs +129 -8
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
import { createServer as createHttps, request as httpsRequest } from 'node:https'
|
|
5
5
|
import { Agent as HttpAgent, createServer as createHttp, request as httpRequest } from 'node:http'
|
|
6
6
|
import { connect as tlsConnect } from 'node:tls'
|
|
7
|
-
import {
|
|
7
|
+
import { connect as h2Connect } from 'node:http2'
|
|
8
|
+
import { createHash, createPrivateKey, randomBytes, randomUUID, sign as cryptoSign, timingSafeEqual, X509Certificate } from 'node:crypto'
|
|
8
9
|
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
9
10
|
import { execFile, execFileSync, spawn } from 'node:child_process'
|
|
10
11
|
import { homedir, hostname, networkInterfaces } from 'node:os'
|
|
@@ -389,6 +390,79 @@ export function createRelay({ host, port, fp, token, ticketTtlMs = 60e3, onUnaut
|
|
|
389
390
|
}
|
|
390
391
|
}
|
|
391
392
|
|
|
393
|
+
// ---- Push (APNs): Tether's notifications also while it is closed. ----
|
|
394
|
+
/** An APNs provider token: a JWT (ES256) of the `.p8` key `key`, its `keyId` and the `teamId`, issued at `iat` (seconds). */
|
|
395
|
+
export function apnsJwt({ key, keyId, teamId }, iat = Math.floor(Date.now() / 1000)) {
|
|
396
|
+
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url')
|
|
397
|
+
const data = b64({ alg: 'ES256', kid: keyId }) + '.' + b64({ iss: teamId, iat })
|
|
398
|
+
return data + '.' + cryptoSign('sha256', Buffer.from(data), { key, dsaEncoding: 'ieee-p1363' }).toString('base64url')
|
|
399
|
+
}
|
|
400
|
+
/** Inside Tether's "Only between these hours" (`{from, to, tz}`, minutes after midnight in the phone's time zone) at `now`,
|
|
401
|
+
* with Tether's rule: To before From goes past midnight, the same time for both is all day. No hours: every hour. */
|
|
402
|
+
export function inHours(hours, now = new Date()) {
|
|
403
|
+
if (!hours || hours.from === hours.to) return true
|
|
404
|
+
let t
|
|
405
|
+
try {
|
|
406
|
+
const [h, m] = new Intl.DateTimeFormat('en-GB', { timeZone: hours.tz, hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).format(now).split(':').map(Number)
|
|
407
|
+
t = h * 60 + m
|
|
408
|
+
} catch { return true } // an unknown time zone: every hour, as with no hours
|
|
409
|
+
return hours.from < hours.to ? t >= hours.from && t < hours.to : t >= hours.from || t < hours.to
|
|
410
|
+
}
|
|
411
|
+
/** Sends alerts to APNs over HTTP/2 with a provider token. `cfg`: `{ key, keyId, teamId, topic }` (`key` a private key
|
|
412
|
+
* object). `send(token, sandbox, payload)` answers `{ status, reason }` (200: APNs took it). `origin` replaces Apple's
|
|
413
|
+
* server (tests). */
|
|
414
|
+
export function apnsSender(cfg, { origin } = {}) {
|
|
415
|
+
const sessions = new Map()
|
|
416
|
+
let jwt, jwtAt = 0
|
|
417
|
+
const session = (sandbox) => {
|
|
418
|
+
const o = origin ?? (sandbox ? 'https://api.sandbox.push.apple.com' : 'https://api.push.apple.com')
|
|
419
|
+
let s = sessions.get(o)
|
|
420
|
+
if (!s || s.closed || s.destroyed) {
|
|
421
|
+
s = h2Connect(o)
|
|
422
|
+
s.unref()
|
|
423
|
+
s.on('error', () => {}).on('goaway', () => s.close()).on('close', () => { if (sessions.get(o) === s) sessions.delete(o) })
|
|
424
|
+
sessions.set(o, s)
|
|
425
|
+
}
|
|
426
|
+
return s
|
|
427
|
+
}
|
|
428
|
+
const send = (token, sandbox, payload) => new Promise((done) => {
|
|
429
|
+
const now = Date.now()
|
|
430
|
+
// APNs refuses a token older than an hour, and one that changes more often than every 20 minutes.
|
|
431
|
+
if (!jwt || now - jwtAt > 50 * 60e3) { jwt = apnsJwt(cfg); jwtAt = now }
|
|
432
|
+
let req
|
|
433
|
+
try {
|
|
434
|
+
req = session(sandbox).request({ ':method': 'POST', ':path': '/3/device/' + token, authorization: 'bearer ' + jwt,
|
|
435
|
+
'apns-topic': cfg.topic, 'apns-push-type': 'alert', 'apns-priority': '10',
|
|
436
|
+
// APNs keeps it for an hour while the iPhone is off or has no network.
|
|
437
|
+
'apns-expiration': String(Math.floor(now / 1000) + 3600), 'content-type': 'application/json' })
|
|
438
|
+
} catch (e) { return done({ status: 0, reason: e.message }) }
|
|
439
|
+
let status = 0, body = ''
|
|
440
|
+
const timer = setTimeout(() => req.close(), 10e3)
|
|
441
|
+
timer.unref()
|
|
442
|
+
req.setEncoding('utf8')
|
|
443
|
+
req.on('response', (h) => { status = h[':status'] }).on('data', (d) => { if (body.length < 4096) body += d }).on('error', () => {})
|
|
444
|
+
req.on('close', () => {
|
|
445
|
+
clearTimeout(timer)
|
|
446
|
+
let reason
|
|
447
|
+
try { reason = JSON.parse(body).reason } catch {}
|
|
448
|
+
if (reason === 'ExpiredProviderToken') jwt = undefined
|
|
449
|
+
done({ status, reason })
|
|
450
|
+
})
|
|
451
|
+
req.end(JSON.stringify(payload))
|
|
452
|
+
})
|
|
453
|
+
return { send, close: () => { for (const s of sessions.values()) s.close(); sessions.clear() } }
|
|
454
|
+
}
|
|
455
|
+
/** The APNs settings of this Mac: `apns.json` in the gateway folder, `{ keyFile, keyId, teamId, topic }` (the `.p8` key
|
|
456
|
+
* that Apple gives). No file: no push. A wrong file: no push, and `error` says why. */
|
|
457
|
+
function loadApns(file) {
|
|
458
|
+
if (!existsSync(file)) return {}
|
|
459
|
+
try {
|
|
460
|
+
const c = JSON.parse(readFileSync(file, 'utf8'))
|
|
461
|
+
if (typeof c?.keyFile !== 'string' || !/^[A-Z0-9]{10}$/.test(c.keyId) || !/^[A-Z0-9]{10}$/.test(c.teamId) || !/^[\w.-]{1,200}$/.test(c.topic)) throw new Error('needs keyFile, keyId, teamId and topic')
|
|
462
|
+
return { push: apnsSender({ key: createPrivateKey(readFileSync(c.keyFile)), keyId: c.keyId, teamId: c.teamId, topic: c.topic }) }
|
|
463
|
+
} catch (e) { return { error: 'apns.json: ' + e.message } }
|
|
464
|
+
}
|
|
465
|
+
|
|
392
466
|
// ---- Upstream: the DSH host on loopback, authenticated with the URL that dsh-composer-plus (Mobile connect) gives. ----
|
|
393
467
|
function send(method, url, body, headers = {}, signal) {
|
|
394
468
|
return new Promise((ok, bad) => {
|
|
@@ -496,6 +570,43 @@ export function createGateway(opts = {}) {
|
|
|
496
570
|
const conns = new Set(), devSockets = new Map(), pending = new Map()
|
|
497
571
|
const reqHits = new Map(), fails = new Map()
|
|
498
572
|
const evSubs = new Set(), pendingEv = new Map(), ev = { ws: null, clientId: null }
|
|
573
|
+
// Push (APNs): Tether's notifications also while it is closed. Each phone sends its device token and the notification
|
|
574
|
+
// settings of this Mac (`POST /push`); they stay in devices.json with the phone's pairing, so unpairing ends them.
|
|
575
|
+
// A Remote computers front door has no phones.
|
|
576
|
+
const apns = computer ? {} : opts.apns ? { push: apnsSender(opts.apns, { origin: opts.apns.origin }) } : loadApns(join(dir, 'apns.json'))
|
|
577
|
+
const pushTo = () => apns.push ? devices.filter(d => d.push?.on === true && typeof d.push.token === 'string') : []
|
|
578
|
+
/** One alert to each phone that wants this Mac's notifications now (switched on, inside its hours). The subtitle names
|
|
579
|
+
* this Mac and the chat's project; the body is the chat's title, then `detail` (the reply, tool, question or error).
|
|
580
|
+
* `opts.about(sessionId)` gives `{ chat, project }` (the Host half); without it the subtitle is the Mac name only. */
|
|
581
|
+
function pushAll(title, detail, sessionId) {
|
|
582
|
+
const to = pushTo().filter(d => inHours(d.push.hours))
|
|
583
|
+
if (to.length === 0) return
|
|
584
|
+
let about = {}
|
|
585
|
+
try { if (typeof sessionId === 'string') about = opts.about?.(sessionId) ?? {} } catch {}
|
|
586
|
+
const some = s => typeof s === 'string' && s.trim() !== ''
|
|
587
|
+
// ponytail: 160 + 600 characters keep the payload under APNs' 4 KB also for 3-byte (CJK) text.
|
|
588
|
+
const cut = (s, n) => [...s].slice(0, n).join('') // by code point: an emoji is not split in half
|
|
589
|
+
const subtitle = cut([mac, about.project].filter(some).join(' · '), 160)
|
|
590
|
+
const body = cut([about.chat, detail].filter(some).join('\n'), 600)
|
|
591
|
+
for (const d of to) {
|
|
592
|
+
const token = d.push.token
|
|
593
|
+
apns.push.send(token, d.push.sandbox === true, { aps: { alert: { title, subtitle, body }, sound: 'default' } }).then(r => {
|
|
594
|
+
// The app left the iPhone (or the token is not for this app): forget the token; the phone sends a new one.
|
|
595
|
+
if ((r.status === 410 || r.reason === 'BadDeviceToken') && d.push?.token === token) { delete d.push.token; saveDevices() }
|
|
596
|
+
}, () => {})
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
/** `POST /push` from a paired phone: `{ token, sandbox, on, hours: {from, to, tz} | null }`. */
|
|
600
|
+
async function setPush(req, res, dev) {
|
|
601
|
+
let b
|
|
602
|
+
try { b = JSON.parse(await readBody(req, 4096)) } catch { return reply(res, 400, { error: 'bad request' }) }
|
|
603
|
+
const mins = v => Number.isInteger(v) && v >= 0 && v < 1440
|
|
604
|
+
const hours = b?.hours === null ? null : mins(b?.hours?.from) && mins(b.hours.to) && typeof b.hours.tz === 'string' && b.hours.tz.length <= 64 ? { from: b.hours.from, to: b.hours.to, tz: b.hours.tz } : undefined
|
|
605
|
+
if (typeof b?.on !== 'boolean' || (b.on && (!/^[0-9a-f]{64,200}$/i.test(b.token ?? '') || typeof b.sandbox !== 'boolean' || hours === undefined))) return reply(res, 400, { error: 'bad request' })
|
|
606
|
+
dev.push = b.on ? { on: true, token: b.token.toLowerCase(), sandbox: b.sandbox, hours } : { on: false }
|
|
607
|
+
saveDevices()
|
|
608
|
+
reply(res, 200, { push: b.on })
|
|
609
|
+
}
|
|
499
610
|
|
|
500
611
|
// Rate limits. shortcut: fixed in-memory windows per IP, fine for one household LAN; persist/upgrade if exposed wider.
|
|
501
612
|
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 }
|
|
@@ -619,14 +730,18 @@ export function createGateway(opts = {}) {
|
|
|
619
730
|
if (f.type !== 'item') { ev.ws?.close(); return }
|
|
620
731
|
const v = f.value ?? {}
|
|
621
732
|
if (v.type === 'ready') { ev.clientId = v.clientId; upState = 'connected'; return } // host.home is dropped here
|
|
733
|
+
if (v.type === 'emit' && v.event === 'api-session/error' && typeof v.args?.[1] === 'string' && v.args[1]) pushAll('DSH error', v.args[1], v.args[0])
|
|
622
734
|
if (v.type === 'emit' && EVENTS.has(v.event)) return toPhones({ type: 'emit', event: v.event, args: v.args })
|
|
623
735
|
if (v.type === 'cancel') looking.delete(v.eventId)
|
|
624
736
|
if (v.type === 'cancel' && dropPending(v.eventId)) return toPhones({ type: 'cancel', eventId: v.eventId })
|
|
625
737
|
if (v.type !== 'waterfall') return
|
|
626
738
|
if (v.event === 'approval/request') return grantedOrAsk(v)
|
|
627
|
-
if (!EVENTS.has(v.event) || !
|
|
739
|
+
if (!EVENTS.has(v.event) || !holds()) return answer(v.eventId, { kind: 'next' }).catch(() => {})
|
|
628
740
|
askPhones(v)
|
|
629
741
|
}
|
|
742
|
+
/** The gateway keeps an ask for the phones while one is connected, or while a phone gets pushes: the push opens Tether,
|
|
743
|
+
* and Tether gets the ask when it connects. dsh's desktop can answer it at the same time. */
|
|
744
|
+
const holds = () => evSubs.size > 0 || pushTo().length > 0
|
|
630
745
|
/** Allow for this chat, also with no dsh window open: the gateway allows a granted tool itself (the grants that the
|
|
631
746
|
* desktop and the phone save in dsh-composer-plus). Any other approval goes to the phones. */
|
|
632
747
|
async function grantedOrAsk(v) {
|
|
@@ -635,11 +750,13 @@ export function createGateway(opts = {}) {
|
|
|
635
750
|
try { const r = await upstream.rpc('/api/composer-plus.grants', undefined, 'GET'); if (r.status === 200) grants = JSON.parse(r.body) } catch {}
|
|
636
751
|
if (!looking.delete(v.eventId)) return // answered elsewhere (the desktop) while the grants were read
|
|
637
752
|
if (isGrantedAsk(v, grants)) return answer(v.eventId, { kind: 'result', value: 'allowed-once' }).catch(() => {})
|
|
638
|
-
if (!
|
|
753
|
+
if (!holds()) return answer(v.eventId, { kind: 'next' }).catch(() => {})
|
|
639
754
|
askPhones(v)
|
|
640
755
|
}
|
|
641
756
|
function askPhones(v) {
|
|
642
757
|
const frame = { type: 'waterfall', event: v.event, eventId: v.eventId, agentId: v.agentId, request: v.request }
|
|
758
|
+
if (v.event === 'approval/request') pushAll('Approval needed', v.request?.toolName, v.agentId)
|
|
759
|
+
else pushAll('DSH has a question', v.request?.questions?.[0]?.question, v.agentId)
|
|
643
760
|
// 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.
|
|
644
761
|
const timer = setTimeout(() => { if (dropPending(v.eventId, { kind: 'next' })) toPhones({ type: 'cancel', eventId: v.eventId }) }, waterfallTtl)
|
|
645
762
|
pendingEv.set(v.eventId, { frame, timer })
|
|
@@ -687,7 +804,9 @@ export function createGateway(opts = {}) {
|
|
|
687
804
|
if (!dev) { fail(ip); logAttempt(ip, null, 'refused', 'token'); return reply(res, 401, { error: 'unauthorized' }) }
|
|
688
805
|
track(dev, req.socket)
|
|
689
806
|
if (computer) return toDsh(req, res)
|
|
690
|
-
|
|
807
|
+
// `push`: this Mac can send Tether's notifications through APNs (it has an APNs key); an older gateway leaves it out.
|
|
808
|
+
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, mac, plus: [...PLUS.keys()], push: apns.push !== undefined })
|
|
809
|
+
if (req.method === 'POST' && path === '/push') return setPush(req, res, dev)
|
|
691
810
|
if (path.startsWith('/plus/')) return plus(req, res, path.slice(6))
|
|
692
811
|
if (req.method !== 'POST' || !path.startsWith('/api/')) return reply(res, 404, { error: 'not found' })
|
|
693
812
|
const method = path.slice(5)
|
|
@@ -773,7 +892,7 @@ export function createGateway(opts = {}) {
|
|
|
773
892
|
// A failed connect (DSH restarting) is forgotten so the next stream open retries instead of failing forever.
|
|
774
893
|
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 })
|
|
775
894
|
const err = (streamId, message) => ws.send(JSON.stringify({ type: 'error', streamId, error: { code: 'mobile/forbidden', message } }))
|
|
776
|
-
const unsub = id => { const s = subs.get(id); if (!s) return; subs.delete(id); evSubs.delete(s); if (!
|
|
895
|
+
const unsub = id => { const s = subs.get(id); if (!s) return; subs.delete(id); evSubs.delete(s); if (!holds()) for (const e of [...pendingEv.keys()]) dropPending(e, { kind: 'next' }) }
|
|
777
896
|
ws.onText = async text => {
|
|
778
897
|
let f
|
|
779
898
|
try { f = JSON.parse(text) } catch { return ws.close(1007) }
|
|
@@ -878,7 +997,9 @@ export function createGateway(opts = {}) {
|
|
|
878
997
|
code: code && Date.now() < code.expires ? { payload: code.payload, expiresAt: new Date(code.expires).toISOString(), qr: qrReady } : null,
|
|
879
998
|
pending: [...pending.values()].map(({ id, name, ip, at }) => ({ id, name, ip, at, expiresAt: new Date(at + approveTtl).toISOString() })),
|
|
880
999
|
last: last && Date.now() - last.at < 60e3 ? { ...last, at: new Date(last.at).toISOString() } : null,
|
|
881
|
-
|
|
1000
|
+
// The APNs device token stays here: the Mac page shows only whether the phone gets pushes.
|
|
1001
|
+
devices: devices.map(({ tokenHash, push, ...d }) => ({ ...d, push: push?.on === true && typeof push.token === 'string', connections: devSockets.get(d.id)?.size ?? 0 })),
|
|
1002
|
+
...(apns.error ? { pushError: apns.error } : {}),
|
|
882
1003
|
})
|
|
883
1004
|
|
|
884
1005
|
// ---- Mac admin page: loopback only, plain HTTP. ----
|
|
@@ -919,7 +1040,7 @@ export function createGateway(opts = {}) {
|
|
|
919
1040
|
return new Promise((ok, bad) => { adminServer.once('error', bad); adminServer.listen(p, '127.0.0.1', () => ok(adminServer.address().port)) })
|
|
920
1041
|
}
|
|
921
1042
|
async function close() {
|
|
922
|
-
clearInterval(watch); await stop(); for (const p of pending.values()) p.res.destroy(); adminServer?.close(); adminServer?.closeAllConnections()
|
|
1043
|
+
clearInterval(watch); await stop(); for (const p of pending.values()) p.res.destroy(); adminServer?.close(); adminServer?.closeAllConnections(); apns.push?.close()
|
|
923
1044
|
if (logTimer) flushLog()
|
|
924
1045
|
if (seenDirty) try { saveDevices() } catch {}
|
|
925
1046
|
}
|
|
@@ -927,6 +1048,6 @@ export function createGateway(opts = {}) {
|
|
|
927
1048
|
/** An event from dsh-composer-plus itself (not from dsh's `$events`), for every connected phone. */
|
|
928
1049
|
const emit = (event, args) => toPhones({ type: 'emit', event, args })
|
|
929
1050
|
|
|
930
|
-
return { start, stop, close, state, newCode, settle, revoke, listenAdmin, emit, get port() { return port }, get fingerprint() { return fp } }
|
|
1051
|
+
return { start, stop, close, state, newCode, settle, revoke, listenAdmin, emit, push: pushAll, get port() { return port }, get fingerprint() { return fp } }
|
|
931
1052
|
}
|
|
932
1053
|
|