serviceInstance.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113
  1. import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
  2. import { chmodSync, existsSync, 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. import type { BuildIdentity } from './buildIdentity.js'
  7. export interface ServiceRecord {
  8. protocol: 1
  9. instanceId: string
  10. pid: number
  11. dataKey: string
  12. version: string
  13. url?: string
  14. token: string
  15. desktopOwned: boolean
  16. build?: BuildIdentity
  17. maintenanceProtocol: 1
  18. }
  19. /** Every executable entry takes this lock before opening any databases.
  20. * Process death can be recovered; an unknown or live owner always fails closed. */
  21. export function acquireServiceInstance(home: string, version: string, desktopOwned = false, build?: BuildIdentity) {
  22. mkdirSync(home, { recursive: true, mode: 0o700 })
  23. const root = realpathSync(home), file = join(root, 'service-instance.json')
  24. // SQLite holds an OS-level lock, released by the kernel on process death.
  25. // A separate database avoids blocking the application databases. No stale
  26. // PID-file unlink race can evict a replacement process.
  27. const lockPath = join(root, 'service-instance.sqlite3'), lock = new DatabaseSync(lockPath)
  28. chmodSync(lockPath, 0o600)
  29. try { lock.exec('PRAGMA busy_timeout=0; BEGIN EXCLUSIVE') }
  30. catch { lock.close(); throw new Error('这份数据已由另一个夭夭服务使用,请连接已有服务。') }
  31. const record: ServiceRecord = { protocol: 1, instanceId: randomUUID(), pid: process.pid,
  32. dataKey: createHash('sha256').update(root).digest('hex'), version,
  33. token: randomBytes(32).toString('base64url'), desktopOwned, build, maintenanceProtocol: 1 }
  34. const write = () => {
  35. const temporary = join(root, `service-instance-${record.instanceId}.tmp`)
  36. writeFileSync(temporary, JSON.stringify(record), { mode: 0o600 })
  37. renameSync(temporary, file)
  38. }
  39. try { write() } catch (error) { lock.close(); throw error }
  40. let released = false
  41. let stopping = false
  42. let quiescedUntil = 0, pendingMutations = 0
  43. const transition = join(root, 'updates', 'transition.json')
  44. const quiesced = () => Date.now() < quiescedUntil || existsSync(transition)
  45. return {
  46. record,
  47. get quiesced() { return quiesced() },
  48. beginShutdown() { stopping = true },
  49. publish(url: string) { record.url = url; write() },
  50. release() {
  51. if (released) return
  52. released = true
  53. try {
  54. const current = JSON.parse(readFileSync(file, 'utf8')) as ServiceRecord
  55. if (current.instanceId === record.instanceId && current.pid === process.pid) {
  56. rmSync(file)
  57. }
  58. } catch { /* Never remove a lock that can no longer be identified. */ }
  59. finally { lock.close() }
  60. },
  61. middleware(shutdown: () => Promise<void>, idle: () => boolean = () => true, environment?: (ctx:Koa.Context)=>Promise<void>, activation?: {readonly required:boolean;activate():void|Promise<void>}, desktopSession?: (ctx: Koa.Context) => void): Koa.Middleware {
  62. return async (ctx, next) => {
  63. const control = ctx.path === '/desktop/environment' || ctx.path === '/desktop/service' || ctx.path === '/desktop/service/quiesce' || ctx.path === '/desktop/service/activate' || ctx.path === '/desktop/service/session'
  64. if (!control) {
  65. if(stopping){ctx.status=503;ctx.body={error:'后台服务正在停止',code:'service_stopping'};return}
  66. if (quiesced() && !['/healthz', '/readyz', '/api/status'].includes(ctx.path)) {
  67. ctx.status = 503; ctx.set('Retry-After', '3')
  68. ctx.body = { error: 'Web 服务正在更新,请稍后重试', code: 'service_updating' }; return
  69. }
  70. const mutation = !['GET', 'HEAD', 'OPTIONS'].includes(ctx.method) && !/^\/api\/runner\/.*\/(poll|heartbeat)$/.test(ctx.path)
  71. if (mutation) pendingMutations++
  72. try { await next() } finally { if (mutation) pendingMutations-- }
  73. return
  74. }
  75. const received = createHash('sha256').update(ctx.get('x-yaoyao-desktop-token')).digest()
  76. const expected = createHash('sha256').update(record.token).digest()
  77. const local = ['127.0.0.1', '::1', '::ffff:127.0.0.1'].includes(ctx.req.socket.remoteAddress ?? '')
  78. if (!local || ctx.get('origin') || !timingSafeEqual(received, expected)) { ctx.status = 403; ctx.body = { error: '桌面服务授权无效' }; return }
  79. ctx.set('Cache-Control', 'no-store')
  80. if (ctx.path === '/desktop/service/session') {
  81. if (ctx.method !== 'POST' || !desktopSession) { ctx.status = 405; return }
  82. if (stopping || quiesced()) { ctx.status = 503; return }
  83. try { desktopSession(ctx) }
  84. catch (error) { ctx.status = (error as any).status ?? 500; ctx.body = { error: '本机管理员会话未就绪' } }
  85. return
  86. }
  87. if(ctx.path==='/desktop/environment'){if(environment){try{await environment(ctx)}catch(error){ctx.status=(error as any).status??500;ctx.body={error:ctx.status<500?(error as Error).message:'桌面连接请求失败',code:(error as any).code??'desktop_error'}}}else ctx.status=404;return}
  88. if (ctx.path === '/desktop/service/activate') {
  89. if (ctx.method !== 'POST' || !activation) { ctx.status = 405; return }
  90. if (stopping || quiesced()) { ctx.status = 503; ctx.body = { error: '后台服务暂时不能激活' }; return }
  91. await activation.activate()
  92. ctx.body = { activated: true }; return
  93. }
  94. if (ctx.path.endsWith('/quiesce')) {
  95. if (ctx.method === 'POST') {
  96. if (pendingMutations || !idle()) { ctx.status = 409; ctx.body = { error: '正在等待任务完成', code: 'service_busy' }; return }
  97. quiescedUntil = Date.now() + 15000; ctx.body = { quiesced: true }
  98. } else if (ctx.method === 'DELETE' && !existsSync(transition)) {
  99. quiescedUntil = 0; ctx.body = { quiesced: false }
  100. } else { ctx.status = 405; ctx.body = { error: '更新事务尚未结束' } }
  101. } else if (ctx.method === 'GET') {
  102. const { token: _token, ...publicRecord } = record
  103. ctx.body = { ...publicRecord, quiesced: quiesced(), stopping, activationRequired: activation?.required ?? false }
  104. } else if (ctx.method === 'DELETE' && desktopOwned) {
  105. ctx.body = { stopping: true }
  106. setImmediate(() => { void shutdown() })
  107. } else { ctx.status = 405; ctx.body = { error: '此服务不接受桌面停止请求' } }
  108. }
  109. },
  110. }
  111. }