leaked_sockets.js 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254
  1. /**
  2. * This file monitors HTTP connections in Node.js and logs any potential socket leaks.
  3. * It uses the `diagnostics_channel` module to intercept requests and reponses in the
  4. * `http` module and tracks the lifetime of each http socket. If a socket is open for
  5. * longer than a specified time, it is considered a potential leak and its details are
  6. * logged along with the corresponding information from /proc/net/tcp.
  7. */
  8. const fs = require('node:fs')
  9. const diagnosticsChannel = require('node:diagnostics_channel')
  10. const SOCKET_MONITOR_INTERVAL = 60 * 1000
  11. // set the threshold for logging leaked sockets in minutes, defaults to 15
  12. const MIN_SOCKET_LEAK_TIME =
  13. (parseInt(process.env.LEAKED_SOCKET_AGE_THRESHOLD, 10) || 15) * 60 * 1000
  14. // Record HTTP events using diagnostics_channel
  15. diagnosticsChannel.subscribe('http.client.request.start', handleRequest)
  16. diagnosticsChannel.subscribe('http.server.request.start', handleRequest)
  17. diagnosticsChannel.subscribe('http.client.response.finish', handleResponse)
  18. diagnosticsChannel.subscribe('http.server.response.finish', handleResponse)
  19. function handleRequest({ request: req }) {
  20. const socket = req?.socket
  21. if (socket) {
  22. recordRequest(req, socket)
  23. }
  24. }
  25. function recordRequest(req, socket) {
  26. const { method, protocol, path, url, rawHeaders, _header } = req
  27. socket._ol_debug = {
  28. method,
  29. protocol,
  30. url: url ?? path,
  31. request: { headers: rawHeaders ?? _header, ts: new Date() },
  32. }
  33. }
  34. function handleResponse({ request: req, response: res }) {
  35. const socket = req?.socket || res?.socket
  36. if (!socket || !res) {
  37. return
  38. }
  39. if (!socket._ol_debug) {
  40. // I don't know if this will ever happen, but if we missed the request,
  41. // record it here.
  42. recordRequest(req, socket)
  43. }
  44. const { statusCode, statusMessage, headers, _header } = res
  45. Object.assign(socket._ol_debug, {
  46. response: {
  47. statusCode,
  48. statusMessage,
  49. headers: headers ?? _header,
  50. ts: new Date(),
  51. },
  52. })
  53. }
  54. // Additional functions to log request headers with sensitive information redacted
  55. function flattenHeaders(rawHeaders) {
  56. // Headers can be an array [KEY, VALUE, KEY, VALUE, ..]
  57. // an object {key:value, key:value, ...}
  58. // or a string of the headers separated by \r\n
  59. // Flatten the array and object headers into the string form.
  60. if (Array.isArray(rawHeaders)) {
  61. return rawHeaders
  62. .map((item, index) => (index % 2 === 0 ? `${item}: ` : `${item}\r\n`))
  63. .join('')
  64. } else if (typeof rawHeaders === 'object') {
  65. return Object.entries(rawHeaders)
  66. .map(([key, value]) => `${key}: ${value}\r\n`)
  67. .join('')
  68. } else if (typeof rawHeaders === 'string') {
  69. return rawHeaders
  70. } else {
  71. return JSON.stringify(rawHeaders)
  72. }
  73. }
  74. const REDACT_REGEX = /^(Authorization|Set-Cookie|Cookie):.*?\r/gim
  75. function redactObject(obj) {
  76. const result = {}
  77. for (const [key, value] of Object.entries(obj)) {
  78. if (value == null) {
  79. result[key] = null
  80. } else if (key === 'headers') {
  81. // remove headers with sensitive information
  82. result[key] = flattenHeaders(value).replace(
  83. REDACT_REGEX,
  84. `$1: REDACTED\r`
  85. )
  86. } else if (
  87. typeof value === 'object' &&
  88. ['request', 'response'].includes(key)
  89. ) {
  90. result[key] = redactObject(value)
  91. } else {
  92. result[key] = value
  93. }
  94. }
  95. return result
  96. }
  97. // Check if an old socket has crossed the threshold for logging.
  98. // We log multiple times with an exponential backoff so we can
  99. // see how long a socket hangs around.
  100. function isOldSocket(handle) {
  101. const now = new Date()
  102. const created = handle._ol_debug.request.ts
  103. const lastLoggedAt = handle._ol_debug.lastLoggedAt ?? created
  104. const nextLogTime = new Date(
  105. created.getTime() +
  106. Math.max(2 * (lastLoggedAt - created), MIN_SOCKET_LEAK_TIME)
  107. )
  108. return now >= nextLogTime
  109. }
  110. function logOldSocket(logger, handle, tcpinfo) {
  111. const now = new Date()
  112. const info = Object.assign(
  113. {
  114. localAddress: handle.localAddress,
  115. localPort: handle.localPort,
  116. remoteAddress: handle.remoteAddress,
  117. remotePort: handle.remotePort,
  118. tcpinfo,
  119. age: Math.floor((now - handle._ol_debug.request.ts) / (60 * 1000)), // age in minutes
  120. },
  121. redactObject(handle._ol_debug)
  122. )
  123. handle._ol_debug.lastLoggedAt = now
  124. if (tcpinfo) {
  125. logger.error(info, 'old socket handle - tcp socket')
  126. } else {
  127. logger.warn(info, 'stale socket handle - no entry in /proc/net/tcp')
  128. }
  129. }
  130. // Correlate socket handles with /proc/net/tcp entries using a key based on the
  131. // local and remote addresses and ports. This will allow us to distinguish between
  132. // sockets that are still open and sockets that have been closed and removed from
  133. // the /proc/net/tcp table but are still present in the node active handles array.
  134. async function getOpenSockets() {
  135. // get open sockets remote and local address:port from /proc/net/tcp
  136. const procNetTcp = '/proc/net/tcp'
  137. const openSockets = new Map()
  138. const lines = await fs.promises.readFile(procNetTcp, 'utf8')
  139. for (const line of lines.split('\n')) {
  140. const socket = parseProcNetTcp(line)
  141. if (socket) {
  142. openSockets.set(socket, line)
  143. }
  144. }
  145. return openSockets
  146. }
  147. function keyFromSocket(socket) {
  148. return `${socket.localAddress}:${socket.localPort} -> ${socket.remoteAddress}:${socket.remotePort}`
  149. }
  150. function decodeHexIpAddress(hex) {
  151. // decode hex ip address to dotted decimal notation
  152. const ip = parseInt(hex, 16)
  153. const a = ip & 0xff
  154. const b = (ip >> 8) & 0xff
  155. const c = (ip >> 16) & 0xff
  156. const d = (ip >> 24) & 0xff
  157. return `${a}.${b}.${c}.${d}`
  158. }
  159. function decodeHexPort(hex) {
  160. // decode hex port to decimal
  161. return parseInt(hex, 16)
  162. }
  163. // Regex for extracting the local and remote addresses and ports from the /proc/net/tcp output
  164. // Example line:
  165. // 16: AB02A8C0:D9E2 86941864:01BB 01 00000000:00000000 02:000004BE 00000000 0 0 36802 2 0000000000000000 28 4 26 10 -1
  166. // ^^^^^^^^^^^^^ ^^^^^^^^^^^^^
  167. // local remote
  168. const TCP_STATE_REGEX =
  169. /^\s*\d+:\s+(?<localHexAddress>[0-9A-F]{8}):(?<localHexPort>[0-9A-F]{4})\s+(?<remoteHexAddress>[0-9A-F]{8}):(?<remoteHexPort>[0-9A-F]{4})/i
  170. function parseProcNetTcp(line) {
  171. const match = line.match(TCP_STATE_REGEX)
  172. if (match) {
  173. const { localHexAddress, localHexPort, remoteHexAddress, remoteHexPort } =
  174. match.groups
  175. return keyFromSocket({
  176. localAddress: decodeHexIpAddress(localHexAddress),
  177. localPort: decodeHexPort(localHexPort),
  178. remoteAddress: decodeHexIpAddress(remoteHexAddress),
  179. remotePort: decodeHexPort(remoteHexPort),
  180. })
  181. }
  182. }
  183. let LeakedSocketsMonitor
  184. // Export the monitor and scanSockets functions
  185. module.exports = LeakedSocketsMonitor = {
  186. monitor(logger) {
  187. const interval = setInterval(
  188. () => LeakedSocketsMonitor.scanSockets(logger),
  189. SOCKET_MONITOR_INTERVAL
  190. )
  191. const Metrics = require('./index')
  192. return Metrics.registerDestructor(() => clearInterval(interval))
  193. },
  194. scanSockets(logger) {
  195. const debugSockets = process._getActiveHandles().filter(handle => {
  196. return handle._ol_debug
  197. })
  198. // Bail out if there are no sockets with the _ol_debug property
  199. if (debugSockets.length === 0) {
  200. return
  201. }
  202. const oldSockets = debugSockets.filter(isOldSocket)
  203. // Bail out if there are no old sockets to log
  204. if (oldSockets.length === 0) {
  205. return
  206. }
  207. // If there old sockets to log, get the connections from /proc/net/tcp
  208. // to distinguish between sockets that are still open and sockets that
  209. // have been closed and removed from the /proc/net/tcp table.
  210. getOpenSockets()
  211. .then(openSockets => {
  212. oldSockets.forEach(handle => {
  213. try {
  214. const key = keyFromSocket(handle)
  215. const tcpinfo = openSockets.get(key)
  216. logOldSocket(logger, handle, tcpinfo)
  217. } catch (err) {
  218. logger.error({ err }, 'error in scanSockets')
  219. }
  220. })
  221. })
  222. .catch(err => {
  223. logger.error({ err }, 'error getting open sockets')
  224. })
  225. },
  226. }