Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/tcp-probe-v2-session-record.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@prosopo/types": minor
"@prosopo/types-database": minor
"@prosopo/provider": minor
---

Read the new X-TCP-Probe header from chaddy and save the whole record on the Session as `tcpProbe`. This adds IPv6 connections and the packets sent after the first handshake to what we store. The older X-TLS-* signals keep working.
2 changes: 1 addition & 1 deletion docker/images/caddy/src/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ FROM caddy:2.11.4-builder@sha256:401121e61853cb9c7df83cba43e532e49a68e30f32921c7
RUN apk add --no-cache gcc g++ make libpcap-dev libpcap
RUN CGO_ENABLED=1 xcaddy build v2.11.4 \
--with github.com/mholt/caddy-ratelimit@v0.1.0 \
--with github.com/prosopo/chaddy@v0.0.0-20261102144457-c3c68b316aa5 \
--with github.com/prosopo/chaddy@v0.0.0-20261109164248-faf69736830d \
--with github.com/lolPants/caddy-requestid@v1.1.2 \
--with github.com/mholt/caddy-l4@v0.1.2
FROM caddy:2.11.4@sha256:0c994536bddb66445885237f1a5dcc1916bccea922661c76b4e9fc24061f9b52
Expand Down
54 changes: 24 additions & 30 deletions docker/provider/docker-compose.provider.tcp-probe.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,28 @@
# -f docker-compose.provider.tcp-probe.yml up -d
# The caddy service in the base already mounts /var/run/tcp-probe read-only.
services:
# eBPF sidecar that captures raw TCP handshake data (SYN / SYN-ACK / ACK)
# off the WAN interface and serves per-request lookups over a Unix socket.
# Chaddy (adjacent `caddy` service) reads that socket at accept() time and
# forwards the raw fields (`X-TLS-Syn-Ns`, `X-TLS-Observed-Ttl`, `X-TLS-Tcp-*`)
# onto the reverse-proxy request; the provider persists them onto the Session
# record. Nothing here is on by default: the service lives only in this
# overlay, so the base stack neither pulls nor starts it. Ansible layers it
# on for hosts with `tcp_probe_enabled`. The image is published from
# `protect/tcp-probe`; see `deploy/DEPLOY.md` there
# for the bumblebee-side reference deployment; the same wire protocol
# (12-byte req, 80- or 104-byte reply) and image are reused here.
caddy:
depends_on:
# Must attach first: only listeners opened afterwards keep the saved SYN.
tcp-probe:
condition: service_started
group_add:
- "${TCP_PROBE_GID:-10500}"
# eBPF sidecar that records each connection's TCP handshake from a sockops
# program on the root cgroup and serves per-connection lookups over a Unix
# socket. Chaddy reads that socket and forwards the record as `X-TCP-Probe`;
# the provider persists it onto the Session record. The service lives only
# in this overlay, so the base stack neither pulls nor starts it. Ansible
# layers it on for hosts with `tcp_probe_enabled`. The image is published
# from `protect/tcp-probe`.
#
# `network_mode: host` is mandatory: XDP/TC hooks attach to real host
# interfaces, and the container-network veth wouldn't see the outside
# traffic. Caps are the minimum needed to load BPF programs and attach
# them; the probe never modifies traffic (XDP_PASS / TC_ACT_PIPE) so a
# crash cannot black-hole requests — worst case is the socket disappears
# and chaddy silently degrades to the pre-sidecar header set.
# `network_mode: host` and `cgroup: host` are mandatory: the program attaches
# to the host's root cgroup. It never modifies traffic, so a crash cannot
# black-hole requests; chaddy just stops adding the header.
tcp-probe:
container_name: tcp-probe
# Pinned, not `:latest`. The probe's reply is a bare fixed-size struct
# with no version in it, so the tag is the only thing that says which
# layout a host is serving — and publishing `:latest` would change that
# on every pronode at the next pull, with nobody running a deploy. Set
# TCP_PROBE_IMAGE explicitly to move a host. 0.1.1 serves the 80-byte
# record; chaddy reads that and the 104-byte one (prosopo/chaddy#17).
image: prosopo/tcp-probe:${TCP_PROBE_IMAGE:-0.1.1}
# Pinned, not `:latest`, so publishing cannot change a host without a deploy.
image: prosopo/tcp-probe:${TCP_PROBE_IMAGE:?set TCP_PROBE_IMAGE to a version}
network_mode: host
cap_add:
- BPF
Expand All @@ -44,15 +39,14 @@ services:
# containers see the same inode. Docker will create the dir if it
# doesn't exist yet.
- /var/run/tcp-probe:/var/run/tcp-probe
- /sys/fs/cgroup:/sys/fs/cgroup:ro
cgroup: host
command:
- "--iface"
# Each pronode's WAN NIC name is different (contabo/hetzner/etc.);
# ansible sets TCP_PROBE_IFACE per-host in .env.1.$NODE_ENV.
- "${TCP_PROBE_IFACE}"
# The port the listener sees, after Docker's DNAT.
- "--port"
- "443"
- "--format"
- "jsonl"
- "--socket-gid"
- "${TCP_PROBE_GID:-10500}"
- "--lookup-socket"
- "/var/run/tcp-probe/lookup.sock"
restart: unless-stopped
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { TcpProbeRecord } from "@prosopo/types";
import { ModeEnum } from "@prosopo/types";
import type { Response } from "express";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
Expand Down Expand Up @@ -123,4 +124,20 @@ describe("runEmptyDetectorPoolPowFallback", () => {

expect(sendPowCaptcha).not.toHaveBeenCalled();
});

it("keeps the tcp-probe record on the bypass session", async () => {
initDetectorBundlePool(dir);
const sendPowCaptcha = vi.fn(async (_params: unknown) => ({
captchaType: "pow",
}));
const tcpProbe: TcpProbeRecord = { v: 2, hit: false, reason: "no_record" };
const { res } = makeRes();

await runEmptyDetectorPoolPowFallback(
{ ...makeInput(sendPowCaptcha), tcpProbe },
res,
);

expect(JSON.stringify(sendPowCaptcha.mock.calls[0])).toContain("no_record");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
type ModeEnum,
type RequestHeaders,
type ScoreComponents,
type TcpProbeRecord,
clampImageRounds,
isConcreteCaptchaType,
} from "@prosopo/types";
Expand Down Expand Up @@ -57,6 +58,7 @@ export type ShortCircuitInput = {
detectorSessionId?: string;
tcpToChelloUs?: number;
chelloToHandshakeUs?: number;
tcpProbe?: TcpProbeRecord;
// Carried onto the session so a site pinned to a captcha type still
// records refreshes, and its refresh limit can still apply.
refresh?: RefreshLineage;
Expand Down Expand Up @@ -113,6 +115,7 @@ const buildBypassSessionParams = async (input: ShortCircuitInput) => {
...(input.chelloToHandshakeUs !== undefined && {
chelloToHandshakeUs: input.chelloToHandshakeUs,
}),
...(input.tcpProbe !== undefined && { tcpProbe: input.tcpProbe }),
};
};

Expand Down
43 changes: 41 additions & 2 deletions packages/provider/src/api/rawTlsSignalsMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import type { IncomingHttpHeaders } from "node:http";
import { handleErrors } from "@prosopo/api-express-router";
import { type Logger, getLogger } from "@prosopo/logger";
import { type TcpProbeRecord, TcpProbeRecordSchema } from "@prosopo/types";
import type { ProviderEnvironment } from "@prosopo/types-env";
import { parseInteger } from "@prosopo/util";
import type { NextFunction, Request, Response } from "express";
Expand Down Expand Up @@ -64,6 +65,10 @@ const HEADER_TLS_HANDSHAKE_RTT_US = "x-tls-handshake-rtt-us";
const HEADER_TCP_OPTS_FLAGS = "x-tls-tcp-opts-flags";
const HEADER_TCP_OPTS_ORDER = "x-tls-tcp-opts-order";

const HEADER_TCP_PROBE = "x-tcp-probe";
// A full record is about 3KB of base64url; anything far larger is not chaddy's.
const MAX_TCP_PROBE_HEADER_CHARS = 8192;

export interface RawTlsSignals {
// Kernel monotonic ns timestamps of the TCP 3-way handshake — captured by
// the eBPF probe on the WAN interface. Boot-relative; only meaningful in
Expand Down Expand Up @@ -150,6 +155,9 @@ export interface RawTlsSignals {
// all. Superseded by `tcpOptsKinds`. Only present on sessions served
// by a chaddy older than prosopo/chaddy#16.
tcpOptsOrder?: number;
// The whole tcp-probe v2 record, stored as sent. Unlike the fields above it
// covers IPv6 and the packets after the ClientHello.
tcpProbe?: TcpProbeRecord;
// Microseconds from the server's TLS handshake flight to the client's
// reply: a round trip to whatever terminates TLS, where synackNs → ackNs
// is a round trip to whatever opened the TCP connection. Includes the
Expand Down Expand Up @@ -185,6 +193,7 @@ const RAW_TLS_SIGNAL_KEYS = [
"ipTos",
"tcpOptsFlags",
"tcpOptsOrder",
"tcpProbe",
"tlsHandshakeRttUs",
] as const satisfies readonly (keyof RawTlsSignals)[];

Expand Down Expand Up @@ -294,12 +303,41 @@ const parseOptsKindsHeader = (
return kinds.length > 0 ? kinds : undefined;
};

export const parseTcpProbeHeader = (
raw: string | string[] | undefined,
logger: Logger,
): TcpProbeRecord | undefined => {
const value = Array.isArray(raw) ? raw[0] : raw;
if (value === undefined) {
return undefined;
}
const reject = (reason: string): undefined => {
logger.debug(() => ({
msg: "Ignoring malformed tcp-probe header",
data: { reason, length: value.length },
}));
return undefined;
};
if (value.length > MAX_TCP_PROBE_HEADER_CHARS) {
return reject("too_long");
}
let decoded: unknown;
try {
decoded = JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
} catch {
return reject("not_json");
}
const parsed = TcpProbeRecordSchema.safeParse(decoded);
return parsed.success ? parsed.data : reject("bad_shape");
};

export const getRawTlsSignals = (
headers: IncomingHttpHeaders,
logger?: Logger,
): RawTlsSignals => {
const log = logger ?? getLogger("info", "provider:raw-tls-signals");
return {
tcpProbe: parseTcpProbeHeader(headers[HEADER_TCP_PROBE], log),
synNs: parseIntHeader(headers[HEADER_SYN_NS], log, HEADER_SYN_NS, MAX_NS),
synackNs: parseIntHeader(
headers[HEADER_SYNACK_NS],
Expand Down Expand Up @@ -465,9 +503,10 @@ export const rawTlsSignalsMiddleware = (env: ProviderEnvironment) => {
}
}

const hasAny = Object.values(signals).some((v) => v !== undefined);
const { tcpProbe, ...scalarSignals } = signals;
const hasAny = Object.values(scalarSignals).some((v) => v !== undefined);
if (hasAny) {
req.logger = req.logger.with(signals, "rawTlsSignals");
req.logger = req.logger.with(scalarSignals, "rawTlsSignals");
}
next();
} catch (err) {
Expand Down
4 changes: 3 additions & 1 deletion packages/provider/src/express.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@

import type { I18nLookup, TranslateFn } from "@prosopo/locale";
import type { Logger } from "@prosopo/logger";
import type { IPInfoResponse } from "@prosopo/types";
import type { IPInfoResponse, TcpProbeRecord } from "@prosopo/types";
export interface AugmentedRequest {
t: TranslateFn;
i18n: I18nLookup;
Expand Down Expand Up @@ -59,6 +59,7 @@ export interface AugmentedRequest {
// older than prosopo/chaddy#16.
tcpOptsFlags?: number;
tcpOptsOrder?: number;
tcpProbe?: TcpProbeRecord;
tlsHandshakeRttUs?: number;
}

Expand Down Expand Up @@ -96,6 +97,7 @@ declare global {
ipTos?: number;
tcpOptsFlags?: number;
tcpOptsOrder?: number;
tcpProbe?: TcpProbeRecord;
tlsHandshakeRttUs?: number;
}
interface Locals {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@

import type { IncomingHttpHeaders } from "node:http";
import type { Logger } from "@prosopo/logger";
import type { TcpProbeHit } from "@prosopo/types";
import { describe, expect, it, vi } from "vitest";
import {
getRawTlsSignals,
parseTcpProbeHeader,
rawTlsSignalsForSession,
rawTlsSignalsFromRecord,
} from "../../../api/rawTlsSignalsMiddleware.js";
Expand Down Expand Up @@ -124,7 +126,106 @@ describe("getRawTlsSignals", () => {
});
});

const encodeProbe = (body: unknown): string =>
Buffer.from(JSON.stringify(body), "utf8").toString("base64url");

const probeHit: TcpProbeHit = {
v: 2,
hit: true,
flags: 15,
remote_ip: "2001:db8::1",
remote_port: 51000,
local_ip: "2001:db8::2",
local_port: 443,
is_v6: true,
syn_ns: 2 ** 60,
est_ns: 2 ** 60,
est_srtt_us: 900,
est_mss: 1440,
ack_window: 502,
ack_tsval: 1,
ack_tsecr: 2,
retrans: 0,
last_state: 1,
rtt_us: [900, 950],
pkts: [{ ns: 5, tsval: 3, len: 517, window: 502, flags: 24 }],
syn: {
ttl: 52,
tos: 0,
total_len: 60,
ip_id: 0,
frag_flags: 0,
flow_label: 12345,
ip_options: "",
seq: 7,
data_offset_resv: 160,
tcp_flags: 2,
window: 64240,
urg_ptr: 0,
opts: [{ kind: 2, data: "05a0" }],
opts_malformed: false,
raw: "ab",
},
tcp_info: { state: 1, snd_cwnd: 10 },
};

describe("parseTcpProbeHeader", () => {
it("decodes a full IPv6 hit, keeping post-handshake packets", () => {
expect(parseTcpProbeHeader(encodeProbe(probeHit), mockLogger())).toEqual(
probeHit,
);
});

it("keeps a miss so the blank rate can be measured", () => {
const miss = { v: 2, hit: false, reason: "unavailable" };
expect(parseTcpProbeHeader(encodeProbe(miss), mockLogger())).toEqual(miss);
});

it("returns undefined when the header is absent", () => {
expect(parseTcpProbeHeader(undefined, mockLogger())).toBeUndefined();
});

it("takes the first value of a repeated header", () => {
const raw = [encodeProbe(probeHit), "junk"];
expect(parseTcpProbeHeader(raw, mockLogger())).toEqual(probeHit);
});

it.each<[string, string]>([
["non-base64 junk", "%%%"],
["base64 that is not JSON", Buffer.from("nope").toString("base64url")],
["an oversized value", "a".repeat(8193)],
["an unknown version", encodeProbe({ ...probeHit, v: 3 })],
["a missing field", encodeProbe({ v: 2, hit: true })],
["an out-of-range port", encodeProbe({ ...probeHit, remote_port: 70000 })],
[
"too many packets",
encodeProbe({ ...probeHit, pkts: Array(17).fill(probeHit.pkts[0]) }),
],
[
"non-hex SYN bytes",
encodeProbe({ ...probeHit, syn: { ...probeHit.syn, raw: "<script>" } }),
],
])("rejects %s", (_name, raw) => {
expect(parseTcpProbeHeader(raw, mockLogger())).toBeUndefined();
});

it("drops fields a forger added", () => {
const parsed = parseTcpProbeHeader(
encodeProbe({ ...probeHit, $where: "1" }),
mockLogger(),
);
expect(parsed).toEqual(probeHit);
});
});

describe("rawTlsSignalsForSession", () => {
it("carries the tcp-probe record", () => {
const out = rawTlsSignalsForSession({
tcpProbe: probeHit,
} as Parameters<typeof rawTlsSignalsForSession>[0]);
expect(out).toEqual({ tcpProbe: probeHit });
});

it("omits undefined fields so Mongo docs stay slim", () => {
const out = rawTlsSignalsForSession({
tcpMss: 1460,
Expand Down
2 changes: 2 additions & 0 deletions packages/types-database/src/types/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1061,6 +1061,7 @@ export const SessionRecordSchema = new Schema<SessionRecord>({
// rows hold them and routing rules read them.
tcpOptsFlags: { type: Number, required: false },
tcpOptsOrder: { type: Number, required: false },
tcpProbe: { type: Schema.Types.Mixed, required: false },
tlsHandshakeRttUs: { type: Number, required: false },
// DNS observation merge target. Populated by
// POST /v1/prosopo/provider/admin/dns/event from the dns-event
Expand Down Expand Up @@ -1361,6 +1362,7 @@ export const SESSION_PROJECTION = {
ipTos: 1,
tcpOptsFlags: 1,
tcpOptsOrder: 1,
tcpProbe: 1,
tlsHandshakeRttUs: 1,
"headers.user-agent": 1,
"headers.accept": 1,
Expand Down
Loading
Loading