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.
@@ -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 { createHash, randomBytes, randomUUID, timingSafeEqual, X509Certificate } from 'node:crypto'
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) || !evSubs.size) return answer(v.eventId, { kind: 'next' }).catch(() => {})
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 (!evSubs.size) return answer(v.eventId, { kind: 'next' }).catch(() => {})
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
- 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()] })
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 (!evSubs.size) for (const e of [...pendingEv.keys()]) dropPending(e, { kind: 'next' }) }
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
- devices: devices.map(({ tokenHash, ...d }) => ({ ...d, connections: devSockets.get(d.id)?.size ?? 0 })),
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