app.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. import { randomBytes } from 'node:crypto'
  2. import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
  3. import { createServer as createHttpServer, type Server as HttpServer } from 'node:http'
  4. import { createServer as createHttpsServer } from 'node:https'
  5. import { join } from 'node:path'
  6. import { bodyParser } from '@koa/bodyparser'
  7. import Koa from 'koa'
  8. import type { ServerConfig } from './config.js'
  9. import { loadServerConfig } from './config.js'
  10. import { errorMessage, HttpError } from './errors.js'
  11. import { RealtimeLeaseStore } from './leases.js'
  12. import { NodePairingStore } from './pairing.js'
  13. import { createApiRouter } from './routes.js'
  14. import {
  15. applySecurityHeaders,
  16. CsrfProtection,
  17. isAllowedHostHeader,
  18. isExactOrigin,
  19. } from './security.js'
  20. import { UpstreamHttpError, UpstreamClient } from './upstream.js'
  21. import { UploadStore } from './uploads.js'
  22. import { SystemUpdateManager } from './updateManager.js'
  23. import { installWebSocketRelay } from './websocket.js'
  24. export interface ApplicationOptions {
  25. config?: ServerConfig
  26. fetchImpl?: typeof fetch
  27. csrfSecret?: Buffer
  28. leases?: RealtimeLeaseStore
  29. pairings?: NodePairingStore
  30. uploads?: UploadStore
  31. restartDashboard?: () => Promise<void>
  32. updates?: SystemUpdateManager
  33. }
  34. export interface ApplicationRuntime {
  35. app: Koa
  36. config: ServerConfig
  37. csrf: CsrfProtection
  38. leases: RealtimeLeaseStore
  39. pairings: NodePairingStore
  40. upstream: UpstreamClient
  41. uploads: UploadStore
  42. updates: SystemUpdateManager
  43. close(): void
  44. }
  45. function isMutation(method: string): boolean {
  46. return !['GET', 'HEAD', 'OPTIONS'].includes(method)
  47. }
  48. function persistentCsrfSecret(home: string, override?: Buffer): Buffer {
  49. if (override) return override
  50. const path = join(home, 'csrf-secret.bin')
  51. try {
  52. const secret = readFileSync(path)
  53. if (secret.byteLength === 32) return secret
  54. throw new Error('CSRF secret has an invalid length')
  55. } catch (error) {
  56. if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
  57. }
  58. mkdirSync(home, { recursive: true, mode: 0o700 })
  59. const generated = randomBytes(32)
  60. try {
  61. writeFileSync(path, generated, { mode: 0o600, flag: 'wx' })
  62. return generated
  63. } catch (error) {
  64. // Another 8800 process can finish first during launchd hand-off. Never
  65. // rotate its secret; use the one it safely wrote instead.
  66. if ((error as NodeJS.ErrnoException).code === 'EEXIST') return readFileSync(path)
  67. throw error
  68. }
  69. }
  70. export function createApplication(options: ApplicationOptions = {}): ApplicationRuntime {
  71. const config = options.config ?? loadServerConfig()
  72. const app = new Koa()
  73. app.proxy = false
  74. const csrf = new CsrfProtection(persistentCsrfSecret(config.home, options.csrfSecret), Boolean(config.tlsCert))
  75. const leases = options.leases ?? new RealtimeLeaseStore()
  76. const pairings = options.pairings ?? new NodePairingStore(config.home)
  77. const upstream = new UpstreamClient(config.upstream, options.fetchImpl, Boolean(config.tlsCert))
  78. const uploads = options.uploads ?? new UploadStore(config.home)
  79. const updates = options.updates ?? new SystemUpdateManager(config)
  80. try {
  81. uploads.cleanupUncommitted()
  82. } catch {
  83. // A locked cleanup should not prevent startup; upload operations will
  84. // still surface the underlying storage error when used.
  85. }
  86. app.use(async (ctx, next) => {
  87. try {
  88. await next()
  89. } catch (error) {
  90. if (error instanceof UpstreamHttpError) {
  91. ctx.status = error.response.status
  92. ctx.type = 'application/json; charset=utf-8'
  93. ctx.body = { error: `Hermes returned HTTP ${error.response.status}`, code: error.code }
  94. return
  95. }
  96. const status = error instanceof HttpError
  97. ? error.status
  98. : typeof (error as { status?: unknown })?.status === 'number'
  99. ? Number((error as { status: number }).status)
  100. : 500
  101. const expose = status < 500 || error instanceof HttpError
  102. ctx.status = status
  103. ctx.type = 'application/json; charset=utf-8'
  104. ctx.body = {
  105. error: expose ? errorMessage(error) : 'Internal server error',
  106. code: error instanceof HttpError ? error.code : 'internal_error',
  107. }
  108. if (status >= 500 && !config.production) ctx.app.emit('error', error, ctx)
  109. }
  110. })
  111. app.use(async (ctx, next) => {
  112. applySecurityHeaders(ctx, Boolean(config.tlsCert), config.allowedHosts)
  113. if (!isAllowedHostHeader(ctx.get('host'), config)) {
  114. throw new HttpError(421, 'Request Host is not allowed', 'invalid_host')
  115. }
  116. await next()
  117. })
  118. app.use(async (ctx, next) => {
  119. if (ctx.path.startsWith('/api/app/') && isMutation(ctx.method)) {
  120. const secure = ctx.secure || Boolean(config.tlsCert)
  121. if (!isExactOrigin(ctx.get('origin'), ctx.get('host'), secure, config.allowedHosts)) {
  122. throw new HttpError(403, 'Request Origin is not allowed', 'invalid_origin')
  123. }
  124. if (!csrf.verify(ctx.get('cookie'), ctx.get('x-csrf-token'))) {
  125. throw new HttpError(403, 'CSRF token is invalid or missing', 'invalid_csrf')
  126. }
  127. }
  128. await next()
  129. })
  130. app.use(bodyParser({
  131. encoding: 'utf-8',
  132. enableTypes: ['json'],
  133. parsedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
  134. jsonLimit: '2mb',
  135. onError: (_error, ctx) => {
  136. throw new HttpError(ctx.status === 413 ? 413 : 400, 'Invalid JSON request body', 'invalid_json_body')
  137. },
  138. }))
  139. const router = createApiRouter({
  140. config,
  141. csrf,
  142. upstream,
  143. leases,
  144. pairings,
  145. uploads,
  146. updates,
  147. restartDashboard: options.restartDashboard,
  148. })
  149. app.use(router.routes())
  150. app.use(router.allowedMethods({ throw: false }))
  151. app.use(async (ctx, next) => {
  152. if (ctx.path === '/api' || ctx.path.startsWith('/api/') || ctx.path.startsWith('/ws/')) {
  153. ctx.status = 404
  154. ctx.type = 'application/json; charset=utf-8'
  155. ctx.body = { error: 'Route not found', code: 'not_found' }
  156. return
  157. }
  158. await next()
  159. })
  160. return {
  161. app,
  162. config,
  163. csrf,
  164. leases,
  165. pairings,
  166. upstream,
  167. uploads,
  168. updates,
  169. close: () => uploads.close(),
  170. }
  171. }
  172. export interface NodeServerRuntime {
  173. server: HttpServer
  174. close(): Promise<void>
  175. }
  176. export function createNodeServer(runtime: ApplicationRuntime): NodeServerRuntime {
  177. const { config } = runtime
  178. const server: HttpServer = config.tlsCert && config.tlsKey
  179. ? createHttpsServer({
  180. cert: readFileSync(config.tlsCert),
  181. key: readFileSync(config.tlsKey),
  182. }, runtime.app.callback())
  183. : createHttpServer(runtime.app.callback())
  184. const removeWebSockets = installWebSocketRelay(
  185. server,
  186. config,
  187. runtime.leases,
  188. runtime.pairings,
  189. )
  190. return {
  191. server,
  192. close: async () => {
  193. removeWebSockets()
  194. await new Promise<void>((resolve, reject) => {
  195. if (!server.listening) {
  196. resolve()
  197. return
  198. }
  199. server.close((error) => error ? reject(error) : resolve())
  200. })
  201. runtime.close()
  202. },
  203. }
  204. }