import * as _ from 'lodash'; import * as os from 'os'; import * as net from 'net'; import * as tls from 'tls'; import * as http2 from 'http2'; import { isNode } from './util'; const now = () => performance.now(); import { OngoingRequest, RawPassthroughEvent, TimingEvents, TlsConnectionEvent } from '../types'; import { SocketTimingInfo, InitialRemoteAddress, InitialRemotePort, TlsMetadata, SocketMetadata } from './socket-extensions'; import { getSocketMetadataTags } from './socket-metadata'; import { normalizeIP } from './ip-utils'; // Test if a local port for a given interface (IPv4/6) is currently in use, by attempting // to bind to it. We reuse a single server instance and cache results to avoid creating // a new server on every request. Concurrent checks for the same port share one probe. const probeServer = net.createServer(); const portActiveCache = new Map(); const portActiveInFlight = new Map>(); const PORT_ACTIVE_CACHE_TTL = 5000; // 5 seconds export async function isLocalPortActive(interfaceIp: '::1' | '127.0.0.1', port: number) { if (interfaceIp === '::1' && !isLocalIPv6Available) return false; const cacheKey = `${interfaceIp}:${port}`; const cached = portActiveCache.get(cacheKey); if (cached && cached.expires > Date.now()) { return cached.result; } // Deduplicate concurrent checks for the same address:port let probe = portActiveInFlight.get(cacheKey); if (!probe) { probe = probePort(interfaceIp, port); portActiveInFlight.set(cacheKey, probe); probe.then((result) => { portActiveCache.set(cacheKey, { result, expires: Date.now() + PORT_ACTIVE_CACHE_TTL }); portActiveInFlight.delete(cacheKey); }); } return probe; } // Serialized via the in-flight map above — only one probe runs at a time per key, // and different keys won't collide because they share the single probeServer sequentially // via the listen/close cycle. let probeServerReady = Promise.resolve(); function probePort(interfaceIp: string, port: number): Promise { const result = probeServerReady.then(() => new Promise((resolve) => { probeServer.listen({ host: interfaceIp, port, ipv6Only: interfaceIp === '::1' }); probeServer.once('listening', () => { probeServer.close(() => resolve(false)); }); probeServer.once('error', () => { resolve(true); }); }) ); // Chain so the next probe waits for this one to fully complete probeServerReady = result.then(() => {}); return result; } // This file imported in browsers etc as it's used in handlers, but none of these methods are used // directly. It is useful though to guard sections that immediately perform actions: export const isLocalIPv6Available = isNode ? _.some(os.networkInterfaces(), (addresses) => _.some(addresses, a => a.address === '::1') ) : true; // Check whether an incoming socket is the other end of one of our outgoing sockets: export const isSocketLoop = (outgoingSockets: net.Socket[] | Set, incomingSocket: net.Socket) => // We effectively just compare the address & port: if they match, we've almost certainly got a loop. // I don't think it's generally possible to see the same ip on different interfaces from one process (you need // ip-netns network namespaces), but if it is, then there's a tiny chance of false positives here. If we have ip X, // and on another interface somebody else has ip X, and they send a request with the same incoming port as an // outgoing request we have on the other interface, we'll assume it's a loop. Extremely unlikely imo. _.some([...outgoingSockets], (outgoingSocket) => { if (!outgoingSocket.localAddress || !outgoingSocket.localPort) { // It's possible for sockets in outgoingSockets to be closed, in which case these properties // will be undefined. If so, we know they're not relevant to loops, so skip entirely. return false; } else { return normalizeIP(outgoingSocket.localAddress) === normalizeIP(incomingSocket.remoteAddress) && outgoingSocket.localPort === incomingSocket.remotePort; } }); export function getParentSocket(socket: net.Socket) { return socket._parent || // TLS wrapper socket.stream || // SocketWrapper (socket as any)._handle?._parentWrap?.stream; // HTTP/2 CONNECT'd TLS wrapper } const isSocketResetSupported = isNode ? !!net.Socket.prototype.resetAndDestroy : false; // Avoid errors in browsers export const requireSocketResetSupport = () => { if (!isSocketResetSupported) { throw new Error( 'Connection reset is only supported in Node v16.17+, v18.3.0+, or later' ); } }; const isHttp2Stream = (maybeStream: any): maybeStream is http2.Http2ServerRequest => 'httpVersion' in maybeStream && maybeStream.httpVersion?.startsWith('2'); /** * Reset the socket where possible, or at least destroy it where that's not possible. * * This has a few cases for different layers of socket & tunneling, designed to * simulate a real connection reset as closely as possible. That means, in general, * we unwrap the connection as far as possible whilst still only affecting a single * request. * * In practice, we unwrap HTTP/1 & TLS back as far as we can, until we hit either an * HTTP/2 stream or a raw TCP connection. We then either send a RST_FRAME or a TCP RST * to kill that connection. */ export function resetOrDestroy(requestOrSocket: | net.Socket | OngoingRequest & { socket?: net.Socket } | http2.Http2ServerRequest ) { let primarySocket: net.Socket | http2.Http2Stream = (isHttp2Stream(requestOrSocket) && requestOrSocket.stream) ? requestOrSocket.stream : ('socket' in requestOrSocket && requestOrSocket.socket) ? requestOrSocket.socket : requestOrSocket as net.Socket; let socket = primarySocket; while (socket instanceof tls.TLSSocket) { const parent = getParentSocket(socket); if (!parent) break; // Not clear why, but it seems in some cases we run out of parents here socket = parent; } if ('rstCode' in socket) { // It's an HTTP/2 stream instance - let's kill it here. // If it's the innermost stream, i.e. this is the stream of the request we're // resetting, then we want to send an internal error. If it's a tunneling // stream, then we want to send a CONNECT error: const isOuterSocket = socket === (requestOrSocket as any).stream; const errorCode = isOuterSocket ? http2.constants.NGHTTP2_INTERNAL_ERROR : http2.constants.NGHTTP2_CONNECT_ERROR; const h2Stream = socket as http2.ServerHttp2Stream; h2Stream.close(errorCode); } else { // Must be a net.Socket then, so we let's reset it for real: if (isSocketResetSupported) { try { socket.resetAndDestroy!(); } catch (error) { // This could fail in funky ways if the socket is not just the right kind // of socket. We should still fail in that case, but it's useful to log // some extra data first beforehand, so we can fix this if it ever happens: console.warn(`Failed to reset on socket of type ${ socket.constructor.name } with parent of type ${getParentSocket(socket as any)?.constructor.name}`); throw error; } } else { socket.destroy(); } } // Explicitly mark the top-level socket as destroyed too. This isn't always required, but // is good for backwards compat ( { const timingInfo = socket[SocketTimingInfo] || socket._parent?.[SocketTimingInfo] || buildSocketTimingInfo(); return { remoteIpAddress: socket.remoteAddress || // Normal case socket._parent?.remoteAddress || // Pre-certCB TLS error, e.g. timeout socket[InitialRemoteAddress]!, // Post-certcb, recorded by monkeypatch remotePort: socket.remotePort || socket._parent?.remotePort || socket[InitialRemotePort]!, tags: getSocketMetadataTags(socket[SocketMetadata]), timingEvents: { startTime: timingInfo.initialSocket, connectTimestamp: timingInfo.initialSocketTimestamp, tunnelTimestamp: timingInfo.tunnelSetupTimestamp } }; } export function buildTlsSocketEventData( socket: net.Socket & Partial ): Omit & TlsConnectionEvent { const rawSocketData = buildRawSocketEventData(socket) as Partial; const timingInfo = socket[SocketTimingInfo] || socket._parent?.[SocketTimingInfo] || buildSocketTimingInfo(); rawSocketData.timingEvents!.handshakeTimestamp = timingInfo.tlsConnectedTimestamp; // Attached in passThroughMatchingTls TLS sniffing logic in http-combo-server: rawSocketData.tlsMetadata = socket[TlsMetadata] || socket._parent?.[TlsMetadata] || {}; return rawSocketData as any; } export function buildSocketTimingInfo(): Required[typeof SocketTimingInfo] { return { initialSocket: Date.now(), initialSocketTimestamp: now() }; } // If we get an error linked to a socket, we want to calculate a rough estimate of when the // request was started, using the socket timing data: export function buildSocketErrorRequestTimings(socket: net.Socket | undefined): TimingEvents { if (socket?.[SocketTimingInfo]) { if (socket[SocketTimingInfo].lastRequestTimestamp) { // If this isn't the first request (or was partially received) we use the // most recent received request time as the start of this request: const startTimestamp = socket[SocketTimingInfo].lastRequestTimestamp; const startTime = socket[SocketTimingInfo].initialSocket + (startTimestamp - socket[SocketTimingInfo].initialSocketTimestamp); return { startTime, startTimestamp }; } else { // If this is the first request, treat socket creation as the start: return { startTime: socket[SocketTimingInfo].initialSocket, startTimestamp: socket[SocketTimingInfo].initialSocketTimestamp }; } } else { return { startTime: Date.now(), startTimestamp: now() }; } }