File size: 4,360 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
/**
 * Fase 3 / Epic A — TPROXY capture-mode manager (decrypt 4a/N).
 *
 * Singleton lifecycle around the decrypt-capable transparent listener
 * (`captureMode.ts`): builds the dynamic CA (#4173), gates on native-addon
 * availability, holds the single running handle, counts interceptions, and
 * exposes start / stop / status for a (local-only) route to drive.
 *
 * The OS trust-store mechanism is deliberately NOT baked in here: `installCa` /
 * `uninstallCa` are injected by the caller (the route / VPS e2e provides the real
 * installer, which needs its own trust-store slot so it does not clobber the
 * static MITM cert). This keeps the manager unit-testable without root and means
 * it never mutates the trust store on its own.
 */
import { DynamicCertStore } from "./dynamicCert";
import { startTproxyCapture, type TproxyCaptureHandle } from "./captureMode";
import { isTransparentSocketAvailable } from "./transparentSocket";
import type { TproxyConfig } from "./commands";

export interface CaptureManagerStatus {
  /** Whether a capture session is currently running. */
  running: boolean;
  /** Whether the native IP_TRANSPARENT addon is loadable on this host. */
  available: boolean;
  /** ISO timestamp of when the running session started. */
  startedAt?: string;
  /** Number of connections intercepted in the running session. */
  interceptCount?: number;
  /** The transparent listener port of the running session. */
  onPort?: number;
}

export interface CaptureManagerDeps {
  startTproxyCapture: typeof startTproxyCapture;
  isAvailable: () => boolean;
  createCertStore: () => DynamicCertStore;
  now: () => string;
}

const realDeps: CaptureManagerDeps = {
  startTproxyCapture,
  isAvailable: isTransparentSocketAvailable,
  createCertStore: () => new DynamicCertStore(),
  now: () => new Date().toISOString(),
};

export interface StartCaptureModeOptions {
  cfg: TproxyConfig;
  /** Install the dynamic CA cert (PEM) into the OS trust store. Injected so the
   * manager never touches the trust store itself. */
  installCa: (caPem: string) => Promise<void>;
  /** Remove the CA from the trust store on stop (symmetric teardown). Injected. */
  uninstallCa: () => Promise<void>;
  /** Seam overrides for unit testing. */
  deps?: Partial<CaptureManagerDeps>;
}

interface ActiveCapture {
  handle: TproxyCaptureHandle;
  startedAt: string;
  intercepts: { count: number };
}

let active: ActiveCapture | null = null;

/**
 * Start the decrypt-capable TPROXY capture mode. Rejects if a session is already
 * running or the native addon is unavailable. The dynamic CA is created here and
 * installed in the trust store via the injected `installCa`.
 */
export async function startCaptureMode(
  options: StartCaptureModeOptions
): Promise<CaptureManagerStatus> {
  if (active) throw new Error("TPROXY capture mode is already running");

  const deps: CaptureManagerDeps = { ...realDeps, ...options.deps };
  if (!deps.isAvailable()) {
    throw new Error("TPROXY capture mode requires the native addon (Linux + CAP_NET_ADMIN).");
  }

  const certStore = deps.createCertStore();
  const intercepts = { count: 0 };
  const handle = await deps.startTproxyCapture(options.cfg, {
    decrypt: { certStore, installCa: options.installCa, uninstallCa: options.uninstallCa },
    onIntercept: () => {
      intercepts.count += 1;
    },
  });
  active = { handle, startedAt: deps.now(), intercepts };
  return getCaptureStatus();
}

/** Stop the running capture session (closes the listener, uninstalls the CA, and
 * reverts the rules via the handle). Idempotent — a no-op when nothing runs. */
export async function stopCaptureMode(): Promise<CaptureManagerStatus> {
  const current = active;
  active = null;
  if (current) await current.handle.stop();
  return getCaptureStatus();
}

/** Current capture-mode status (safe to call any time, including when idle). */
export function getCaptureStatus(): CaptureManagerStatus {
  const available = isTransparentSocketAvailable();
  if (!active) return { running: false, available };
  return {
    running: true,
    available,
    startedAt: active.startedAt,
    interceptCount: active.intercepts.count,
    onPort: active.handle.cfg.onPort,
  };
}

/** Test-only: clear the singleton without invoking teardown. */
export function __resetCaptureManager(): void {
  active = null;
}