serviceInstance.ts 3.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
  1. import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
  2. import { chmodSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs'
  3. import { join } from 'node:path'
  4. import { DatabaseSync } from 'node:sqlite'
  5. import type Koa from 'koa'
  6. export interface ServiceRecord {
  7. protocol: 1
  8. instanceId: string
  9. pid: number
  10. dataKey: string
  11. version: string
  12. url?: string
  13. token: string
  14. desktopOwned: boolean
  15. }
  16. /** Every executable entry takes this lock before opening any databases.
  17. * Process death can be recovered; an unknown or live owner always fails closed. */
  18. export function acquireServiceInstance(home: string, version: string, desktopOwned = false) {
  19. mkdirSync(home, { recursive: true, mode: 0o700 })
  20. const root = realpathSync(home), file = join(root, 'service-instance.json')
  21. // SQLite holds an OS-level lock, released by the kernel on process death.
  22. // A separate database avoids blocking the application databases. No stale
  23. // PID-file unlink race can evict a replacement process.
  24. const lockPath = join(root, 'service-instance.sqlite3'), lock = new DatabaseSync(lockPath)
  25. chmodSync(lockPath, 0o600)
  26. try { lock.exec('PRAGMA busy_timeout=0; BEGIN EXCLUSIVE') }
  27. catch { lock.close(); throw new Error('这份数据已由另一个夭夭服务使用,请连接已有服务。') }
  28. const record: ServiceRecord = { protocol: 1, instanceId: randomUUID(), pid: process.pid,
  29. dataKey: createHash('sha256').update(root).digest('hex'), version,
  30. token: randomBytes(32).toString('base64url'), desktopOwned }
  31. const write = () => {
  32. const temporary = join(root, `service-instance-${record.instanceId}.tmp`)
  33. writeFileSync(temporary, JSON.stringify(record), { mode: 0o600 })
  34. renameSync(temporary, file)
  35. }
  36. try { write() } catch (error) { lock.close(); throw error }
  37. let released = false
  38. return {
  39. record,
  40. publish(url: string) { record.url = url; write() },
  41. release() {
  42. if (released) return
  43. released = true
  44. try {
  45. const current = JSON.parse(readFileSync(file, 'utf8')) as ServiceRecord
  46. if (current.instanceId === record.instanceId && current.pid === process.pid) {
  47. rmSync(file)
  48. }
  49. } catch { /* Never remove a lock that can no longer be identified. */ }
  50. finally { lock.close() }
  51. },
  52. middleware(shutdown: () => Promise<void>): Koa.Middleware {
  53. return async (ctx, next) => {
  54. if (ctx.path !== '/desktop/service') return next()
  55. const received = createHash('sha256').update(ctx.get('x-yaoyao-desktop-token')).digest()
  56. const expected = createHash('sha256').update(record.token).digest()
  57. const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(ctx.req.socket.remoteAddress ?? '')
  58. if (!local || ctx.get('origin') || !timingSafeEqual(received, expected)) { ctx.status = 403; ctx.body = { error: '桌面服务授权无效' }; return }
  59. ctx.set('Cache-Control', 'no-store')
  60. if (ctx.method === 'GET') {
  61. const { token: _token, ...publicRecord } = record
  62. ctx.body = publicRecord
  63. } else if (ctx.method === 'DELETE' && desktopOwned) {
  64. ctx.body = { stopping: true }
  65. setImmediate(() => { void shutdown() })
  66. } else { ctx.status = 405; ctx.body = { error: '此服务不接受桌面停止请求' } }
  67. }
  68. },
  69. }
  70. }