app.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. // Metrics must be initialized before importing anything else
  2. import '@overleaf/metrics/initialize.js'
  3. import Metrics from '@overleaf/metrics'
  4. import Settings from '@overleaf/settings'
  5. import async from 'async'
  6. import logger from '@overleaf/logger'
  7. import express from 'express'
  8. import session from 'express-session'
  9. import redis from '@overleaf/redis-wrapper'
  10. import ConnectRedis from 'connect-redis'
  11. import SessionSockets from './app/js/SessionSockets.js'
  12. import CookieParser from 'cookie-parser'
  13. import DrainManager from './app/js/DrainManager.js'
  14. import HealthCheckManager from './app/js/HealthCheckManager.js'
  15. import DeploymentManager from './app/js/DeploymentManager.js'
  16. import Path from 'node:path'
  17. import socketIO from 'socket.io'
  18. import socketIOClient from 'socket.io-client'
  19. import http from 'node:http'
  20. import Router from './app/js/Router.js'
  21. import WebsocketLoadBalancer from './app/js/WebsocketLoadBalancer.js'
  22. import DocumentUpdaterController from './app/js/DocumentUpdaterController.js'
  23. logger.initialize('real-time')
  24. Metrics.event_loop.monitor(logger)
  25. Metrics.open_sockets.monitor()
  26. const sessionRedisClient = redis.createClient(Settings.redis.websessions)
  27. const RedisStore = ConnectRedis(session)
  28. // NOTE: debug is invoked for every blob that is put on the wire
  29. const socketIoLogger = {
  30. error(...message) {
  31. logger.debug({ fromSocketIo: true, originalLevel: 'error' }, ...message)
  32. },
  33. warn(...message) {
  34. logger.debug({ fromSocketIo: true, originalLevel: 'warn' }, ...message)
  35. },
  36. info() {},
  37. debug() {},
  38. log() {},
  39. }
  40. // monitor status file to take dark deployments out of the load-balancer
  41. DeploymentManager.initialise()
  42. // Set up socket.io server
  43. const app = express()
  44. const server = http.createServer(app)
  45. server.keepAliveTimeout = Settings.keepAliveTimeoutMs
  46. const io = socketIO.listen(server, {
  47. logger: socketIoLogger,
  48. })
  49. // Bind to sessions
  50. const sessionStore = new RedisStore({ client: sessionRedisClient })
  51. if (!Settings.security.sessionSecret) {
  52. throw new Error('No SESSION_SECRET provided.')
  53. }
  54. const sessionSecrets = [
  55. Settings.security.sessionSecret,
  56. Settings.security.sessionSecretUpcoming,
  57. Settings.security.sessionSecretFallback,
  58. ].filter(Boolean)
  59. const cookieParser = CookieParser(sessionSecrets)
  60. const sessionSockets = new SessionSockets(
  61. io,
  62. sessionStore,
  63. cookieParser,
  64. Settings.cookieName
  65. )
  66. Metrics.injectMetricsRoute(app)
  67. io.configure(function () {
  68. // Don't use socket.io to serve client
  69. io.disable('browser client')
  70. // Fix for Safari 5 error of "Error during WebSocket handshake: location mismatch"
  71. // See http://answers.dotcloud.com/question/578/problem-with-websocket-over-ssl-in-safari-with
  72. io.set('match origin protocol', true)
  73. io.set('transports', ['websocket', 'xhr-polling'])
  74. if (Settings.allowedCorsOrigins) {
  75. // Create a regex for matching origins, allowing wildcard subdomains
  76. const allowedCorsOriginsRegex = new RegExp(
  77. `^${Settings.allowedCorsOrigins.replaceAll('.', '\\.').replace('://*', '://[^.]+')}(?::443)?$`
  78. )
  79. io.set('origins', function (origin, req) {
  80. if (!origin) {
  81. // There is no origin or referer header - this is likely a same-site request.
  82. logger.warn({ req }, 'No origin or referer header')
  83. return true
  84. }
  85. const normalizedOrigin = URL.parse(origin).origin
  86. const originIsValid = allowedCorsOriginsRegex.test(normalizedOrigin)
  87. if (req.headers.origin) {
  88. if (!originIsValid) {
  89. logger.warn(
  90. { normalizedOrigin, origin, req },
  91. 'Origin header does not match allowed origins'
  92. )
  93. }
  94. return originIsValid
  95. }
  96. if (!originIsValid) {
  97. // There is no Origin header and the Referrer does not satisfy the
  98. // constraints. We're going to pass this anyway for now but log it
  99. logger.warn(
  100. { req, referer: req.headers.referer },
  101. 'Referrer header does not match allowed origins'
  102. )
  103. }
  104. return true
  105. })
  106. }
  107. })
  108. // Serve socket.io.js client file from imported dist folder
  109. // The express sendFile method correctly handles conditional
  110. // requests using the last-modified time and etag (which is
  111. // a combination of mtime and size)
  112. const socketIOClientFolder = socketIOClient.dist
  113. app.get('/socket.io/socket.io.js', function (req, res) {
  114. res.sendFile(Path.join(socketIOClientFolder, 'socket.io.min.js'))
  115. })
  116. // a 200 response on '/' is required for load balancer health checks
  117. // these operate separately from kubernetes readiness checks
  118. app.get('/', function (req, res) {
  119. if (Settings.shutDownInProgress || DeploymentManager.deploymentIsClosed()) {
  120. res.sendStatus(503) // Service unavailable
  121. } else {
  122. res.send('real-time is open')
  123. }
  124. })
  125. app.get('/status', function (req, res) {
  126. if (Settings.shutDownInProgress) {
  127. res.sendStatus(503) // Service unavailable
  128. } else {
  129. res.send('real-time is alive')
  130. }
  131. })
  132. app.get('/debug/events', function (req, res) {
  133. Settings.debugEvents = parseInt(req.query.count, 10) || 20
  134. logger.info({ count: Settings.debugEvents }, 'starting debug mode')
  135. res.send(`debug mode will log next ${Settings.debugEvents} events`)
  136. })
  137. const rclient = redis.createClient(Settings.redis.realtime)
  138. function healthCheck(req, res) {
  139. rclient.healthCheck(function (error) {
  140. if (error) {
  141. logger.err({ err: error }, 'failed redis health check')
  142. res.sendStatus(500)
  143. } else if (HealthCheckManager.isFailing()) {
  144. const status = HealthCheckManager.status()
  145. logger.err({ pubSubErrors: status }, 'failed pubsub health check')
  146. res.sendStatus(500)
  147. } else {
  148. res.sendStatus(200)
  149. }
  150. })
  151. }
  152. app.get(
  153. '/health_check',
  154. (req, res, next) => {
  155. if (Settings.shutDownComplete) {
  156. return res.sendStatus(503)
  157. }
  158. next()
  159. },
  160. healthCheck
  161. )
  162. app.get('/health_check/redis', healthCheck)
  163. // log http requests for routes defined from this point onwards
  164. app.use(Metrics.http.monitor(logger))
  165. Router.configure(app, io, sessionSockets)
  166. WebsocketLoadBalancer.listenForEditorEvents(io)
  167. DocumentUpdaterController.listenForUpdatesFromDocumentUpdater(io)
  168. const { port } = Settings.internal.realTime
  169. const { host } = Settings.internal.realTime
  170. server.listen(port, host, function (error) {
  171. if (error) {
  172. throw error
  173. }
  174. logger.info(`realtime starting up, listening on ${host}:${port}`)
  175. })
  176. // Stop huge stack traces in logs from all the socket.io parsing steps.
  177. Error.stackTraceLimit = 10
  178. function shutdownAfterAllClientsHaveDisconnected() {
  179. const connectedClients = io.sockets.clients().length
  180. if (connectedClients === 0) {
  181. logger.info({}, 'no clients connected, exiting')
  182. process.exit()
  183. } else {
  184. logger.info(
  185. { connectedClients },
  186. 'clients still connected, not shutting down yet'
  187. )
  188. setTimeout(() => shutdownAfterAllClientsHaveDisconnected(), 5_000)
  189. }
  190. }
  191. function drainAndShutdown(signal) {
  192. if (Settings.shutDownInProgress) {
  193. logger.info({ signal }, 'shutdown already in progress, ignoring signal')
  194. } else {
  195. Settings.shutDownInProgress = true
  196. const { statusCheckInterval } = Settings
  197. if (statusCheckInterval) {
  198. logger.info(
  199. { signal },
  200. `received interrupt, delay drain by ${statusCheckInterval}ms`
  201. )
  202. }
  203. setTimeout(function () {
  204. logger.info(
  205. { signal },
  206. `received interrupt, starting drain over ${shutdownDrainTimeWindow} mins`
  207. )
  208. DrainManager.startDrainTimeWindow(io, shutdownDrainTimeWindow, () => {
  209. shutdownAfterAllClientsHaveDisconnected()
  210. setTimeout(() => {
  211. const staleClients = io.sockets.clients()
  212. if (staleClients.length !== 0) {
  213. logger.info(
  214. { staleClients: staleClients.map(client => client.id) },
  215. 'forcefully disconnecting stale clients'
  216. )
  217. staleClients.forEach(client => {
  218. client.disconnect()
  219. })
  220. }
  221. // Mark the node as unhealthy.
  222. Settings.shutDownComplete = true
  223. }, Settings.gracefulReconnectTimeoutMs)
  224. })
  225. }, statusCheckInterval)
  226. }
  227. }
  228. Settings.shutDownInProgress = false
  229. Settings.shutDownScheduled = false
  230. const shutdownDrainTimeWindow = parseInt(Settings.shutdownDrainTimeWindow, 10)
  231. if (Settings.shutdownDrainTimeWindow) {
  232. logger.info({ shutdownDrainTimeWindow }, 'shutdownDrainTimeWindow enabled')
  233. for (const signal of [
  234. 'SIGINT',
  235. 'SIGHUP',
  236. 'SIGQUIT',
  237. 'SIGUSR1',
  238. 'SIGUSR2',
  239. 'SIGTERM',
  240. 'SIGABRT',
  241. ]) {
  242. process.on(signal, drainAndShutdown)
  243. } // signal is passed as argument to event handler
  244. // global exception handler
  245. if (Settings.errors && Settings.errors.catchUncaughtErrors) {
  246. process.removeAllListeners('uncaughtException')
  247. process.on('uncaughtException', function (error) {
  248. if (
  249. [
  250. 'ETIMEDOUT',
  251. 'EHOSTUNREACH',
  252. 'EPIPE',
  253. 'ECONNRESET',
  254. 'ERR_STREAM_WRITE_AFTER_END',
  255. 'ERR_STREAM_UNABLE_TO_PIPE',
  256. ].includes(error.code) ||
  257. // socket.io error handler sending on polling connection again.
  258. (error.code === 'ERR_HTTP_HEADERS_SENT' &&
  259. error.stack &&
  260. error.stack.includes('Transport.error'))
  261. ) {
  262. Metrics.inc('disconnected_write', 1, { status: error.code })
  263. return logger.warn(
  264. { err: error },
  265. 'attempted to write to disconnected client'
  266. )
  267. }
  268. logger.error({ err: error }, 'uncaught exception')
  269. if (
  270. Settings.errors?.shutdownOnUncaughtError &&
  271. !Settings.shutDownScheduled
  272. ) {
  273. Settings.shutDownScheduled = true
  274. const delay = Math.ceil(
  275. Math.random() * 60 * Math.max(io.sockets.clients().length, 1_000)
  276. )
  277. logger.info({ delay }, 'delaying shutdown on uncaught error')
  278. setTimeout(() => drainAndShutdown('SIGABRT'), delay)
  279. }
  280. })
  281. }
  282. }
  283. if (Settings.continualPubsubTraffic) {
  284. logger.debug('continualPubsubTraffic enabled')
  285. const pubsubClient = redis.createClient(Settings.redis.pubsub)
  286. const clusterClient = redis.createClient(Settings.redis.websessions)
  287. const publishJob = function (channel, callback) {
  288. const checker = new HealthCheckManager(channel)
  289. logger.debug({ channel }, 'sending pub to keep connection alive')
  290. const json = JSON.stringify({
  291. health_check: true,
  292. key: checker.id,
  293. date: new Date().toString(),
  294. })
  295. Metrics.summary(`redis.publish.${channel}`, json.length)
  296. pubsubClient.publish(channel, json, function (err) {
  297. if (err) {
  298. logger.err({ err, channel }, 'error publishing pubsub traffic to redis')
  299. }
  300. const blob = JSON.stringify({ keep: 'alive' })
  301. Metrics.summary('redis.publish.cluster-continual-traffic', blob.length)
  302. clusterClient.publish('cluster-continual-traffic', blob, callback)
  303. })
  304. }
  305. const runPubSubTraffic = () =>
  306. async.map(['applied-ops', 'editor-events'], publishJob, () =>
  307. setTimeout(runPubSubTraffic, 1000 * 20)
  308. )
  309. runPubSubTraffic()
  310. }
  311. export default app