app.ts 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  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 { LocalAuthStore, UpstreamServiceSession } from './localAuth.js'
  14. import { AccountLoginPairingStore } from './accountPairing.js'
  15. import { createApiRouter } from './routes.js'
  16. import {
  17. applySecurityHeaders,
  18. CsrfProtection,
  19. isAllowedHostHeader,
  20. isExactOrigin,
  21. } from './security.js'
  22. import { UpstreamHttpError, UpstreamClient } from './upstream.js'
  23. import { UploadStore } from './uploads.js'
  24. import { SystemUpdateManager } from './updateManager.js'
  25. import { installWebSocketRelay } from './websocket.js'
  26. export interface ApplicationOptions {
  27. config?: ServerConfig
  28. fetchImpl?: typeof fetch
  29. csrfSecret?: Buffer
  30. leases?: RealtimeLeaseStore
  31. pairings?: NodePairingStore
  32. uploads?: UploadStore
  33. updates?: SystemUpdateManager
  34. auth?: LocalAuthStore
  35. upstreamSession?: UpstreamServiceSession
  36. accountPairings?: AccountLoginPairingStore
  37. }
  38. export interface ApplicationRuntime {
  39. app: Koa
  40. config: ServerConfig
  41. csrf: CsrfProtection
  42. leases: RealtimeLeaseStore
  43. pairings: NodePairingStore
  44. upstream: UpstreamClient
  45. uploads: UploadStore
  46. updates: SystemUpdateManager
  47. auth: LocalAuthStore
  48. upstreamSession: UpstreamServiceSession
  49. accountPairings: AccountLoginPairingStore
  50. close(): void
  51. }
  52. function isMutation(method: string): boolean {
  53. return !['GET', 'HEAD', 'OPTIONS'].includes(method)
  54. }
  55. function persistentCsrfSecret(home: string, override?: Buffer): Buffer {
  56. if (override) return override
  57. const path = join(home, 'csrf-secret.bin')
  58. try {
  59. const secret = readFileSync(path)
  60. if (secret.byteLength === 32) return secret
  61. throw new Error('CSRF secret has an invalid length')
  62. } catch (error) {
  63. if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
  64. }
  65. mkdirSync(home, { recursive: true, mode: 0o700 })
  66. const generated = randomBytes(32)
  67. try {
  68. writeFileSync(path, generated, { mode: 0o600, flag: 'wx' })
  69. return generated
  70. } catch (error) {
  71. // Another 8800 process can finish first during launchd hand-off. Never
  72. // rotate its secret; use the one it safely wrote instead.
  73. if ((error as NodeJS.ErrnoException).code === 'EEXIST') return readFileSync(path)
  74. throw error
  75. }
  76. }
  77. export function createApplication(options: ApplicationOptions = {}): ApplicationRuntime {
  78. const config = options.config ?? loadServerConfig()
  79. const app = new Koa()
  80. app.proxy = false
  81. const csrf = new CsrfProtection(persistentCsrfSecret(config.home, options.csrfSecret), Boolean(config.tlsCert))
  82. const leases = options.leases ?? new RealtimeLeaseStore()
  83. const pairings = options.pairings ?? new NodePairingStore(config.home)
  84. const upstream = new UpstreamClient(config.upstream, options.fetchImpl, Boolean(config.tlsCert))
  85. const uploads = options.uploads ?? new UploadStore(config.home)
  86. const updates = options.updates ?? new SystemUpdateManager(config)
  87. const auth = options.auth ?? new LocalAuthStore(config.home, Boolean(config.tlsCert))
  88. if (config.superviseDashboard && !config.upstreamUsername && !config.upstreamPassword) {
  89. auth.ensureUpstreamCredentials()
  90. }
  91. const configuredUpstreamCredentials = config.upstreamUsername && config.upstreamPassword
  92. ? { username: config.upstreamUsername, password: config.upstreamPassword }
  93. : undefined
  94. const upstreamSession = options.upstreamSession ?? new UpstreamServiceSession(
  95. upstream,
  96. () => auth.upstreamCredentials(configuredUpstreamCredentials),
  97. )
  98. const accountPairings = options.accountPairings ?? new AccountLoginPairingStore()
  99. try {
  100. uploads.cleanupUncommitted()
  101. } catch {
  102. // A locked cleanup should not prevent startup; upload operations will
  103. // still surface the underlying storage error when used.
  104. }
  105. app.use(async (ctx, next) => {
  106. try {
  107. await next()
  108. } catch (error) {
  109. if (error instanceof UpstreamHttpError) {
  110. ctx.status = error.response.status
  111. ctx.type = 'application/json; charset=utf-8'
  112. ctx.body = { error: `Hermes returned HTTP ${error.response.status}`, code: error.code }
  113. return
  114. }
  115. const status = error instanceof HttpError
  116. ? error.status
  117. : typeof (error as { status?: unknown })?.status === 'number'
  118. ? Number((error as { status: number }).status)
  119. : 500
  120. const expose = status < 500 || error instanceof HttpError
  121. ctx.status = status
  122. ctx.type = 'application/json; charset=utf-8'
  123. ctx.body = {
  124. error: expose ? errorMessage(error) : 'Internal server error',
  125. code: error instanceof HttpError ? error.code : 'internal_error',
  126. }
  127. if (status >= 500 && !config.production) ctx.app.emit('error', error, ctx)
  128. }
  129. })
  130. app.use(async (ctx, next) => {
  131. applySecurityHeaders(ctx, Boolean(config.tlsCert), config.allowedHosts)
  132. if (!isAllowedHostHeader(ctx.get('host'), config)) {
  133. throw new HttpError(421, 'Request Host is not allowed', 'invalid_host')
  134. }
  135. await next()
  136. })
  137. app.use(async (ctx, next) => {
  138. if (ctx.path.startsWith('/api/app/') && isMutation(ctx.method)) {
  139. const secure = ctx.secure || Boolean(config.tlsCert)
  140. if (!isExactOrigin(ctx.get('origin'), ctx.get('host'), secure, config.allowedHosts)) {
  141. throw new HttpError(403, 'Request Origin is not allowed', 'invalid_origin')
  142. }
  143. if (!csrf.verify(ctx.get('cookie'), ctx.get('x-csrf-token'))) {
  144. throw new HttpError(403, 'CSRF token is invalid or missing', 'invalid_csrf')
  145. }
  146. }
  147. await next()
  148. })
  149. app.use(bodyParser({
  150. encoding: 'utf-8',
  151. enableTypes: ['json'],
  152. parsedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
  153. jsonLimit: '2mb',
  154. onError: (_error, ctx) => {
  155. throw new HttpError(ctx.status === 413 ? 413 : 400, 'Invalid JSON request body', 'invalid_json_body')
  156. },
  157. }))
  158. app.use(async (ctx, next) => {
  159. ctx.state.localAuth = auth
  160. ctx.state.upstreamSession = upstreamSession
  161. const anonymous = ctx.path === '/api/app/bootstrap'
  162. || ctx.path === '/api/app/login'
  163. || ctx.path === '/api/status'
  164. || ctx.path === '/api/auth/providers'
  165. || ctx.path === '/auth/password-login'
  166. || ctx.path.startsWith('/api/pair/v1/')
  167. || ctx.path.startsWith('/api/account-pair/v1/')
  168. || ctx.path === '/healthz'
  169. || ctx.path === '/readyz'
  170. if (ctx.path.startsWith('/api/app/') && !anonymous) {
  171. const allowPasswordChange = ctx.path === '/api/app/account/credentials'
  172. || ctx.path === '/api/app/logout'
  173. ctx.state.localUser = auth.require(ctx, allowPasswordChange)
  174. const adminOnly = ctx.path.startsWith('/api/app/admin/')
  175. || ctx.path.startsWith('/api/app/system/')
  176. || ctx.path.startsWith('/api/app/plugins/')
  177. || ctx.path.startsWith('/api/app/pairings')
  178. || ctx.path.startsWith('/api/app/paired-devices')
  179. || ctx.path.startsWith('/api/app/groups/nodes')
  180. if (adminOnly) ctx.state.localUser = auth.requireAdmin(ctx)
  181. }
  182. const gatewayCompatibility = (ctx.path.startsWith('/api/') && !ctx.path.startsWith('/api/app/')
  183. && !ctx.path.startsWith('/api/pair/'))
  184. || ctx.path === '/auth/logout'
  185. if (gatewayCompatibility && !anonymous) {
  186. const allowPasswordChange = ctx.path === '/api/auth/me' || ctx.path === '/api/profiles'
  187. || ctx.path === '/api/account/credentials'
  188. ctx.state.localUser = auth.require(ctx, allowPasswordChange)
  189. if (isMutation(ctx.method)) {
  190. const origin = ctx.get('origin')
  191. if (origin && !isExactOrigin(origin, ctx.get('host'), ctx.secure || Boolean(config.tlsCert), config.allowedHosts)) {
  192. throw new HttpError(403, 'Request Origin is not allowed', 'invalid_origin')
  193. }
  194. }
  195. }
  196. await next()
  197. })
  198. const router = createApiRouter({
  199. config,
  200. csrf,
  201. upstream,
  202. leases,
  203. pairings,
  204. uploads,
  205. updates,
  206. auth,
  207. upstreamSession,
  208. accountPairings,
  209. })
  210. app.use(router.routes())
  211. app.use(router.allowedMethods({ throw: false }))
  212. app.use(async (ctx, next) => {
  213. if (ctx.path === '/api' || ctx.path.startsWith('/api/') || ctx.path.startsWith('/ws/')) {
  214. ctx.status = 404
  215. ctx.type = 'application/json; charset=utf-8'
  216. ctx.body = { error: 'Route not found', code: 'not_found' }
  217. return
  218. }
  219. await next()
  220. })
  221. return {
  222. app,
  223. config,
  224. csrf,
  225. leases,
  226. pairings,
  227. upstream,
  228. uploads,
  229. updates,
  230. auth,
  231. upstreamSession,
  232. accountPairings,
  233. close: () => uploads.close(),
  234. }
  235. }
  236. export interface NodeServerRuntime {
  237. server: HttpServer
  238. close(): Promise<void>
  239. }
  240. export function createNodeServer(runtime: ApplicationRuntime): NodeServerRuntime {
  241. const { config } = runtime
  242. const server: HttpServer = config.tlsCert && config.tlsKey
  243. ? createHttpsServer({
  244. cert: readFileSync(config.tlsCert),
  245. key: readFileSync(config.tlsKey),
  246. }, runtime.app.callback())
  247. : createHttpServer(runtime.app.callback())
  248. const removeWebSockets = installWebSocketRelay(
  249. server,
  250. config,
  251. runtime.leases,
  252. runtime.pairings,
  253. )
  254. return {
  255. server,
  256. close: async () => {
  257. removeWebSockets()
  258. await new Promise<void>((resolve, reject) => {
  259. if (!server.listening) {
  260. resolve()
  261. return
  262. }
  263. server.close((error) => error ? reject(error) : resolve())
  264. })
  265. runtime.close()
  266. },
  267. }
  268. }