File size: 13,942 Bytes
cd8bd0a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
/**
 * Fase 3 / Epic A β€” TLS-terminating capture for the TPROXY mode (decrypt 2/N).
 *
 * The transparent listener (#4169 `captureMode.ts`) intercepts LOCAL outbound
 * connections and, so far, raw-pipes them to the original destination β€” bodies
 * stay opaque. This module is the decrypt engine: given a raw intercepted socket
 * plus its original destination, it
 *
 *   1. TLS-terminates the CLIENT side with a per-SNI leaf issued on demand by the
 *      dynamic CA (`dynamicCert.ts`, #4173) β€” the client must trust that CA;
 *   2. feeds the decrypted plaintext to an internal `http.Server` (Node parses the
 *      request automatically, exactly like `inspector/httpProxyServer.ts`);
 *   3. captures the exchange into the Traffic Inspector buffer with
 *      `source: "tproxy"` (sanitized headers + masked bodies);
 *   4. forwards the request to the original destination, RE-encrypted. The forward
 *      seam is injected so the real path can mark its upstream socket with the
 *      bypass SO_MARK (`connectMarked`) β€” without that, the OUTPUT-based TPROXY
 *      rule would re-intercept the proxy's own forward and loop.
 *
 * Every effectful seam (`buffer`, `forward`, `now`, `randomId`) is injected so the
 * decrypt + capture path is unit-testable with a real local TLS round-trip β€” no
 * root, no iptables, no native addon. The anti-loop forward (`realForward`, which
 * needs `connectMarked`) is the only kernel-dependent piece and is validated e2e
 * on the VPS when wired into the transparent listener (3/N).
 */
import http from "node:http";
import https from "node:https";
import net from "node:net";
import tls from "node:tls";
import { randomUUID } from "node:crypto";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
import { sanitizeHeaders } from "../sanitizeHeaders.ts";
import { maskSecret } from "../maskSecrets.ts";
import { MITM_IDLE_TIMEOUT_MS } from "../socketTimeouts.ts";
import { globalTrafficBuffer } from "../inspector/buffer.ts";
import type { InterceptedRequest } from "../inspector/types.ts";
import type { DynamicCertStore } from "./dynamicCert.ts";
import { connectMarked } from "./transparentSocket.ts";

/** Default bypass SO_MARK for the forward path (anti-loop). Matches captureMode. */
export const DEFAULT_BYPASS_MARK = 0x539;

/** First byte of a TLS record carrying a handshake (ClientHello) β€” RFC 8446 Β§5.1. */
export function isTlsClientHello(firstByte: number): boolean {
  return firstByte === 0x16;
}

/**
 * Host to display/route as: prefer the SNI servername the client requested, then
 * the `Host` header (port stripped), then the raw destination IP as a last resort.
 */
export function resolveCaptureHost(
  sniServername: string | undefined,
  hostHeader: string | undefined,
  destIp: string
): string {
  const sni = (sniServername ?? "").trim();
  if (sni) return sni;
  const host = (hostHeader ?? "").trim();
  if (host) return host.replace(/:\d+$/, "");
  return destIp;
}

/** Original destination of an intercepted connection (TPROXY preserves it). */
export interface DecryptedDest {
  ip: string;
  port: number;
  /** SNI servername, when already known (otherwise read off the TLS socket). */
  sni?: string;
}

export interface ForwardInit {
  method: string;
  path: string;
  headers: Record<string, string>;
  body: Buffer;
}

export interface ForwardResult {
  status: number;
  headers: Record<string, string>;
  body: Buffer;
}

export interface TlsCaptureDeps {
  buffer: Pick<typeof globalTrafficBuffer, "push" | "update">;
  /** Forward the decrypted request to `dest`, re-encrypted. Injectable for tests
   * and so the real path can SO_MARK its upstream socket (anti-loop). */
  forward: (dest: DecryptedDest, init: ForwardInit) => Promise<ForwardResult>;
  /** Monotonic clock for latency (default `performance.now`). */
  now: () => number;
  /** Request id generator (default `randomUUID`). */
  randomId: () => string;
}

function defaultDeps(overrides: Partial<TlsCaptureDeps>): TlsCaptureDeps {
  return {
    buffer: globalTrafficBuffer,
    forward: realForward,
    now: () => performance.now(),
    randomId: () => randomUUID(),
    ...overrides,
  };
}

async function readBody(req: http.IncomingMessage): Promise<Buffer> {
  const chunks: Buffer[] = [];
  for await (const chunk of req) {
    chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
  }
  return Buffer.concat(chunks);
}

/**
 * Headers for the upstream forward: drop hop-by-hop/framing fields (so Node sets
 * its own) but KEEP auth so the upstream still authenticates, and pin `host` to
 * the resolved capture host. Mirrors `httpProxyServer.buildFetchHeaders`.
 */
export function buildForwardHeaders(
  raw: http.IncomingHttpHeaders,
  host: string
): Record<string, string> {
  const out: Record<string, string> = {};
  for (const [name, value] of Object.entries(raw)) {
    if (value === undefined || value === null) continue;
    const lower = name.toLowerCase();
    if (
      lower === "host" ||
      lower === "connection" ||
      lower === "keep-alive" ||
      lower === "proxy-authenticate" ||
      lower === "proxy-authorization" ||
      lower === "te" ||
      lower === "trailer" ||
      lower === "transfer-encoding" ||
      lower === "upgrade" ||
      lower === "content-length"
    ) {
      continue;
    }
    out[lower] = Array.isArray(value) ? value.join(", ") : String(value);
  }
  out.host = host;
  return out;
}

/**
 * Handle one decrypted request: capture it (source "tproxy"), forward it to the
 * original destination, relay the response, and record the full exchange. Mirrors
 * `httpProxyServer.handleHttp` but the destination comes from TPROXY (not an
 * absolute proxy URL) and bodies are visible because the TLS was terminated.
 */
export function handleDecryptedRequest(
  req: http.IncomingMessage,
  res: http.ServerResponse,
  dest: DecryptedDest,
  deps: TlsCaptureDeps
): void {
  const startedAt = deps.now();
  const socket = req.socket as tls.TLSSocket;
  const sni = dest.sni ?? (typeof socket.servername === "string" ? socket.servername : undefined);
  const host = resolveCaptureHost(sni, req.headers.host, dest.ip);
  const path = req.url ?? "/";

  const intercepted: InterceptedRequest = {
    id: deps.randomId(),
    source: "tproxy",
    timestamp: new Date().toISOString(),
    method: req.method ?? "GET",
    host,
    path,
    requestHeaders: sanitizeHeaders(req.headers),
    requestBody: null,
    requestSize: 0,
    responseHeaders: {},
    responseBody: null,
    responseSize: 0,
    status: "in-flight",
  };

  deps.buffer.push(intercepted);

  void (async () => {
    try {
      const body = await readBody(req);
      intercepted.requestSize = body.length;
      intercepted.requestBody = body.length > 0 ? maskSecret(body.toString("utf8")) : null;

      const result = await deps.forward(
        { ip: dest.ip, port: dest.port, sni },
        { method: req.method ?? "GET", path, headers: buildForwardHeaders(req.headers, host), body }
      );

      const totalLatencyMs = deps.now() - startedAt;
      intercepted.responseHeaders = sanitizeHeaders(result.headers);
      intercepted.responseBody = maskSecret(result.body.toString("utf8"));
      intercepted.responseSize = result.body.length;
      intercepted.status = result.status;
      intercepted.totalLatencyMs = totalLatencyMs;
      intercepted.upstreamLatencyMs = totalLatencyMs;
      intercepted.proxyLatencyMs = 0;

      const safeRespHeaders: Record<string, string> = {};
      for (const [k, v] of Object.entries(result.headers)) {
        const lk = k.toLowerCase();
        if (lk === "content-length" || lk === "transfer-encoding") continue;
        safeRespHeaders[k] = v;
      }
      res.writeHead(result.status, safeRespHeaders);
      res.end(result.body);

      deps.buffer.update(intercepted.id, intercepted);
    } catch (err) {
      intercepted.status = "error";
      intercepted.error = sanitizeErrorMessage(err);
      intercepted.totalLatencyMs = deps.now() - startedAt;
      deps.buffer.update(intercepted.id, intercepted);
      if (!res.headersSent) {
        res.writeHead(502, { "content-type": "text/plain" });
        res.end("Bad Gateway");
      } else {
        res.end();
      }
    }
  })();
}

export interface TlsCaptureServer {
  /** Internal HTTP server that parses the decrypted plaintext. */
  server: http.Server;
  /** TLS-terminate a raw intercepted socket and capture/forward the exchange. */
  terminate(rawClient: net.Socket, dest: DecryptedDest): void;
  /** Close the internal server. */
  close(): Promise<void>;
}

/**
 * Build the decrypt engine: an internal `http.Server` whose request handler
 * captures + forwards, plus `terminate()` to feed it a raw intercepted socket.
 */
export function createTlsCaptureServer(
  certStore: Pick<DynamicCertStore, "createSNICallback">,
  deps: Partial<TlsCaptureDeps> = {}
): TlsCaptureServer {
  const resolved = defaultDeps(deps);
  const pending = new WeakMap<object, DecryptedDest>();
  const server = http.createServer();

  // Bound lifetimes so a hung decrypted tunnel cannot exhaust file descriptors.
  server.requestTimeout = MITM_IDLE_TIMEOUT_MS * 5;
  server.headersTimeout = MITM_IDLE_TIMEOUT_MS;
  server.keepAliveTimeout = MITM_IDLE_TIMEOUT_MS;

  server.on("request", (req, res) => {
    const dest = pending.get(req.socket) ?? { ip: "", port: 0 };
    handleDecryptedRequest(req, res, dest, resolved);
  });

  const sniCallback = certStore.createSNICallback();

  return {
    server,
    terminate(rawClient, dest) {
      const tlsSocket = new tls.TLSSocket(rawClient, {
        isServer: true,
        SNICallback: sniCallback,
      });
      pending.set(tlsSocket, dest);
      tlsSocket.on("error", () => {
        try {
          rawClient.destroy();
        } catch {
          // already gone
        }
      });
      // Hand the decrypted stream to the HTTP parser (the MITM termination trick).
      server.emit("connection", tlsSocket);
    },
    close: () =>
      new Promise<void>((resolve) => {
        // Destroy any lingering decrypted sockets so their idle timers don't keep
        // the event loop alive past close (Node 18.2+).
        server.closeAllConnections?.();
        server.close(() => resolve());
      }),
  };
}

/**
 * Build a forward function that opens its upstream TCP socket via `connectRaw`,
 * then re-encrypts to the original destination over TLS. `connectRaw` is the
 * anti-loop seam: the real path marks the socket (`connectMarked`) so the
 * OUTPUT-based TPROXY rule excludes the proxy's own forward; tests pass a plain
 * `net.connect`.
 *
 * `rejectUnauthorized` defaults to `true` (secure by default): the upstream cert
 * is validated against `servername` (the SNI/Host the client requested), mirroring
 * what the original client would do β€” the proxy must not silently accept an
 * upstream cert the client itself would reject. Callers talking to a self-signed
 * upstream (e.g. tests) must opt in explicitly with `{ rejectUnauthorized: false }`.
 */
export function createForward(
  connectRaw: (ip: string, port: number) => net.Socket,
  opts: { rejectUnauthorized?: boolean } = {}
): TlsCaptureDeps["forward"] {
  const rejectUnauthorized = opts.rejectUnauthorized ?? true;
  return (dest, init) =>
    new Promise<ForwardResult>((resolve, reject) => {
      const servername = dest.sni || String(init.headers.host || dest.ip);
      // The bypass-marked socket MUST live on the Agent's `createConnection`:
      // `https.request({ createConnection })` is silently IGNORED whenever an
      // agent is present β€” and `agent: false` still installs a fresh default
      // Agent, so the request option never runs and the forward would open its
      // own UNMARKED socket, breaking the anti-loop (TPROXY would re-intercept
      // the proxy's own forward β†’ infinite loop). Verified e2e on the VPS.
      const agent = new https.Agent({ maxSockets: 1, keepAlive: false });
      (agent as unknown as { createConnection: () => net.Socket }).createConnection = () =>
        tls.connect({
          socket: connectRaw(dest.ip, dest.port),
          servername,
          rejectUnauthorized,
        }) as unknown as net.Socket;
      let req: http.ClientRequest;
      try {
        req = https.request(
          {
            host: dest.ip,
            port: dest.port,
            method: init.method,
            path: init.path,
            headers: init.headers,
            servername,
            rejectUnauthorized,
            agent,
          },
          (upstream) => {
            const chunks: Buffer[] = [];
            upstream.on("data", (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c)));
            upstream.on("end", () => {
              const headers: Record<string, string> = {};
              for (const [k, v] of Object.entries(upstream.headers)) {
                if (v === undefined) continue;
                headers[k] = Array.isArray(v) ? v.join(", ") : String(v);
              }
              resolve({ status: upstream.statusCode ?? 0, headers, body: Buffer.concat(chunks) });
            });
          }
        );
      } catch (err) {
        reject(err);
        return;
      }
      req.once("error", reject);
      if (init.body.length > 0) req.write(init.body);
      req.end();
    });
}

/**
 * Production forward: re-encrypt to the original destination over a socket marked
 * with the bypass SO_MARK BEFORE connect, so the OUTPUT-based TPROXY rule excludes
 * it (anti-loop). Requires the native addon β€” exercised e2e on the VPS (3/N).
 * The upstream cert is verified (`rejectUnauthorized` defaults to `true`), so the
 * proxy rejects exactly what the original client would have rejected.
 */
export const realForward: TlsCaptureDeps["forward"] = createForward(
  (ip, port) => new net.Socket({ fd: connectMarked(ip, port, DEFAULT_BYPASS_MARK) })
);