diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ce61dc1..015ea49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,8 +24,8 @@ jobs: - name: Install libpcap development headers run: sudo apt-get update && sudo apt-get install -y libpcap-dev - - name: Install Rust 1.97.1 - uses: dtolnay/rust-toolchain@1.97.1 + - name: Install Rust 1.98.1 + uses: dtolnay/rust-toolchain@1.98.1 - name: Cache Rust build artifacts uses: Swatinem/rust-cache@v2 diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index a4c9473..09020f6 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -36,8 +36,8 @@ jobs: - name: Install PCAP lib (only on Linux) run: sudo apt-get install -y libpcap-dev - - name: Set up Rust 1.97.1 - uses: dtolnay/rust-toolchain@1.97.1 + - name: Set up Rust 1.98.1 + uses: dtolnay/rust-toolchain@1.98.1 - name: Get package version id: cargo-version diff --git a/Cargo.lock b/Cargo.lock index 7505c28..89cb254 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -665,7 +665,7 @@ dependencies = [ [[package]] name = "dpp" -version = "0.3.3" +version = "0.4.0" dependencies = [ "aes", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 1707725..e6c4c7a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,9 +2,9 @@ [package] name = "dpp" -version = "0.3.3" +version = "0.4.0" edition = "2024" -rust-version = "1.97.1" +rust-version = "1.98.1" build = "build.rs" # Custom build script to include version information from git [workspace] diff --git a/README.md b/README.md index a34de34..95ffed7 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ ownership boundaries, and matcher invariants, see [docs/architecture.md](docs/ar ## Prerequisites -- [Rust](https://rustup.rs/) 1.97.1 or newer; this repository is pinned by `rust-toolchain.toml`. +- [Rust](https://rustup.rs/) 1.98.1 or newer; this repository is pinned by `rust-toolchain.toml`. - Cargo - PCAP files for offline processing - `libpcap` development headers for fallback support on non-classic formats @@ -152,11 +152,12 @@ If `output_filename` is omitted, DPP chooses the default file name from the reso `dns_output.csv` for `csv` and `dns_output.parquet` for `parquet` or `pq`. Use `-` only when you want CSV records on stdout. DPP refuses to start when the input and output paths refer to the same file, including hard-link aliases, so the input capture cannot be overwritten. +The same protection applies when the output path refers to the anonymization key file. ### Create an anonymization key -`--anonymize` expects a text file. DPP reads the file contents as a passphrase and derives the -internal pseudonymization key from that value. +`--anonymize` accepts a legacy text key file or a salted v2 key file. DPP derives the internal +pseudonymization keys from the file's passphrase and salt. One practical way to create a key file on Linux or macOS is: @@ -171,15 +172,43 @@ Then use it like this: dpp --anonymize /tmp/anon.key input.pcap output.csv ``` +To create a salted v2 key file instead, use Python 3: + +```bash +umask 077 +python3 - <<'PY' +import os +from secrets import token_bytes, token_hex + +contents = ( + b"\x89DPP-ANON-KEY-v2\n" + + token_bytes(32).hex().encode("ascii") + b"\n" + + token_hex(32).encode("ascii") + b"\n" +) +with os.fdopen(os.open("anon-v2.key", os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600), "wb") as key: + key.write(contents) +PY +dpp --anonymize anon-v2.key input.pcap output.csv +``` + Notes: - Keep the key file private. Anyone with the same file can reproduce the same pseudonymized output. -- The key file must contain a non-empty UTF-8 passphrase. Leading and trailing whitespace is ignored. +- A legacy key file contains a non-empty UTF-8 passphrase. A salted v2 file contains the exact + binary header `\x89DPP-ANON-KEY-v2\n`, a line of exactly 64 hexadecimal digits representing + a 32-byte salt, then a non-empty UTF-8 passphrase. Leading and trailing passphrase whitespace is + ignored. Malformed salted files are rejected. - Rotating the key changes the resulting pseudonymized IP addresses for the same input capture. - Matching and deduplication use the original client IP. Pseudonymization is applied only to the - `source_ip` field of finalized output records, so pseudonym collisions cannot merge clients. -- DPP intentionally uses a fixed PBKDF2 salt for deterministic pseudonymization. The passphrase is - still the operator-controlled secret; changing it rotates the derived pseudonyms. + `source_ip` field of finalized output records. IPv4 pseudonymization is a keyed permutation, so + distinct IPv4 client addresses remain distinct in the output; network prefixes are not preserved. +- Legacy key files retain the original fixed PBKDF2 salt and produce the same pseudonyms as before. + The salted v2 format limits precomputation across files that use weak passphrases; a random + passphrase remains essential. Reuse the entire v2 key file across runs and hosts to keep output + stable. Changing either its salt or passphrase rotates both IPv4 and IPv6 pseudonyms. +- The IPv4 Feistel mapping replaces the previous truncated-AES mapping. Even with the same + passphrase, exports produced before and after this change cannot be joined by pseudonymized IPv4 + `source_ip`. IPv6 pseudonyms remain unchanged. - If `--anonymize` or `DPP_ANONYMIZE` is configured and the key file is missing, unreadable, or invalid, DPP exits with an error. It does not silently fall back to pass-through IP addresses. @@ -199,11 +228,15 @@ Notes: | `--report-format ` | Select the final process report format; defaults to `text`; `json` cannot be combined with `output_filename = -` | | `--match-timeout-ms ` | Set the DNS query-response match timeout in milliseconds; allowed range is `1..=5000`, default is `1200` | | `--monotonic-capture` | Assume globally monotonic packet timestamps, enable batched timeout eviction, and abort if a regression is detected | +| `-t, --threads ` | Cap the CPU execution budget at a positive `N`, never above available CPUs | | `-b, --bonded ` | Set I/O channel capacity in records; internally rounded up to batched messages of up to `1024` records; `0` uses the safe default bounded capacity | | `-z, --zstd` | Enable Zstd compression for Parquet output | | `--v2` | Use Parquet Version 2 | | `-a, --affinity` | Apply CPU affinity to processing threads | | `--dns-wire-fast-path` | Enable the optional question-only DNS wire fast path with `hickory` fallback | +| `--max-dns-compression-jumps ` | Maximum compression-pointer jumps per DNS name; defaults to `32`; `0` disables the jump limit | +| `--allow-fragments` | Opt in to matching complete queries with first IPv4 response fragments when the DNS header and question are present; no reassembly | +| `--full-fragments` | Reassemble IPv4 fragments into complete UDP datagrams; also enables `--allow-fragments` for incomplete responses | | `--anonymize ` | Path to the pseudonymization key file | | `-h, --help` | Print help | | `-V, --version` | Print version | @@ -221,11 +254,13 @@ provide supported per-core thread pinning through the affinity backend. | `DPP_REPORT_FORMAT` | Final process report format: `text` or `json`; defaults to `text`; `json` cannot be combined with `DPP_OUTPUT_FILENAME=-` | | `DPP_MATCH_TIMEOUT_MS` | DNS query-response match timeout in milliseconds; allowed range is `1..=5000`, default is `1200` | | `DPP_MONOTONIC_CAPTURE` | Assume globally monotonic packet timestamps, enable batched timeout eviction, and abort if a regression is detected | +| `DPP_THREADS` | Maximum CPU execution budget when `--threads` is not set; must be a positive integer | | `DPP_BONDED` | I/O channel capacity in records; internally rounded up to batched messages of up to `1024` records; `0` uses the default bounded capacity | | `DPP_ZSTD` | Enable Zstd compression for Parquet output | | `DPP_V2` | Enable Parquet Version 2 | | `DPP_AFFINITY` | Apply CPU affinity to processing threads | | `DPP_DNS_WIRE_FAST_PATH` | Enable the optional DNS wire fast path | +| `DPP_MAX_DNS_COMPRESSION_JUMPS` | Maximum compression-pointer jumps per DNS name when `--max-dns-compression-jumps` is not set; defaults to `32`; `0` disables the jump limit | | `DPP_ANONYMIZE` | Path to the key file used for pseudonymization | | `DPP_SILENT` | Suppress info-level log output | @@ -253,7 +288,9 @@ match window. Finalized transactions are never reopened. With `--monotonic-captu history is needed or allocated. DNS QR determines query/response direction, including valid exchanges with UDP port 53 on both -endpoints. QCLASS and OPCODE distinguish transactions internally and do not add output columns. +endpoints. QCLASS, OPCODE and the full VLAN/QinQ tag stack distinguish transactions internally +and do not add output columns. VLAN identity includes each tag's TPID and VLAN ID; changes to +priority (PCP) or drop eligibility (DEI) do not prevent matching within the same tagged segment. Ordinary DNS QUERY messages with more than one question are rejected under RFC 9619. Responses whose declared answer or authority records are truncated are also rejected before matching. @@ -383,7 +420,7 @@ $ DPP_FILENAME=server1_jul_2024.pcap DPP_FORMAT=csv target/release/dpp --dns-wir 04:15:01.046 INFO > Commercial licensing options: carrier-support@dnstele.com 04:15:01.046 INFO > Nameto Oy (c) 2026. All rights reserved. 04:15:01.062 INFO OS: Linux, ARCH: x86_64 -04:15:01.062 INFO Available parallelism: 4, execution budget: auto (all available CPUs), affinity requested: false, effective: false +04:15:01.062 INFO Available parallelism: 4, execution budget: 4 CPUs (auto), affinity requested: false, effective: false 04:15:01.062 INFO Available memory (system reported): 2,857 MB 04:15:01.062 INFO Starting to process PCAP file: /mnt/mirror/src/dpp/server1_jul_2024.pcap 04:15:01.062 INFO Processing mode: forward sorting with response-query matching @@ -395,7 +432,7 @@ $ DPP_FILENAME=server1_jul_2024.pcap DPP_FORMAT=csv target/release/dpp --dns-wir 04:15:01.076 INFO Monotonic capture mode: enabled (batched timeout eviction active; timestamp regressions abort the run) 04:15:01.076 INFO PID: 501755 04:15:01.147 INFO Results will be written to: /mnt/mirror/src/dpp/dns_output.csv -04:15:01.150 INFO Execution budget: auto using 4 CPUs, phase-parallel pipeline selected for low-core host, Rayon worker budget: 4 +04:15:01.150 INFO Execution budget: 4 CPUs, phase-parallel pipeline selected for low-core budget, Rayon worker budget: 4 04:15:16.718 INFO Total packets processed: 40,000,000 04:15:16.718 INFO Total DNS queries processed: 19,670,037 04:15:16.718 INFO Deduplicated duplicate queries: 23,947 @@ -424,6 +461,8 @@ What this tells you: - the first query for example.com A was matched with a response about 20.736 ms later; - the second query for example.org A had no matching response within the timeout window; - empty response_timestamp and response_code mean timeout. +- a response_timestamp with an empty response_code means a first-fragment response prefix matched, + but the complete response code was unavailable. ### A simple AWK analysis to measure DNS traffic latency ```bash @@ -499,26 +538,38 @@ Additional notes: - See [benches/README.md](benches/README.md) for the benchmark contract and data-handling notes. - For apples-to-apples performance testing, prefer the dedicated `perf` build profile: `cargo build --profile perf` or `DPP_BENCH_PROFILE=perf bash benches/benchmark.sh ...`. -- DPP auto-sizes its execution budget from all available CPUs. -- Historical `--threads` and `DPP_THREADS` inputs are accepted only as deprecated compatibility no-ops and emit a warning if used. +- DPP auto-sizes its execution budget from available CPUs unless `--threads` or `DPP_THREADS` + sets a lower ceiling. The pipeline model is still selected automatically from that budget. - The parser fast path is opt-in through `--dns-wire-fast-path` or `DPP_DNS_WIRE_FAST_PATH=1`; without that flag DPP uses the legacy `hickory` question decoder. +- Both parser modes limit DNS compression chains to `32` pointer jumps per name by default. Use `--max-dns-compression-jumps N` or `DPP_MAX_DNS_COMPRESSION_JUMPS=N` to change the limit; `0` disables this resource limit. Cached suffixes still count toward the full chain depth. Bounds, backward-pointer, overlap, and existing name-length validation remain active. Checked suffixes are cached within each message to avoid repeatedly walking shared chains. +- `--allow-fragments` or `DPP_ALLOW_FRAGMENTS=1` optionally pairs a query with the observable first IPv4 fragment of a response when its DNS header and complete question are present. `response_code` is populated when there are no additional records or every declared DNS record fits in the first fragment; otherwise it is empty. The JSON metric `fragmented_response_prefix_count` (text: `Accepted first IPv4 response fragments`) counts accepted prefixes, including ones without a matching query. +- `--full-fragments` or `DPP_FULL_FRAGMENTS=1` enables IPv4 reassembly and implicitly enables `--allow-fragments`. Complete fragmented UDP datagrams enter the existing DNS parser with the timestamp of their final IPv4 fragment (`MF=0`). An incomplete first response fragment falls back to the prefix heuristic on capacity eviction or end of input; with `--monotonic-capture`, it can also expire after the configured match timeout. The fallback keeps the first fragment's timestamp. A bounded history of completed datagrams compares the fragment key and UDP payload bytes to suppress repeated complete datagrams and prefix fallback when all observed fragments match a recent completion. A different datagram can still use the same IPv4 ID, including one with an identical first fragment but different later bytes. A fully identical new datagram, or an incomplete one whose observed fragments match a recent completion, cannot be distinguished from a duplicate and may be suppressed. A bounded non-DNS history drops later tails after a non-DNS first fragment; if a new DNS datagram reuses the same IPv4 ID and its tail arrives before its first fragment while that history is active, the tail is indistinguishable and may be dropped. Fragmented queries require complete reassembly. Overlapping, inconsistent, or UDP-length-mismatched fragment sets are rejected. - For captures normalized with `reordercap`, `--monotonic-capture` can reduce in-flight matcher state by enabling batched timeout eviction. If a timestamp regression is detected, DPP fails the run instead of silently weakening matching semantics. - Global allocator choice is a build-time concern. See [docs/allocator-guide.md](docs/allocator-guide.md) for the supported allocator matrix and [benches/allocator-benchmarking.md](benches/allocator-benchmarking.md) for the comparison protocol. ## Limitations - **UDP/53 only:** DPP currently processes DNS traffic over UDP port 53 only. -- DPP does not reassemble IPv4 or IPv6 fragments. IPv6 Hop-by-Hop, Routing, Destination Options, - Authentication and atomic Fragment headers are traversed before UDP; fragments requiring - reassembly, ESP and IPv6 jumbograms are unsupported. Flow identity uses observed IP endpoints. +- By default, IPv4 fragments are skipped. `--allow-fragments` uses only an observed first IPv4 + response fragment when its DNS header and complete question are available. This heuristic cannot + confirm that later fragments arrived or validate the full UDP/DNS contents; the response code + may be unknown if needed records are beyond the first fragment. `--full-fragments` reconstructs + complete IPv4 datagrams before the existing UDP/DNS checks and falls back to the first-response + heuristic for incomplete sets. Neither mode reassembles IPv6 fragments. IPv6 Hop-by-Hop, Routing, + Destination Options, Authentication and atomic Fragment headers are traversed before UDP; ESP + and IPv6 jumbograms are unsupported. Flow identity uses observed IP endpoints. +- Full IPv4 reassembly holds ready packet batches until unresolved fragments are completed or + released, preserving retry and response ordering across batches. This adds a retained backlog + of up to 65,536 packets or 64 MiB of packet payloads; exceeding either bound triggers the + incomplete-fragment capacity fallback. The current input batch adds transient memory on top. - If capture parsing fails after processing begins, DPP flushes valid partial (not atomic) output from complete accepted batches and exits with an error; pending queries are not emitted as timeouts. -- **PCAPNG support level:** DPP supports PCAPNG on stream input and via `libpcap` on regular-file fallback paths, but the performance-critical pure-Rust fast path remains focused on classic PCAP. -- **Ethernet linktype only:** DPP rejects captures or packets declared with non-Ethernet linktypes instead of interpreting them as Ethernet. Ethernet frames containing VLAN, QinQ, MPLS, or similar outer encapsulation layers require preprocessing first. See [docs/encapsulation-playbook.md](docs/encapsulation-playbook.md). +- **PCAPNG support level:** DPP supports PCAPNG on stream input and via `libpcap` on regular-file fallback paths, but the performance-critical pure-Rust fast path remains focused on classic PCAP. Stdin PCAPNG blocks larger than 16 MiB are rejected before their body is read. +- **Ethernet linktype only:** DPP rejects captures or packets declared with non-Ethernet linktypes instead of interpreting them as Ethernet. Ethernet frames may carry VLAN or QinQ tags (`0x8100`, `0x88a8`, `0x9100`) before IPv4 or IPv6. MPLS and other outer encapsulation layers still require preprocessing; see [docs/encapsulation-playbook.md](docs/encapsulation-playbook.md). - **Unsorted exported data:** CSV and Parquet outputs are not guaranteed to be timestamp-sorted. - **Variable RAM usage:** Memory usage depends on capture size, traffic shape, and output backpressure. Larger `--bonded` values increase peak memory usage under slow output sinks because the output channel rounds the requested record backlog up to batched messages of up to `1024` records each. - **Monotonic-capture mode is explicit:** Batched timeout eviction is available only with `--monotonic-capture` because it depends on globally monotonic packet timestamps. If the capture is not monotonic, DPP aborts and recommends `reordercap`. -- **Scaling ceilings on skewed workloads:** DPP auto-sizes from all available CPUs, but flow-affinity ceilings can still limit scaling before linear speedup. +- **Scaling ceilings on skewed workloads:** DPP auto-sizes from available CPUs or the configured CPU cap, but flow-affinity ceilings can still limit scaling before linear speedup. - **Duplicate query handling:** Duplicate in-flight DNS queries and responses are preserved and resolved through deterministic matcher tie-breakers derived from capture order. Duplicate-heavy workloads can still increase matcher memory usage, and Parquet output may vary byte-for-byte because writers remain asynchronous. - **QNAME casing trade-off:** Matcher identity preserves observed QNAME casing instead of canonicalizing names to lowercase. RFC 4343 allows ASCII-label case-only differences between a query and a valid response, and name compression can contribute to that mismatch. Community Edition still matches on observed bytes because that better reflects the caching-resolver workloads it targets. As a result, names that differ only by case may not match even on protocol-compliant traffic. @@ -532,7 +583,7 @@ DPP Commercial Edition extends that foundation with broader capture support, ent |-------------------------------------------------------|-------------------------------------------------------------------------------------------------| | Offline PCAP processing | Native S3 integration for reading PCAP | | CSV and single-file Parquet export | Partitioned Parquet datasets, not just single-file export | -| Current encapsulation scope | Advanced encapsulation support: native support for VLAN, QinQ, MPLS, GRE, VXLAN, ERSPAN, Geneve | +| Native Ethernet, VLAN and QinQ decoding | Advanced encapsulation support: MPLS, GRE, VXLAN, ERSPAN, Geneve | | Local file outputs | Direct enterprise sinks: ClickHouse, Kafka, S3, PostgreSQL outputs | | Batch-oriented processing | Live/continuous ingestion | | Deterministic pseudonymization with file-based keying | Commercial anonymization/compliance features | diff --git a/benches/README.md b/benches/README.md index 418f3d6..d68fd51 100644 --- a/benches/README.md +++ b/benches/README.md @@ -9,6 +9,10 @@ This directory is the canonical location for repeatable performance measurements comparisons. - `protocol-correctness-audit.md`: final validation, restored throughput and measured memory costs of the 2026-09-05 protocol and matcher fixes, with earlier experiments identified separately. +- `dns-compression.md`: configurable compression limits, message-local suffix caching, + repeated performance measurements, RR-loop inlining investigation and reproduction instructions. +- `dns-compression-benchmark.py` and `dns-compression/`: synthetic end-to-end and isolated + scanner benchmarks for short names, shared suffixes and deep pointer chains. ## Safety Contract diff --git a/benches/allocator-benchmarking.md b/benches/allocator-benchmarking.md index 177c298..c259dd8 100644 --- a/benches/allocator-benchmarking.md +++ b/benches/allocator-benchmarking.md @@ -24,7 +24,7 @@ Exactly one allocator feature must be enabled at build time. ## Build Matrix -Requires Rust 1.97.1 or newer; this repository is pinned by `rust-toolchain.toml`. +Requires Rust 1.98.1 or newer; this repository is pinned by `rust-toolchain.toml`. Build each variant into a separate binary path. diff --git a/benches/dns-compression-benchmark.py b/benches/dns-compression-benchmark.py new file mode 100644 index 0000000..4fc0939 --- /dev/null +++ b/benches/dns-compression-benchmark.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +r"""Compare two prebuilt DPP binaries on generated DNS compression workloads. + +Uses only synthetic captures, never downloads inputs, and validates transaction +counters and CSV checksums before reporting paired timing ratios. Build both +binaries with the same Cargo profile, compiler and features (prefer `perf`). + +Example: + python3 benches/dns-compression-benchmark.py \ + --baseline /tmp/dpp-before --candidate target/perf/dpp \ + --outdir /tmp/dpp-compression-benchmark --runs 5 + +By default the baseline is a binary predating --max-dns-compression-jumps, so +the option is omitted for it. Use --baseline-limit for a same-policy control. +The candidate is exercised with 32 and 0; capped runs omit deeper-chain cases. +For an uncontaminated timing comparison, do not compile or run other benchmarks +concurrently. CPU scheduling and CSV/matcher work can mask parser-only costs. +""" + +import argparse +import csv +import hashlib +import json +import os +from pathlib import Path +import platform +import statistics +import struct +import subprocess +import time + + +def pointer(offset): + assert 0 <= offset <= 0x3FFF + return struct.pack("!H", 0xC000 | offset) + + +def dns_messages(case): + count, style = CASES[case] + qname = b"\x01a\x00" + if case == "three-label-eight": + qname = b"\x03www\x07example\x03com\x00" + elif case == "long-name-eight": + qname = (b"\x14abcdefghijklmnopqrst" * 10) + b"\x00" + question = qname + struct.pack("!HH", 16, 1) + query = struct.pack("!6H", 0, 0x0100, 1, 0, 0, 0) + question + answer_count = 0 if case in ("authority-eight", "additional-eight") else count + authority_count = count if case == "authority-eight" else 0 + additional_count = count if case == "additional-eight" else 0 + response = bytearray(struct.pack("!6H", 0, 0x8180, 1, answer_count, + authority_count, additional_count) + question) + previous, depth = 12, 0 + max_depth = 0 + for index in range(count): + start = len(response) + if style == "literal": + owner = b"\x01a\x00" + owner_depth = 0 + elif style == "distinct": + label = f"rr{index}".encode("ascii") + owner = bytes([len(label)]) + label + b"\x01a\x00" + owner_depth = 0 + elif style == "suffix": + label = f"rr{index}".encode("ascii") + owner = bytes([len(label)]) + label + pointer(12) + owner_depth = 1 + elif style.startswith("chain") or style == "deep": + owner = pointer(previous) + owner_depth = depth + 1 + else: + owner = pointer(12) + owner_depth = 1 + response += owner + struct.pack("!HHIH", 16, 1, 60, 2) + b"\x01x" + max_depth = max(max_depth, owner_depth) + if start <= 0x3FFF and (style == "deep" or style.startswith("chain") and owner_depth < int(style[5:])): + previous, depth = start, owner_depth + assert len(response) <= 65507 + return query, bytes(response), max_depth + + +CASES = { + "empty": (0, "direct"), + "one": (1, "direct"), + "two": (2, "direct"), + "eight": (8, "direct"), + "authority-eight": (8, "direct"), + "additional-eight": (8, "direct"), + "three-label-eight": (8, "direct"), + "long-name-eight": (8, "direct"), + "literal-eight": (8, "literal"), + "chain2-eight": (8, "chain2"), + "chain8-eight": (8, "chain8"), + "distinct-128": (128, "distinct"), + "suffix-128": (128, "suffix"), + "chain32-128": (128, "chain32"), + "flat-4600": (4600, "direct"), + "deep-4600": (4600, "deep"), +} + + +def frame(message, transaction, response): + client = bytes([10, 0, 0, 1]) + server = bytes([1, 1, 1, 1]) + source, destination = (server, client) if response else (client, server) + port = 53000 + transaction % 1000 + udp = struct.pack("!4H", 53 if response else port, port if response else 53, + 8 + len(message), 0) + ip = struct.pack("!BBHHHBBH4s4s", 0x45, 0, 28 + len(message), + transaction & 0xFFFF, 0, 64, 17, 0, source, destination) + message = struct.pack("!H", transaction & 0xFFFF) + message[2:] + return bytes(12) + b"\x08\x00" + ip + udp + message + + +def make_capture(path, case, transactions): + query, response, max_depth = dns_messages(case) + path.with_suffix(".dns").write_bytes(response) + with path.open("wb") as handle: + handle.write(struct.pack("= fixture["max_jumps"]]: + for run in range(args.runs + 1): + # Alternate order to reduce systematic temperature/cache bias. + order = ["baseline", "candidate"] if run % 2 == 0 else ["candidate", "baseline"] + hashes = [] + for name in order: + output = args.outdir / f"{case}-{name}.csv" + report, wall, digest = run_one( + binaries[name], args.outdir / f"{case}.pcap", output, + fast, threads, limit if name == "candidate" else args.baseline_limit, + fixture["transactions"], environment) + hashes.append(digest) + if run == 0: + continue + metrics = report["metrics"] + row = {"case": case, "decoder": "fast" if fast else "default", + "threads": threads, "limit": limit, "run": run, "binary": name, + "wall_seconds": wall, "processing_seconds": metrics["processing_seconds"], + "rss_kib": metrics["max_memory_usage_kib"], "output_sha256": digest} + writer.writerow(row) + rows.append(row) + if hashes[0] != hashes[1]: + raise RuntimeError(f"CSV mismatch: {case}, fast={fast}, threads={threads}, limit={limit}") + handle.flush() + selected = [row for row in rows if row["case"] == case and row["decoder"] == ("fast" if fast else "default") + and row["threads"] == threads and row["limit"] == limit] + baseline = statistics.median(row["processing_seconds"] for row in selected if row["binary"] == "baseline") + candidate = statistics.median(row["processing_seconds"] for row in selected if row["binary"] == "candidate") + paired = {run: {row["binary"]: row["processing_seconds"] for row in selected if row["run"] == run} + for run in range(1, args.runs + 1)} + ratios = [pair["candidate"] / pair["baseline"] for pair in paired.values()] + summary = {"case": case, "decoder": "fast" if fast else "default", + "threads": threads, "limit": limit, "runs": args.runs, + "baseline_median_seconds": baseline, "candidate_median_seconds": candidate, + "paired_ratio_median": statistics.median(ratios), + "paired_ratio_p25": percentile(ratios, .25), + "paired_ratio_p75": percentile(ratios, .75), + "paired_ratio_min": min(ratios), "paired_ratio_max": max(ratios)} + summaries.append(summary) + (args.outdir / "summary.json").write_text(json.dumps(summaries, indent=2) + "\n") + print(f"{case:16} {'fast' if fast else 'default':7} threads={threads} limit={limit:2} " + f"baseline={baseline:.6f}s candidate={candidate:.6f}s " + f"paired-ratio={summary['paired_ratio_median']:.3f} " + f"IQR=[{summary['paired_ratio_p25']:.3f},{summary['paired_ratio_p75']:.3f}]", flush=True) + + +if __name__ == "__main__": + main() diff --git a/benches/dns-compression.md b/benches/dns-compression.md new file mode 100644 index 0000000..a8f4e6b --- /dev/null +++ b/benches/dns-compression.md @@ -0,0 +1,460 @@ +# DNS compression validation and performance + +## Behavior and validation + +`--max-dns-compression-jumps` defaults to 32. The environment equivalent is +`DPP_MAX_DNS_COMPRESSION_JUMPS`; an explicit CLI value takes precedence. Zero disables +the jump-count policy, while backward-pointer, bounds and nonoverlap checks remain +mandatory. The policy covers questions, scanned RR owners and TSIG algorithm names, +including partial responses and the optional wire fast path's Hickory fallback. + +The decoder belongs to one immutable DNS message. Cached suffixes retain their +encoded segment end, expanded length, pointer depth and first label. Reuse checks +the segment end against the new referring name and charges the entire cached +depth against the configured limit. Entries are published only after a complete +successful walk. No cache survives the message or crosses worker ownership. + +Short RR-owner walks use a direct scanner: at most eight pointers and 64 literal +bytes per referenced segment. Crossing either threshold switches to the cached +scanner from the original name offset. These thresholds select an implementation, +not a different acceptance policy. Uncompressed questions in the optional fast +path retain a single-pass formatter. Compressed questions are validated before +Hickory receives their expanded wire form, avoiding recursive replay of long +pointer chains. + +The RR section loop and metadata reader are both explicitly inlined. This lets +the compiler remove metadata unused by answer/authority scans and retain the +scan cursor in registers; see the follow-up investigation below. + +The tests cover exact jump boundaries, zero/unlimited, warmed-cache depth and +overlap checks, literal versus compressed OPT roots, TSIG, partial fragments, +malformed and oversized questions, per-message isolation, and bounded traversal +work. The strictly backward, nonoverlapping walker accepts a synthetic raw name +with 8,193 pointer transitions when unlimited; normal DNS record framing further +limits how many such segments fit into a message. The deep benchmark is a valid +64,419-byte DNS response with 4,600 RRs and a maximum depth of 1,170. + +## Follow-up: RR-loop inlining (`9b329b9`) + +The initial short-message regression was investigated with single-factor builds +from the same source directory, compiler, dependencies and profile. Five alternating +pairs were used for screening, with ten-pair confirmation of the selected fix. +Production validation and cache behavior are unchanged by the fix: it adds only +`#[inline(always)]` to `skip_dns_resource_records` and +`read_dns_resource_record_meta`, plus comments explaining why both are needed. + +### Mechanism and controls + +The arm64 disassembly of `7807751` contains the RR scan directly inside +`decode_response_code_with_sections`. The compiler keeps the scan cursor in a +register and removes unused metadata, including TTL, from answer/authority scans. +In `400acc5`, `read_dns_resource_record_meta` became a separate 656-byte function +called for every RR. Each call creates a 64-byte stack frame, saves/restores six +registers, reads TTL and writes the full metadata result to the stack. The caller +then reads only the fields needed by that section. + +The earlier uncached-scanner control **also retained this separate function**. +Consequently, disabling suffix caching in that control did not isolate the loss +of inlining around it. This is an indirect compiler consequence of integrating +the new decoder, rather than the cost of HashMap lookups on these fixtures. + +Forcing only the metadata reader inline (F) removed the per-RR calls and dead +metadata, but left the section loop as a separate function; timing did not +improve consistently. Forcing both levels inline (G, the selected fix) restored +the loop within the caller, including a register-held cursor, and produced the +measured improvement. Overflow, fixed-header length and full RDATA bounds checks +remain in the resulting code. + +| Screen: one change from the stated control | Literal-eight ratio [IQR] | Conclusion | +| --- | ---: | --- | +| Historical `7807751` → rebuilt unmodified `400acc5` (B0) | 1.043 [1.003–1.047] | Original regression reproduced in this session | +| Original `400acc5` binary → B0 | 1.003 [0.997–1.007] | Scratch rebuild does not explain it | +| B0 → remove question-capacity bound (C, diagnostic only) | 1.006 [0.999–1.011] | No benefit; allocation safeguard retained | +| B0 → lazily boxed cache state (D) | 1.016 [1.012–1.025] | Slower; inline state retained | +| B0 → earlier plain-QNAME formatter shape (E) | 0.999 [0.993–0.999] | Does not recover the original regression | +| B0 → inline metadata reader only (F) | 1.006 [1.000–1.013] | Assembly improves, end-to-end timing does not | +| B0 → inline metadata reader and section loop (G) | 0.955 [0.953–0.961] | Selected minimal fix | +| G → pass cold-path cursor by value (H) | 1.005 [0.992–1.015] | Removes a stack store but no demonstrated timing benefit; omitted | + +G also reduced the paired median on eight compressed owners to 0.973 +[0.970–0.995] and on two owners to 0.980 [0.956–0.981]. These are screening +results, not a claim that each removed instruction accounts for a known fraction +of elapsed time. All controls produced matching CSV contents and counters. +Workspace tests (353), Clippy with the existing lint allowances, formatting and +diff checks passed after applying G. + +### Confirmation results + +Each configuration below has ten alternating pairs and an excluded warm-up +pair. The direct causal comparison uses the rebuilt pre-fix source (B0) and +production `9b329b9`, both with limit 32 and the optional fast decoder, one +processing thread and one million transactions: + +| Case | Pre-fix median (s) | Fixed median (s) | Paired ratio [IQR] | +| --- | ---: | ---: | ---: | +| Eight literal owners | 0.274228 | 0.263665 | 0.958 [0.950–0.972] | +| Eight compressed owners | 0.276779 | 0.269366 | 0.971 [0.966–0.977] | +| Two compressed owners | 0.237620 | 0.234812 | 0.984 [0.977–0.997] | + +This confirms approximately 4.2%, 2.9% and 1.6% less processing time respectively +from the selected inlining change. A separate ten-pair comparison against the +historical `7807751` binary, at the same transaction count and candidate limit, +shows the remaining differences: + +| Case | Historical median (s) | Fixed median (s) | Paired ratio [IQR] | +| --- | ---: | ---: | ---: | +| Eight literal owners | 0.260786 | 0.263337 | 1.005 [0.995–1.029] | +| Eight compressed owners | 0.274985 | 0.277004 | 1.007 [0.997–1.018] | +| Two compressed owners | 0.235979 | 0.237861 | 1.008 [1.004–1.015] | +| One compressed owner | 0.230069 | 0.233360 | 1.011 [1.007–1.024] | +| No RR owners | 0.222002 | 0.223651 | 1.008 [1.003–1.012] | +| Eight owners sharing `www.example.com` | 0.327628 | 0.329540 | 1.003 [0.994–1.011] | +| Eight owners, chain depth eight | 0.313246 | 0.318369 | 1.014 [1.006–1.023] | +| Eight authority owners | 0.270605 | 0.273137 | 1.010 [0.995–1.022] | +| Eight additional owners | 0.281450 | 0.284516 | 1.012 [0.985–1.025] | + +The earlier 3–4% penalties on eight literal or directly compressed answer owners +no longer reproduce; their remaining median differences are below 1%, with IQRs +crossing 1. A smaller +0.8–1.4% difference remains on several other cases with IQR above 1. Those costs +have **not** been assigned to a specific instruction or exclusively to the jump +limit. The experiment localizes and fixes the main regression, not every last +nanosecond of the difference from the historical decoder. + +Forced inlining can change code size and instruction locality. Confirmation +therefore also includes the default decoder, all three RR sections, dense/deep +responses and the staged pipeline; the tables below retain the observed +differences rather than assuming all workloads benefit. + +The default decoder's short-message comparisons against `7807751` use one million +transactions, one thread and limit 32: + +| Case | Historical median (s) | Fixed median (s) | Paired ratio [IQR] | +| --- | ---: | ---: | ---: | +| One owner | 0.317102 | 0.286759 | 0.901 [0.894–0.912] | +| Eight literal owners | 0.351446 | 0.318372 | 0.908 [0.897–0.913] | +| Eight owners sharing `www.example.com` | 0.450267 | 0.410812 | 0.911 [0.909–0.917] | + +Dense cases use 10,000 transactions for 128 RRs and 200 for 4,600 RRs; staged +one-owner runs use 200,000 transactions. Each cell remains a paired ratio to +`7807751`, followed by its IQR: + +| Case | Threads | Limit | Default decoder | Optional fast decoder | +| --- | ---: | ---: | ---: | ---: | +| 128 owners, chain depth 32 | 1 | 32 | 0.704 [0.688–0.718] | 0.704 [0.691–0.713] | +| 4,600 flat owners | 1 | 32 | 1.004 [0.968–1.043] | 1.025 [0.995–1.066] | +| 128 distinct literal owners | 1 | 32 | 0.992 [0.981–1.010] | 1.037 [0.994–1.059] | +| 128 owners with a shared suffix | 1 | 32 | 0.982 [0.959–1.016] | 1.019 [1.002–1.041] | +| 4,600 owners, chain depth 1,170 | 1 | 0 | 0.0369 [0.0361–0.0374] | 0.0362 [0.0355–0.0364] | +| One owner | 5 | 32 | 0.970 [0.946–0.987] | 1.016 [0.943–1.022] | +| 4,600 owners, chain depth 1,170 | 5 | 0 | 0.0657 [0.0639–0.0663] | 0.0635 [0.0627–0.0646] | + +The shared-suffix fast case retains an observed +1.9% median with IQR above 1; +it is not presented as a demonstrated zero-cost case. Deep-chain improvements +remain about 27-fold with one thread and 15–16-fold with five threads. The final +matrix comprises 29 configurations and 580 measured executions, plus warm-ups; +every pair matched counters and CSV contents. RSS sampling remains too sparse +to compare peaks for these short runs. + +### Follow-up reproduction and provenance + +Use the existing harness with the pre-fix source `c5fe0f7` (production code +identical to `400acc5`) and fixed source `9b329b9`, built with the same compiler, +profile and features. Keep each build in its own Cargo target directory and +copy each binary before building another revision. For the direct comparison: + +```bash +python3 benches/dns-compression-benchmark.py \ + --baseline /path/to/dpp-before-inline --baseline-limit 32 \ + --candidate /path/to/dpp-after-inline --limits 32 --decoders fast \ + --cases literal-eight,eight,two --transactions 1000000 --runs 10 \ + --outdir /tmp/dpp-inline-confirmation +``` + +For comparison with `7807751`, omit `--baseline-limit` because that version has +no configurable cap. Include `authority-eight,additional-eight` to cover the +other RR sections. The scanner harness now sums all three section counts and +has passed a bounds/end-position smoke check on answer, authority and additional +fixtures with both 32 and zero limits. + +The local investigation artifacts are under `/tmp/dpp-compression-ablation`: +`manifest.json` records the build variants and source/patch/binary hashes; +`final-provenance.json` records the final matrix; `final-*` directories contain +the raw samples, checksums and summaries; `final-summary.json` collects all 29 +configurations with absolute times, ratios and sampled RSS. Disassembly excerpts and the analysis +are in `/tmp/dpp-compression-disassembly`. The parser-only harness prepared during +the investigation was not used for these conclusions; adding a benchmark caller +could itself change the inlining decisions being investigated. + +| Follow-up binary | SHA-256 | +| --- | --- | +| Rebuilt pre-fix control B0 | `22cb75b59057420f50a593c9d08b226f88d0df88337bc579dbfdf822eb4dffdd` | +| Production `9b329b9` | `70c1d17e3a956bd17de4bd788664b26c42eca5660797a6fd3b9bb050daf079b1` | + +## Initial measurement method (`400acc5`) + +The end-to-end baseline is commit `7807751`, immediately before configurable +limits and suffix caching. The measured implementation is `400acc5`. Both binaries +use Rust 1.97.1, the `perf` profile and default jemalloc features, on macOS/aarch64. +`perf` disables overflow checks; these measurements +must not be interpreted as measurements of the production `release` profile. + +`dns-compression-benchmark.py` generates deterministic synthetic PCAPs and `.dns` +messages locally. Each configuration gets an excluded warm-up pair followed by +10 measured pairs, alternating which binary runs first. Builds and other test +processes are stopped during measurement. All `DPP_*` environment overrides are +removed for each child process. + +The harness validates packet, query, response, match and timeout counters on every +run, then compares CSV contents. Parallel output is sorted before comparison. +It records binary SHA-256, fixture dimensions, per-run processing time, wall time, +RSS and checksums. The main statistic is the median of the paired +candidate/baseline processing-time ratios; below 1 is faster. The interquartile +range (IQR) describes dispersion, **not** a confidence interval. A single favorable +run is not evidence of an improvement. + +The isolated scanner benchmark uses a fresh message-local decoder per iteration +and a historical uncached walker with the **same** configurable limit. This +separates scanner/cache cost from the end-to-end changes in question parsing and +mandatory validation. The full-application uncached control makes the same +comparison for RR-name skipping while keeping the new question parser (including +its suffix cache) and policy. + +## Reproduction + +Build the baseline and candidate with the same compiler, target and allocator: + +```bash +bench_dir=$(mktemp -d /tmp/dpp-compression.XXXXXX) +mkdir "$bench_dir/baseline" +git archive 7807751 | tar -x -C "$bench_dir/baseline" +cargo build --manifest-path "$bench_dir/baseline/Cargo.toml" \ + --target-dir "$bench_dir/baseline-target" --profile perf --locked --offline +cp "$bench_dir/baseline-target/perf/dpp" "$bench_dir/dpp-before" +cargo build --target-dir "$bench_dir/candidate-target" --profile perf --locked --offline +cp "$bench_dir/candidate-target/perf/dpp" "$bench_dir/dpp-after" + +python3 benches/dns-compression-benchmark.py \ + --baseline "$bench_dir/dpp-before" --candidate "$bench_dir/dpp-after" \ + --outdir "$bench_dir/short" --runs 10 --transactions 1000000 \ + --cases empty,one,two,eight,literal-eight,three-label-eight,chain8-eight \ + --limits 32 --decoders both + +python3 benches/dns-compression-benchmark.py \ + --baseline "$bench_dir/dpp-before" --candidate "$bench_dir/dpp-after" \ + --outdir "$bench_dir/unlimited" --runs 10 --transactions 200000 \ + --cases one,three-label-eight,long-name-eight,chain2-eight --limits 0 --decoders both + +python3 benches/dns-compression-benchmark.py \ + --baseline "$bench_dir/dpp-before" --candidate "$bench_dir/dpp-after" \ + --outdir "$bench_dir/dense" --runs 10 --transactions 200000 \ + --cases distinct-128,suffix-128,chain32-128,flat-4600,deep-4600 \ + --limits 32,0 --decoders both + +python3 benches/dns-compression-benchmark.py \ + --baseline "$bench_dir/dpp-before" --candidate "$bench_dir/dpp-after" \ + --outdir "$bench_dir/staged" --runs 10 --transactions 200000 \ + --cases one,chain32-128,deep-4600 --threads 5 --limits 32,0 --decoders both +``` + +Dense fixtures scale the transaction count by 20 for 128 RRs and by 1,000 for +4,600 RRs. The harness omits configurations whose depth exceeds the requested +nonzero limit, so the deep case runs only with zero. Add `--threads 5` to exercise +the staged pipeline, and use a new output directory for each experiment. + +Build and run the isolated scanner using dependency paths reported by Cargo for +the current compiler, rather than picking arbitrary old `.rlib` files: + +```bash +python3 benches/dns-compression/build-scan.py --output "$bench_dir/dns-compression-scan" +"$bench_dir/dns-compression-scan" "$bench_dir/short" 10 100 > "$bench_dir/scan-short.csv" +"$bench_dir/dns-compression-scan" "$bench_dir/unlimited" 10 100 > "$bench_dir/scan-unlimited.csv" +"$bench_dir/dns-compression-scan" "$bench_dir/dense" 10 100 > "$bench_dir/scan-dense.csv" +``` + +To reproduce the end-to-end control with uncached RR-name skipping, export the +candidate commit and apply the benchmark-only patch in that separate directory: + +```bash +mkdir "$bench_dir/control" +git archive HEAD | tar -x -C "$bench_dir/control" +git -C "$bench_dir/control" apply "$PWD/benches/dns-compression/uncached-control.patch" +cargo build --manifest-path "$bench_dir/control/Cargo.toml" \ + --target-dir "$bench_dir/control-target" --profile perf --locked --offline +python3 benches/dns-compression-benchmark.py \ + --baseline "$bench_dir/control-target/perf/dpp" --candidate "$bench_dir/dpp-after" \ + --baseline-limit 32 --limits 32 --decoders fast \ + --cases one,two,eight,literal-eight,three-label-eight \ + --outdir "$bench_dir/control-results" --runs 10 --transactions 1000000 +``` + +Use an explicit `--baseline-limit 0` when comparing unlimited configurations with +this control. Leave `--baseline-limit` unset for the historical binary, which +predates the option. The patch is a measurement control, not a production mode. + +The scripts use synthetic data only and do not upload anything. Keep raw output +under the selected benchmark directory rather than committing generated PCAPs. +Keep build directories separate: exporting several revisions into one shared +Cargo target directory can leave the top-level executable from the last copy even +when a later build of another copy reports `Fresh`. + +Final correctness checks on `400acc5` passed: 353 workspace tests, formatting, +`git diff --check`, and Clippy with the existing `too_many_arguments`, +`type_complexity` and `large_enum_variant` allowances. CLI smoke checks cover +default rejection at 33 jumps, explicit 33, zero, environment configuration and +CLI precedence, in both decoder modes. Before the final inlining and plain-QNAME +optimizations, a separate randomized comparison of the cached and uncached +validators covered six million calls over 20,000 messages, with cold and warm +caches and limits 0 through 11. The final changes also received an independent +review of cursor handling, error classification and limit equivalence. + +## Initial results (`400acc5`) + +### Short messages against `7807751` + +Ten alternating pairs per configuration, 1,000,000 query/response transactions, +one processing thread, candidate limit 32. Each cell is median paired ratio +followed by its IQR; below 1 is faster. + +| Response owners | Default decoder | Optional fast decoder | +| --- | ---: | ---: | +| None | 0.917 [0.908–0.927] | 1.000 [0.995–1.006] | +| One compressed owner | 0.908 [0.899–0.915] | 1.004 [0.994–1.020] | +| Two compressed owners | 0.941 [0.925–0.944] | 1.024 [1.015–1.033] | +| Eight compressed owners | 0.939 [0.934–0.953] | 1.036 [1.030–1.046] | +| Eight literal owners | 0.941 [0.938–0.955] | 1.043 [1.039–1.053] | +| Eight owners sharing `www.example.com` | 0.923 [0.916–0.930] | 1.014 [0.994–1.022] | +| Eight owners, chain depth up to eight | 0.941 [0.939–0.945] | 1.014 [1.003–1.020] | + +The default decoder uses approximately 6–9% less processing time in this set. +The optional fast decoder has a **remaining end-to-end regression** on several +small synthetic cases, up to 4.3% for eight literal owners. For that case the +median times are 0.267963 s before and 0.278825 s after, per million transactions. +This is not dismissed as noise: its paired IQR is wholly above 1. The complete +change includes configurable validation, different question parsing and cache +context; the control below isolates the adaptive RR scanner more narrowly. + +### Same-policy RR-scanner control + +Ten alternating pairs, 1,000,000 transactions per case, optional fast question +decoder, one processing thread, both binaries explicitly limited to 32 jumps: + +| Response owners | Uncached control median (s) | Candidate median (s) | Median paired ratio | Ratio IQR | +| --- | ---: | ---: | ---: | ---: | +| One compressed owner | 0.233462 | 0.234452 | 1.001 | 0.997–1.020 | +| Two compressed owners | 0.242091 | 0.242340 | 0.996 | 0.989–1.013 | +| Eight compressed owners | 0.283838 | 0.286409 | 1.006 | 0.992–1.017 | +| Eight literal owners | 0.277441 | 0.279397 | 1.007 | 0.988–1.028 | +| Eight owners sharing `www.example.com` | 0.338048 | 0.337085 | 0.998 | 0.992–1.006 | + +These runs do not show a consistent short-message penalty from the adaptive RR +scanner: each IQR includes 1. This is narrower than a claim that the complete +change is free, or that performance is identical on every machine and workload. +Ratios are medians of pairs and need not equal the ratio of the two time medians. + +### Unlimited and dense messages + +Ten pairs per configuration. Unlimited short cases use 200,000 transactions; +dense cases use 10,000 transactions for 128 RRs and 200 for 4,600 RRs. + +| Case | Limit | Default decoder ratio [IQR] | Optional fast decoder ratio [IQR] | +| --- | ---: | ---: | ---: | +| One owner | 0 | 0.925 [0.915–0.947] | 1.022 [1.004–1.031] | +| Eight owners sharing `www.example.com` | 0 | 0.920 [0.907–0.943] | 1.002 [0.990–1.009] | +| Eight owners sharing a 211-byte name | 0 | 0.992 [0.984–1.000] | 0.981 [0.958–0.984] | +| Eight owners, chain depth two | 0 | 0.943 [0.933–0.967] | 1.010 [0.992–1.024] | +| 128 distinct literal owners | 32 | 0.995 [0.984–1.035] | 1.025 [0.969–1.035] | +| 128 distinct literal owners | 0 | 1.029 [1.011–1.076] | 1.000 [0.978–1.026] | +| 128 owners with a shared suffix | 32 | 1.000 [0.970–1.016] | 0.995 [0.976–1.049] | +| 128 owners with a shared suffix | 0 | 1.004 [0.979–1.019] | 0.979 [0.955–1.011] | +| 128 owners, chain depth 32 | 32 | 0.707 [0.698–0.717] | 0.713 [0.690–0.728] | +| 128 owners, chain depth 32 | 0 | 0.714 [0.690–0.723] | 0.702 [0.697–0.726] | +| 4,600 owners pointing directly to the question | 32 | 1.028 [0.993–1.119] | 1.004 [0.973–1.049] | +| 4,600 owners pointing directly to the question | 0 | 1.009 [0.973–1.038] | 1.012 [0.987–1.063] | +| 4,600 owners, chain depth 1,170 | 0 | 0.036 [0.035–0.037] | 0.037 [0.037–0.038] | + +The depth-32 workload needs about 30% less processing time. The deep unlimited +workload improves from median 1.443625 s to 0.052278 s with the default decoder, +and from 1.440767 s to 0.053734 s with the fast decoder (about 27 times faster). +The distinct-owner unlimited/default configuration also has an observed +2.9% +paired median with IQR above 1. Other flat dense configurations mostly overlap 1. +Their 30–40 ms end-to-end duration limits sensitivity to scanner costs; the +isolated measurements below provide a second, narrower view. + +### Five-thread staged pipeline + +Ten pairs, 200,000 short transactions, 10,000 depth-32 transactions and 200 deep +transactions, using the same binary pair: + +| Case | Limit | Default decoder ratio [IQR] | Optional fast decoder ratio [IQR] | +| --- | ---: | ---: | ---: | +| One owner | 32 | 0.961 [0.947–0.985] | 0.977 [0.948–1.000] | +| One owner | 0 | 0.942 [0.924–0.968] | 1.021 [0.995–1.059] | +| 128 owners, chain depth 32 | 32 | 0.840 [0.808–0.858] | 0.834 [0.824–0.856] | +| 128 owners, chain depth 32 | 0 | 0.811 [0.796–0.845] | 0.792 [0.781–0.800] | +| 4,600 owners, chain depth 1,170 | 0 | 0.066 [0.065–0.068] | 0.065 [0.064–0.067] | + +The deep default-decoder medians are 0.561074 s before and 0.036981 s after, +approximately a 15-fold improvement. Every pair has identical normalized CSV +contents and expected transaction counters. + +RSS is retained in the raw data but is not used to claim peak-memory savings. +The application samples RSS every 100 ms, longer than many candidate runs; a +shorter run can miss a memory peak that the slower baseline happens to sample. + +### Isolated name scanning with equal limits + +Ten alternating pairs per case/limit, one excluded warm-up pair, 100 ms per +sample. Times include skipping the question and all RR owners of one DNS message, +with a fresh decoder each time; they exclude question formatting, matching and +CSV output. The baseline walker receives the same limit as the candidate. +This historical table uses the scanner harness from `c5fe0f7`, which reads +ANCOUNT. The current harness sums ANCOUNT, NSCOUNT and ARCOUNT so generated +authority/additional fixtures can also be scanned; that extension received a +correctness smoke check, not a new set of timings for this table. + +| Case | Median ns/message before → after (limit 32) | Ratio at 32 [IQR] | Ratio at 0 [IQR] | +| --- | ---: | ---: | ---: | +| No RR owners | 2.75 → 3.17 | 1.150 [1.133–1.158] | 0.974 [0.966–0.983] | +| One owner | 5.63 → 5.24 | 0.927 [0.920–0.971] | 0.792 [0.785–0.807] | +| Two owners | 8.84 → 7.73 | 0.873 [0.864–0.899] | 0.750 [0.741–0.764] | +| Eight owners | 33.07 → 27.88 | 0.837 [0.835–0.856] | 0.832 [0.823–0.840] | +| Eight literal owners | 22.02 → 16.09 | 0.733 [0.727–0.742] | 0.686 [0.680–0.691] | +| Eight owners sharing `www.example.com` | 50.95 → 52.42 | 1.029 [1.018–1.046] | 1.022 [1.020–1.038] | +| Eight owners sharing a 211-byte name | 145.81 → 79.04 | 0.539 [0.533–0.556] | 0.544 [0.530–0.549] | +| Eight owners, chain depth two | 45.66 → 40.08 | 0.879 [0.872–0.890] | 0.866 [0.848–0.875] | +| Eight owners, chain depth eight | 82.91 → 73.44 | 0.881 [0.868–0.899] | 0.920 [0.913–0.930] | +| 128 distinct literal owners | 599.77 → 493.21 | 0.825 [0.814–0.833] | 0.820 [0.797–0.827] | +| 128 owners with a shared suffix | 741.39 → 686.99 | 0.921 [0.916–0.936] | 0.858 [0.840–0.869] | +| 128 owners, chain depth 32 | 5,987.28 → 2,712.57 | 0.452 [0.451–0.458] | 0.460 [0.455–0.465] | +| 4,600 flat owners | 22,992.61 → 17,816.45 | 0.777 [0.772–0.784] | 0.779 [0.768–0.790] | +| 4,600 owners, chain depth 1,170 | Rejected by policy | — | 0.0154 [0.0151–0.0156] | + +The unlimited deep-case medians are 6.966778 ms and 107.433 microseconds per +message, approximately a 65-fold scanner improvement. Most shallow cases also +improve. Two small costs remain measurable: approximately 0.4 ns per empty +response at limit 32, and 1.5 ns per response with eight three-label owners. +The latter is about 2–3% of isolated scanning. Therefore the result is **not** +"zero overhead in every case," even though the same-policy end-to-end control +cannot consistently distinguish these costs from its variation. + +### Recorded artifacts + +Run-level CSV, summaries and metadata from this machine are under +`/tmp/dpp-compression-final-400-{short,unlimited,dense,staged}` and +`/tmp/dpp-compression-causal-control` (the additional two-owner control is separate). +The isolated results are `/tmp/dpp-compression-final-micro.csv` and +`/tmp/dpp-compression-final-micro-summary.json`; the combined machine-readable +summary is `/tmp/dpp-compression-final-summary.json`. +These temporary files are not required to reproduce the measurements. + +| Binary | SHA-256 | +| --- | --- | +| Historical `7807751` | `8199ab733f9ff1f0c839fa04cbc556d0af410bdde434175f5572c06437628e59` | +| Final `400acc5` | `76c8ea5e7f3d2dfaa8e0190ec54893dbe70ddc5f54ae34ab097fb68431da60ce` | +| Same-source candidate used for the causal control, before refreshing build metadata | `f3cf38d9931b436a9130bcc36feac698be4bd925891c6554f40090ec176a74d7` | +| Uncached RR-scanner control | `cbc0c0f0b0dad398a2d4d541546aabc403f2eb5a7c094f0921c2ad59a301fbed` | + +Measurements use macOS 15.8/aarch64 and Python 3.14.6. Absolute timings and small +relative differences are machine- and workload-dependent; these synthetic runs +do not establish universal performance equivalence. diff --git a/benches/dns-compression/build-scan.py b/benches/dns-compression/build-scan.py new file mode 100644 index 0000000..8af9d5d --- /dev/null +++ b/benches/dns-compression/build-scan.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +"""Build the standalone scanner microbenchmark with current Cargo artifacts. + +Uses the perf profile and default jemalloc feature, matching the recorded runs. +Cargo JSON selects artifacts for the active compiler; stale .rlib files from +another toolchain in target/perf/deps are deliberately ignored. +""" + +import argparse +import json +import os +from pathlib import Path +import shlex +import subprocess +import sys + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", required=True, type=Path) + args = parser.parse_args() + directory = Path(__file__).resolve().parent + root = directory.parent.parent + build = subprocess.run( + ["cargo", "build", "-p", "dpp", "--profile", "perf", "--locked", "--offline", + "--message-format=json"], + cwd=root, capture_output=True, text=True, check=True, + ) + sys.stderr.write(build.stderr) + libraries = {"arrayvec": set(), "tikv_jemallocator": set()} + for line in build.stdout.splitlines(): + record = json.loads(line) + if record.get("reason") != "compiler-artifact": + continue + name = record["target"]["name"] + if name in libraries: + libraries[name].update(path for path in record["filenames"] if path.endswith(".rlib")) + if any(len(paths) != 1 for paths in libraries.values()): + raise RuntimeError(f"Expected one current artifact per dependency: {libraries}") + selected = {name: Path(next(iter(paths))) for name, paths in libraries.items()} + if len({path.parent for path in selected.values()}) != 1: + raise RuntimeError("Dependency artifacts use different target directories") + output = args.output.resolve() + output.parent.mkdir(parents=True, exist_ok=True) + command = [os.environ.get("RUSTC", "rustc"), "--edition=2024", "-C", "opt-level=3", + "-C", "lto", "-C", "codegen-units=1", "-C", "panic=abort", + "-C", "overflow-checks=off", str(directory / "scan.rs"), + "-L", f"dependency={selected['arrayvec'].parent}"] + for name, path in selected.items(): + command += ["--extern", f"{name}={path}"] + command += ["-o", str(output)] + print(shlex.join(command), flush=True) + subprocess.run(command, cwd=root, check=True) + + +if __name__ == "__main__": + main() diff --git a/benches/dns-compression/scan.rs b/benches/dns-compression/scan.rs new file mode 100644 index 0000000..d44ed4f --- /dev/null +++ b/benches/dns-compression/scan.rs @@ -0,0 +1,171 @@ +// Isolated RR-owner validation benchmark. Build with the same optimizer and +// allocator as both E2E binaries. See the compression benchmark report for the +// rustc --extern command. Inputs are *.dns files generated alongside PCAPs by +// dns-compression-benchmark.py. Each iteration starts a new message-local cache. +// +// Usage: dns-compression-scan FIXTURE_DIR [RUNS=10] [SAMPLE_MILLISECONDS=100] +// Stdout is CSV: timings and paired ratios for every measured repetition. +// One unrecorded warm-up pair precedes each case/limit configuration. +// The uncached control is the scanner from 7807751 plus the configurable cap. +use std::{hint::black_box, time::Instant}; +const DNS_POINTER_MASK: u8 = 0xc0; +const DNS_POINTER_TAG: u8 = 0xc0; +const DNS_LABEL_LEN_MASK: u8 = 0x3f; +#[global_allocator] +static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; +struct Old; +impl Old { + fn skip_wire_domain_name( + dns_data: &[u8], + cursor: &mut usize, + max_jumps: usize, + ) -> Result { + let start = *cursor; + let mut position = *cursor; + let mut resume_position = None; + let mut segment_start = position; + let mut segment_end = dns_data.len(); + let mut jumps = 0; + + loop { + let length = *dns_data + .get(..segment_end) + .and_then(|segment| segment.get(position)) + .ok_or("DNS name truncated")?; + + match length { + 0 => { + position += 1; + *cursor = resume_position.unwrap_or(position); + return Ok(resume_position.is_none() && position == start + 1); + } + _ if (length & DNS_POINTER_MASK) == DNS_POINTER_TAG => { + let next = *dns_data + .get(..segment_end) + .and_then(|segment| segment.get(position + 1)) + .ok_or("DNS compression pointer truncated")?; + let offset = + (((length & DNS_LABEL_LEN_MASK) as usize) << 8) | usize::from(next); + + jumps += 1; + if max_jumps != 0 && jumps > max_jumps { + return Err("DNS compression jump limit exceeded"); + } + if offset >= segment_start { + return Err("DNS compression pointer is not prior to name"); + } + + if resume_position.is_none() { + resume_position = Some(position + 2); + } + + // Historical uncached walker, with the same configurable hop cap + // as the candidate, isolates cache/bookkeeping cost from limit cost. + segment_end = segment_start; + segment_start = offset; + position = offset; + } + _ if (length & DNS_POINTER_MASK) != 0 => { + return Err("Unsupported DNS label encoding"); + } + _ => { + let label_len = usize::from(length); + dns_data + .get(..segment_end) + .and_then(|segment| segment.get(position + 1..position + 1 + label_len)) + .ok_or("DNS label truncated")?; + position += 1 + label_len; + } + } + } + } +} +#[allow(dead_code)] +#[path = "../../src/dns_processor/name_decoder.rs"] +mod candidate; +#[inline(never)] +fn old(data: &[u8], limit: usize) -> usize { + let count: usize = [6, 8, 10] + .into_iter() + .map(|offset| usize::from(u16::from_be_bytes([data[offset], data[offset + 1]]))) + .sum(); + let mut cursor = 12; + black_box(Old::skip_wire_domain_name(data, &mut cursor, limit).unwrap()); + cursor += 4; + for _ in 0..count { + black_box(Old::skip_wire_domain_name(data, &mut cursor, limit).unwrap()); + let n = u16::from_be_bytes([data[cursor + 8], data[cursor + 9]]) as usize; + cursor += 10 + n; + } + assert_eq!(cursor, data.len()); + cursor +} +#[inline(never)] +fn new(data: &[u8], limit: usize) -> usize { + let mut decoder = candidate::DnsNameDecoder::new(data, limit); + let count: usize = [6, 8, 10] + .into_iter() + .map(|offset| usize::from(u16::from_be_bytes([data[offset], data[offset + 1]]))) + .sum(); + let mut cursor = 12; + black_box(decoder.skip(&mut cursor).unwrap()); + cursor += 4; + for _ in 0..count { + black_box(decoder.skip(&mut cursor).unwrap()); + let n = u16::from_be_bytes([data[cursor + 8], data[cursor + 9]]) as usize; + cursor += 10 + n; + } + assert_eq!(cursor, data.len()); + cursor +} +fn main() { + let mut args = std::env::args().skip(1); + let directory = std::path::PathBuf::from(args.next().expect("fixture directory required")); + let repetitions: usize = args.next().map(|v| v.parse().unwrap()).unwrap_or(10); + let milliseconds: u128 = args.next().map(|v| v.parse().unwrap()).unwrap_or(100); + assert!(repetitions > 0 && milliseconds > 0); + assert!(args.next().is_none(), "too many arguments"); + let mut fixtures: Vec<_> = std::fs::read_dir(directory) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| path.extension().is_some_and(|extension| extension == "dns")) + .collect(); + fixtures.sort(); + assert!(!fixtures.is_empty(), "no .dns fixtures found"); + println!("case,limit,run,old_ns,new_ns,ratio"); + for path in fixtures { + let case = path.file_stem().unwrap().to_str().unwrap(); + let data = std::fs::read(&path).unwrap(); + for limit in [32, 0] { + // The generated deep case intentionally needs unlimited traversal. + if case == "deep-4600" && limit == 32 { + continue; + } + for round in 0..=repetitions { + let mut numbers = [0., 0.]; + for variant in [round % 2, (round + 1) % 2] { + let scan = if variant == 0 { old } else { new }; + let mut runs = 0; + let started = Instant::now(); + // Avoid a 100-packet batch overshooting the duration on deep chains. + let batch = if data.len() > 16_000 { 1 } else { 100 }; + while started.elapsed().as_millis() < milliseconds { + for _ in 0..batch { + black_box(scan(black_box(&data), black_box(limit))); + } + runs += batch; + } + numbers[variant] = started.elapsed().as_secs_f64() * 1e9 / runs as f64; + } + if round != 0 { + println!( + "{case},{limit},{round},{:.2},{:.2},{:.6}", + numbers[0], + numbers[1], + numbers[1] / numbers[0] + ); + } + } + } + } +} diff --git a/benches/dns-compression/uncached-control.patch b/benches/dns-compression/uncached-control.patch new file mode 100644 index 0000000..ac9a8b8 --- /dev/null +++ b/benches/dns-compression/uncached-control.patch @@ -0,0 +1,64 @@ +--- a/src/dns_processor/name_decoder.rs ++++ b/src/dns_processor/name_decoder.rs +@@ -273,41 +273,25 @@ + let mut segment_end = self.data.len(); + let mut jumps = 0; + loop { +- let length = *self +- .data +- .get(..segment_end) ++ let length = *self.data.get(..segment_end) + .and_then(|segment| segment.get(position)) + .ok_or_else(|| self.boundary_error(segment_end, "DNS name truncated"))?; + match length { + 0 => { + *cursor = resume.unwrap_or(position + 1); +- // OPT requires a literal root, never an expanded root. + return Ok(resume.is_none() && position == start); + } + _ if length & POINTER_TAG == POINTER_TAG => { +- // Every setting permits the first jump. Defer policy work +- // until nested compression, keeping literal/direct names cheap. +- if jumps != 0 && (jumps == DIRECT_POINTER_JUMPS || jumps == self.max_jumps) { +- // Cheap short walks avoid cache bookkeeping. The full +- // walker enforces smaller configured limits and caches +- // longer chains, counting every cached transition. +- return self.skip_cached(cursor); +- } +- let next = *self +- .data +- .get(..segment_end) ++ let next = *self.data.get(..segment_end) + .and_then(|segment| segment.get(position + 1)) +- .ok_or_else(|| { +- self.boundary_error(segment_end, "DNS compression pointer truncated") +- })?; ++ .ok_or_else(|| self.boundary_error(segment_end, "DNS compression pointer truncated"))?; + let target = (usize::from(length & 0x3f) << 8) | usize::from(next); + if target >= segment_start { + return Err("DNS compression pointer is not prior to name"); + } + jumps += 1; +- if resume.is_none() { +- resume = Some(position + 2); +- } ++ self.check_jumps(jumps)?; ++ if resume.is_none() { resume = Some(position + 2); } + segment_end = segment_start; + segment_start = target; + position = target; +@@ -315,13 +299,7 @@ + _ if length & POINTER_TAG != 0 => return Err("Unsupported DNS label encoding"), + _ => { + let end = position + 1 + usize::from(length); +- if resume.is_some() && end - segment_start > DIRECT_SUFFIX_BYTES { +- // Bound repeated literal-label work too, including RR +- // owner names whose expanded size is not materialized. +- return self.skip_cached(cursor); +- } +- self.data +- .get(..segment_end) ++ self.data.get(..segment_end) + .and_then(|segment| segment.get(position + 1..end)) + .ok_or_else(|| self.boundary_error(segment_end, "DNS label truncated"))?; + position = end; diff --git a/clippy.toml b/clippy.toml index dfe7be0..a9e7bc9 100644 --- a/clippy.toml +++ b/clippy.toml @@ -1 +1 @@ -msrv = "1.97.1" +msrv = "1.98.1" diff --git a/docs/allocator-guide.md b/docs/allocator-guide.md index 9b28a8e..1255c74 100644 --- a/docs/allocator-guide.md +++ b/docs/allocator-guide.md @@ -30,7 +30,7 @@ Invalid feature combinations fail the build explicitly. ## Build Commands -Requires Rust 1.97.1 or newer; this repository is pinned by `rust-toolchain.toml`. +Requires Rust 1.98.1 or newer; this repository is pinned by `rust-toolchain.toml`. Default allocator build: diff --git a/docs/architecture.md b/docs/architecture.md index 80f7f1a..04b4149 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -75,14 +75,21 @@ In the current multi-threaded implementation, those stages are realized as a bou reader, a lightweight routing stage, shard-owned workers, a deterministic aggregator, and asynchronous writers. The routing stage performs only cheap L3/L4 extraction, computes a canonical client/resolver flow key, and hands owned packet batches to the correct worker before full DNS -decode begins. DPP derives its execution budget from all available CPUs; there is no supported -runtime thread-count override. On low-core hosts, DPP falls back to the simpler phase-parallel +decode begins. DPP derives its execution budget from available CPUs, capped by `--threads` or +`DPP_THREADS` when specified. On low-core budgets, DPP falls back to the simpler phase-parallel pipeline so it does not spend too much of the machine on staged-pipeline service roles. In staged mode, the runtime reserves two non-worker service threads for routing/aggregation and parser work, and uses the remaining CPU budget for shard workers. Routed DNS packets also carry compact UDP/DNS metadata into shard workers so the worker path can reuse the first L3/L4 parse instead of repeating it for full DNS question decoding. +Ethernet decoding also produces one canonical VLAN context: the ordered stack of tag TPIDs and +12-bit VLAN IDs. PCP and DEI are QoS metadata and do not distinguish transactions. This context is +shared by fragment keys and matcher identities, keeping overlapping IP endpoints in different +VLANs separate. Routing still uses the coarser client/resolver endpoint tuple, so different VLANs +with the same endpoints can share a worker without sharing matcher state. Untagged packets allocate +no tag storage; tagged stacks use shared immutable storage and remain internal to processing. + Flow routing buffers the existing tuple's `Hash` writes on the stack and runs SeaHash once over the resulting bytes. The integer encoding and digest remain identical to the streaming hasher, so shard assignment and output order are preserved. If a future tuple encoding exceeds the @@ -101,10 +108,13 @@ or second encoding of canonical addresses and ports. high/low timestamp words using the interface's full binary/decimal resolution and signed offset. Conversion scales the complete counter before rounding to microseconds and saturates only the final signed timestamp. It never invokes the dependency's stateful timestamp conversion. - Buffers grow with received block bytes rather than untrusted declared lengths. Stdin probing + Buffers grow with received block bytes rather than untrusted declared lengths, and stdin PCAPNG + blocks over 16 MiB are rejected before their body is read. Stdin probing needs no temp-file or second ingest owner. Unsupported stdin stream magic is rejected explicitly. Signal-driven shutdown can interrupt a blocked stdin read without waiting for the producer to - close the stream; capture reading remains parser-owned. + close the stream; capture reading remains parser-owned. Packets already read into a nonempty + batch are handed to the processing pipeline before intake stops. The signal shutdown policy can + still discard records buffered by the output writer. The pure-Rust classic-PCAP reader still relies on the upstream `pcap-file` `3.0.0-rc1` release candidate until a stable line with the required functionality is available. The parser can also enforce globally monotonic capture timestamps for the optional batched @@ -142,8 +152,9 @@ or second encoding of canonical addresses and ports. as a downstream-close outcome; regular file-write failures remain fatal. - `src/monitor_memory.rs` - Optional RSS tracking helper with an explicit stop/join lifecycle. The monitor must not detach - indefinitely from top-level process shutdown. + Optional RSS tracking helper with an explicit stop/join lifecycle. The monitor's 100 ms sampling + wait is interruptible by stop, so join need not wait for the next sample. An in-progress RSS + refresh must still finish before the monitor thread exits. - `src/dns_processor.rs` and `src/dns_processor/*` The DNS processor facade owns packet-to-matcher orchestration and delegates to focused @@ -156,19 +167,82 @@ or second encoding of canonical addresses and ports. shard-local DNS decode, but that reuse must stay within the same ownership boundary so packet parsing does not gain a second source of truth for IP/port extraction. That metadata owns the exact DNS byte range validated against IPv4 Total Length or IPv6 Payload Length and then UDP - Length; capture padding and trailing IP payload cannot extend the DNS slice. A compact relative + Length; capture padding and trailing IP payload cannot extend the DNS slice. For an opted-in + first IPv4 response fragment, the DNS slice ends at that fragment's Total Length, and the UDP + Length is checked against the declared datagram size. A compact relative offset preserves DNS starts above 65535 without widening every packet's routing metadata: the offset is measured from the minimum Ethernet/IPv4/UDP header length, and construction checks that it fits. DNS QR determines direction and the canonical client/resolver flow, including exchanges with UDP port 53 on both ends. IPv6 Hop-by-Hop, Routing, Destination Options, AH and atomic Fragment headers are traversed - within the declared payload boundary. Non-atomic IPv6 and fragmented IPv4 datagrams are skipped - because this boundary has no IP reassembly stage. The optional runtime flag + within the declared payload boundary. Non-atomic IPv6 fragments and, by default, fragmented + IPv4 datagrams are skipped. With + `--allow-fragments`, only a first IPv4 response fragment with a complete DNS header and question + can enter the matcher as an inferred response; later fragments and fragmented queries remain + skipped. Its response code is present only when there are no additional records or all declared + DNS records fit in that prefix. + `--full-fragments` additionally enables IPv4 reassembly across packet batches and implies + `--allow-fragments`. The DNS processor owns the reassembly state: a complete datagram enters the + existing UDP/DNS validation path only after the first and last fragments establish its bounds + and every byte is present. Incomplete responses may use the first-fragment inference on capacity + eviction or end of input; with monotonic capture, entries can also expire after the match + timeout. Without monotonic capture, timestamp gaps between fragments of the same datagram + do not expire it; capacity limits and end of input still bound its lifetime. + Fragmented queries require a complete datagram. A complete datagram + uses the final fragment's (`MF=0`) capture timestamp, while inferred responses use the first + fragment's timestamp. While any datagram is unresolved, routing retains its ready packet + batches so a delayed reconstruction cannot arrive after a retry or response that it should + precede. Released packets are sorted together by timestamp and capture ordinal before matching. + The eviction watermark cannot advance beyond either unresolved fragments or retained ready + packets. The additional ready backlog is bounded between batches to 65,536 packets or 64 MiB + of packet payloads; exceeding either bound resolves pending datagrams through the existing + capacity fallback before releasing the backlog. The current input batch and fallback packets + can temporarily exceed that retained-state bound. This buffering is owned by the routing stage + and contains no query/response pairing state. + The reassembler retains a bounded history of completed datagrams and + compares the fragment key and UDP payload bytes before suppressing repeated complete datagrams + or prefix fallback from fragments matching a recent completion. A reused IPv4 ID with different + content can still produce a new datagram, even when its first fragment is identical. An incomplete + new datagram whose observed fragments match a recent completion is indistinguishable from a + duplicate and may be suppressed; the same holds for a fully identical new datagram. Reassembly + state must remain bounded in memory, and IPv6 fragments remain unsupported. A fragmented UDP datagram must + have an IP payload length equal to its UDP Length; mismatched fragment sets are rejected. + When the first fragment arrives after tails, its UDP Length also bounds every retained tail; + a nonfinal fragment cannot reach or exceed that boundary, regardless of arrival order. + A first non-DNS UDP fragment removes any earlier tails for its datagram key. A separate bounded + history drops later non-DNS tails without evicting the completed-DNS history; a new DNS first + fragment with a reused IPv4 ID removes that non-DNS mark. The canonical VLAN context is part of + the fragment key so traffic from different tagged segments is not assembled together, while + priority or drop-eligibility changes do not split a datagram. A new DNS tail + that arrives before its first fragment while the old non-DNS mark is active is indistinguishable + from an old non-DNS tail and may be dropped. + The optional runtime flag `--dns-wire-fast-path` may enable a custom question-only wire fast path, but `hickory` remains the semantic fallback for rare DNS messages that the fast path does not accept. Compression pointers must target prior, nonoverlapping names; enabling the fast path must not weaken that - validation. For ordinary QUERY messages (OPCODE 0), more than one question is rejected under - RFC 9619. A response cannot claim answer or authority records that are absent from its DNS + validation. `--max-dns-compression-jumps` (environment: `DPP_MAX_DNS_COMPRESSION_JUMPS`) + limits each decoded name to 32 pointer transitions by default; `0` disables this resource + limit. The limit applies to questions, scanned RR owners and TSIG algorithm names in both + decoder modes, including partial responses and fast-path fallback. A cached suffix contributes + its full pointer depth to this limit. Even when unlimited, strictly backward, nonoverlapping + segments prevent cycles. This is a resource policy, not a claim that longer chains violate DNS. + One immutable-message name decoder caches fully validated pointer targets and their encoded + segment end, expanded wire length, pointer depth and first label. Every cache hit rechecks the + segment end against the current name's start so a previously valid target cannot authorize an + overlap. Only referenced suffixes are retained; ordinary flat record lists do not grow the cache + per record. RR/TSIG name skipping validates short walks directly (up to eight jumps and 64 + literal bytes per referenced segment), switching to the shared cached walker when either + threshold or the configured jump limit would be exceeded. These thresholds choose a cheaper + validation strategy; they never relax the limit or accept an otherwise invalid name. + Small caches and traversal stacks use inline storage; larger ones allocate within + the message and are bounded by the 14-bit pointer address space. Label materialization skips + pointer-only segments. Compressed questions are passed to Hickory already expanded, avoiding + a second recursive traversal without replacing Hickory's question semantics. Literal-root + validation for OPT remains distinct from a compressed name that expands to root. + The optional fast path keeps a single-pass formatter for uncompressed questions; seeing a + pointer restarts validation at the original name offset through the shared cached decoder. + For ordinary QUERY messages (OPCODE 0), more than one question is rejected under + RFC 9619. A complete response cannot claim answer or authority records that are absent from its DNS payload, whether or not it has additional records. TSIG status extraction consumes and bounds-checks the declared Other Data, including the six-byte server time in BADTIME responses. Both paths accept @@ -222,8 +296,8 @@ or second encoding of canonical addresses and ports. pipeline execution (0006), and packet-storage allocation experiments (0007). - `docs/encapsulation-playbook.md` - Operational and engineering guidance for captures that contain VLAN, QinQ, MPLS, or other outer - encapsulation layers before the IP header. + Operational and engineering guidance for captures with unsupported MPLS or other outer + encapsulation layers before the IP header. Ethernet VLAN and QinQ are decoded natively. - `benches/README.md` Documents the benchmark contract, safety expectations, and result layout for repeatable runs. @@ -308,8 +382,9 @@ The DNS matcher must preserve these invariants: - Routing and in-flight matching use the original observed client IP, client port, and resolver IP. The resolver remains internal; deterministic client-IP pseudonymization is applied exactly once when the matcher constructs a finalized `DnsRecord`. -- DNS ID, observed QNAME, QTYPE, QCLASS and OPCODE also distinguish in-flight identities. QCLASS, - OPCODE and resolver identity remain internal and do not change the exported record schema. +- DNS ID, observed QNAME, QTYPE, QCLASS, OPCODE and the full canonical VLAN tag stack also distinguish + in-flight identities. QCLASS, OPCODE, VLAN context and resolver identity remain internal and do + not change the exported record schema. - Repeated pending queries with the same match identity inside the configured timeout window (`1200ms` by default) are deduplicated to the earliest canonical query. Later duplicates are counted separately and must not create extra matched or timeout records. @@ -365,9 +440,13 @@ The required boundary is: - Writers remain asynchronous. Logical record order can still be deterministic even when output container layout, such as Parquet row-group boundaries, is not byte-identical across runs. - IP rewriting is deterministic pseudonymization. It reduces direct exposure of source addresses, - but identical inputs still map to identical outputs. -- The pseudonymization key derivation uses a fixed PBKDF2 salt by design so the same passphrase - yields stable output across runs and hosts. The operator-provided passphrase remains the secret - rotation boundary. + but identical inputs still map to identical outputs. IPv4 uses an eight-round, 32-bit Feistel + permutation with an independently derived AES key, so distinct IPv4 addresses cannot collide. + IPv6 retains its original AES block mapping. The IPv4 mapping changed with this algorithm; + exports from before and after the change cannot be joined by pseudonymized IPv4 address. +- Legacy text key files retain the fixed PBKDF2 salt so existing pseudonyms remain stable across + runs and hosts. The optional salted v2 key-file format supplies a 32-byte salt for both IPv4 and + IPv6 key derivation. Reusing the full file keeps its mapping stable; changing the salt or + passphrase rotates both mappings. Key loading and derivation are owned by `anonymizer.rs`. - `docs/rfc/` and `benches/` remain the canonical references for accepted architecture decisions and repeatable benchmark runs. diff --git a/docs/encapsulation-playbook.md b/docs/encapsulation-playbook.md index dde45e6..9e06bfb 100644 --- a/docs/encapsulation-playbook.md +++ b/docs/encapsulation-playbook.md @@ -2,9 +2,12 @@ ## Goal -This document explains how to convert a capture that contains VLAN, QinQ, MPLS, or similar outer -encapsulation layers into a flat Ethernet plus IPv4 or IPv6 capture that the current community -edition DPP can read directly. +This document explains how to convert a capture that contains MPLS or another unsupported outer +encapsulation layer into a flat Ethernet plus IPv4 or IPv6 capture that DPP can read directly. +Ethernet VLAN and QinQ stacks with TPIDs `0x8100`, `0x88a8`, or `0x9100` are decoded natively. +Matching and IPv4 reassembly preserve the complete ordered TPID/VLAN-ID stack, so overlapping +IP endpoints on different tagged segments remain separate. PCP and DEI changes are ignored for +this identity; no VLAN column is added to exported records. Target output shape: @@ -19,14 +22,12 @@ If the input capture already has that shape, no conversion is needed. Use this guide when the original capture contains any of the following before the IP header: -- 802.1Q VLAN -- 802.1ad QinQ - MPLS label stacks -- similar shim layers between Ethernet and IP +- other unsupported shim layers between Ethernet and IP Typical symptom: -- DPP runs, but query or response counts are lower than expected because tagged or labeled packets +- DPP runs, but query or response counts are lower than expected because unsupported labeled packets are not being extracted by the current fast path. ## Linux Workflow @@ -39,8 +40,8 @@ Use `tshark` to confirm whether the capture contains VLAN, QinQ, MPLS, or other tshark -r input.pcap -q -z io,phs ``` -If you see protocol hierarchy entries such as `vlan`, `mpls`, or provider-bridging layers before -`ip` or `ipv6`, continue with normalization. +If you see `mpls` or another unsupported layer before `ip` or `ipv6`, continue with normalization. +VLAN and provider-bridging layers alone do not require this step. ### 2. Install the Required Tools @@ -188,18 +189,6 @@ Or for Parquet: target/release/dpp normalized.pcap normalized.pq --format pq ``` -## Optional Shortcut for Simple VLAN-Only Captures - -If the capture contains only simple VLAN tagging and you already use `tcprewrite`, you can strip -VLAN tags with a smaller workflow such as: - -```bash -tcprewrite --enet-vlan=del --infile=input.pcap --outfile=normalized.pcap -``` - -That shortcut is convenient, but it is not the preferred general-purpose recipe here because it is -less explicit for mixed or MPLS-tagged captures. - ## Validation Checklist After conversion, validate all of the following: @@ -220,9 +209,7 @@ After conversion, validate all of the following: ## If Native Support Is Needed If normalization is not acceptable operationally and DPP must read those captures directly, native -support should be added only inside: - -- [parser.rs](/Users/kam/RustroverProjects/dpp-public/src/dns_processor/parser.rs) +support belongs in `src/dns_processor/parser.rs` and, for fragmented IPv4, `reassembly.rs`. That change must be accompanied by: diff --git a/docs/rfc/0004-forward-only-matcher.md b/docs/rfc/0004-forward-only-matcher.md index 7d73d4a..d9ea62f 100644 --- a/docs/rfc/0004-forward-only-matcher.md +++ b/docs/rfc/0004-forward-only-matcher.md @@ -24,12 +24,28 @@ order within each shard and never backtracks. The key design choices: `CanonicalFlowKey` (observed client IP, client port and resolver IP, oriented by DNS QR) and hashes it to a shard index. This includes the valid case where both ports are 53 and guarantees that a query and its - matching response always land in the same shard. + matching response always land in the same shard. This is a coarser partition than matcher + identity: VLANs with overlapping endpoints may share a worker, but their transaction state + remains distinct. 3. **Deterministic ordering.** Within a shard, packets are processed in strict `(timestamp, packet_ordinal, record_ordinal)` order. Tie-breaks are explicit — scheduler interleaving and container iteration order are not valid tie-breaks. + With full IPv4 reassembly, routing retains ready packet batches while fragment sets remain + unresolved. A reconstructed datagram can carry an earlier final-fragment timestamp, so merely + limiting timeout eviction would still let a retry or response finalize before that datagram. + Routing releases the retained packets and reconstructed datagrams together for the existing + timestamp/ordinal sort. This also preserves ordering within a batch whose timestamps regress + in default mode; it does not impose global monotonicity on that mode. + + This packet backlog is separate from matcher state and bounded between batches to one packet + batch (65,536 packets) or 64 MiB of payloads. On overflow, pending fragment sets use the existing + capacity fallback before routing releases the backlog. Processing the current input batch and + fallback packets can temporarily exceed those retained-state limits. EOF and interrupted + intake drain accepted ready packets through the existing shutdown policy. The matcher eviction + watermark is capped by both the oldest unresolved fragment and the oldest retained ready packet. + 4. **Retry deduplication.** If a query with the same identity arrives while an earlier one is still pending inside the match-timeout window (1200 ms by default), the duplicate is counted but doesn't create a second canonical query. One canonical query → one terminal outcome @@ -45,8 +61,11 @@ order within each shard and never backtracks. The key design choices: of retry history, with no secondary matcher map. Match identity includes the DNS ID, observed name, client IP and port, resolver IP, query - type, query class and opcode. Resolver identity, query class and opcode remain internal and are - not added to the exported `DnsRecord` schema. + type, query class, opcode and canonical VLAN context. Ethernet decoding owns the ordered + TPID/12-bit-VID stack used by both matcher and fragment keys; PCP and DEI bits are excluded. + Untagged traffic needs no tag allocation, while tagged metadata and keys share immutable tag + storage. Resolver identity, query class, opcode and VLAN context remain internal and are not + added to the exported `DnsRecord` schema. The current Community Edition identity key preserves the observed presentation-form QNAME bytes and does not lowercase them before matching. This is a deliberate Community Edition trade-off, diff --git a/docs/rfc/0005-dual-path-pcap-parsing.md b/docs/rfc/0005-dual-path-pcap-parsing.md index 2ce22fd..b3c676a 100644 --- a/docs/rfc/0005-dual-path-pcap-parsing.md +++ b/docs/rfc/0005-dual-path-pcap-parsing.md @@ -83,8 +83,23 @@ validated DNS offset and length. IPv4 Total Length or IPv6 Payload Length first payload; UDP Length then bounds the DNS payload. Bytes outside either declared boundary, including Ethernet padding and trailing capture bytes, never reach a DNS decoder. -DPP has no IPv4 reassembly stage. IPv4 datagrams with the More Fragments flag or a non-zero fragment -offset are therefore skipped rather than interpreting a fragment body as a complete UDP datagram. +By default, IPv4 datagrams with the More Fragments flag or a non-zero fragment offset are skipped. +With `--allow-fragments`, a first IPv4 response fragment may be matched when its DNS header and +entire question fit in the fragment. The metadata bounds DNS bytes to the observed fragment and +marks the response as partial. This is an inference from the prefix, not validation of the full UDP +datagram. A response code is emitted only if there are no additional records or all declared DNS +records fit in the prefix. +With `--full-fragments`, DPP also reconstructs complete IPv4 datagrams across packet batches. This +mode implies `--allow-fragments`; incomplete responses may use the prefix heuristic on capacity +eviction or end of input, and on elapsed capture-time timeout with `--monotonic-capture`. +Fragmented queries require complete reassembly. The reassembler keys +fragments by source, destination, protocol, and IPv4 identification, places payload bytes at the +declared eight-octet offsets, and waits for the first fragment, final fragment, and contiguous +coverage before sending a datagram through the existing UDP/DNS parser. Fragmented IP payload +length must equal the declared UDP Length; mismatches are rejected. Complete datagrams carry +the final fragment's (`MF=0`) capture timestamp; prefix fallbacks retain the first fragment's +timestamp. The reassembler bounds incomplete state +to prevent unbounded capture-driven memory growth. Neither mode reassembles IPv6 fragments. IPv6 extraction traverses Hop-by-Hop, Routing, Destination Options, Authentication and atomic Fragment headers with per-header bounds checks. Non-atomic fragments require reassembly and are skipped. The DNS offset can exceed 65535 after a long valid extension chain. Internal metadata diff --git a/docs/rfc/0006-adaptive-pipeline.md b/docs/rfc/0006-adaptive-pipeline.md index 887311b..9fcc250 100644 --- a/docs/rfc/0006-adaptive-pipeline.md +++ b/docs/rfc/0006-adaptive-pipeline.md @@ -52,7 +52,7 @@ threads would compete with the actual work. ### How the decision is made The threshold lives in `src/config.rs` as part of the `ExecutionBudget`. The -`uses_staged_pipeline()` method returns `true` when the available CPU count is high enough to +`uses_staged_pipeline()` method returns `true` when the effective CPU budget is high enough to reserve service threads (currently 2: one for parsing/routing, one implicitly for aggregation) and still leave meaningful worker capacity. The exact threshold is a tuned constant, not a CLI flag — it's an implementation detail, not a user-facing policy. @@ -70,12 +70,13 @@ CLI flag — it's an implementation detail, not a user-facing policy. and passes it alongside the packet to the matcher worker, so the worker doesn't repeat L3/L4 parsing for DNS question decode. -## Why not let the user choose? +## Why the execution model remains automatic Because the right choice depends on hardware, not on user preference. A user on a 2-core VM who forces staged mode gets worse performance, not better. The automatic selection removes a footgun without losing any capability — on high-core machines, you get the staged pipeline -automatically. +automatically. Operators can cap the CPU budget with `--threads` or `DPP_THREADS`; the pipeline +model is selected from that effective budget. If future profiling shows the threshold needs tuning, it's a one-line change in `config.rs`. diff --git a/docs/rfc/0008-matcher-state-expiry.md b/docs/rfc/0008-matcher-state-expiry.md index 4a927b8..0f2c56c 100644 --- a/docs/rfc/0008-matcher-state-expiry.md +++ b/docs/rfc/0008-matcher-state-expiry.md @@ -44,6 +44,14 @@ identity is approximately 312 bytes on the measured target. A safe eagerly cance index must either share that identity through another allocation or introduce stable handles and an arena/reverse index. +On 2026-09-26, the inline `DnsNameBuf` capacity was separately reduced from 255 to 64 bytes; +the size above describes the original experiment. In five alternating paired runs on one macOS +ARM64 host (`perf` profile, 16-CPU budget), the smaller layout improved processing speed by a +paired geometric mean of `1.095x` on an 18.0 M packet synthetic capture and `1.316x` on a 500,612 +query-only subset. Median peak child RSS fell from 307 to 187 MiB and from 407 to 234 MiB, +respectively. CSV output for the query-only subset was byte-identical. These measurements are +specific to the tested host and captures; they do not establish a gain for every QNAME distribution. + ## Decision Keep the current matcher state and eviction behavior. diff --git a/docs/synthetic-pcap-generator.md b/docs/synthetic-pcap-generator.md index 6177bd2..d84bbff 100644 --- a/docs/synthetic-pcap-generator.md +++ b/docs/synthetic-pcap-generator.md @@ -22,7 +22,7 @@ The generator also models: ## Usage -Requires Rust 1.97.1 or newer; this repository is pinned by `rust-toolchain.toml`. +Requires Rust 1.98.1 or newer; this repository is pinned by `rust-toolchain.toml`. Build the standalone binary: diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 010f002..c3f67b6 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.97.1" +channel = "1.98.1" profile = "minimal" components = ["rustfmt", "clippy"] diff --git a/src/app.rs b/src/app.rs index 2f04873..0f559d7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -6,7 +6,7 @@ */ use crate::config::{AppConfig, InputSource, OutputFormat, ReportFormat}; -use crate::dns_processor::{DnsProcessor, ProcessingCounters}; +use crate::dns_processor::{DnsProcessor, PipelineExecutionConfig, ProcessingCounters}; use crate::error::{AppRunError, OutputError}; use crate::output::OutputMessage; use crate::packet_parser::{NonMonotonicTimestampSample, PacketParser}; @@ -149,6 +149,26 @@ fn display_parser_mode(args: &AppConfig) { } else { info!("DNS wire fast path: disabled"); } + if args.max_dns_compression_jumps == 0 { + info!("DNS compression-pointer jump limit: disabled"); + } else { + info!( + "DNS compression-pointer jump limit: {} per name", + args.max_dns_compression_jumps + ); + } +} + +fn display_fragment_mode(args: &AppConfig) { + if args.full_fragments { + info!( + "IPv4 fragment reassembly: enabled (incomplete responses may use first-fragment matching)" + ); + } else if args.allow_fragments { + info!( + "IPv4 response fragment prefix matching: enabled (no reassembly; full UDP/DNS content unverified)" + ); + } } fn display_match_timeout(args: &AppConfig) { @@ -185,6 +205,9 @@ struct RunConfigSummary { match_timeout_ms: u64, monotonic_capture: bool, dns_wire_fast_path: bool, + max_dns_compression_jumps: usize, + allow_fragments: bool, + full_fragments: bool, anonymization_enabled: bool, anonymize_key_path: Option, zstd: bool, @@ -229,6 +252,7 @@ struct RunMetricsSummary { total_dns_queries_processed: usize, deduplicated_duplicate_queries: usize, total_dns_responses_processed: usize, + fragmented_response_prefix_count: usize, total_matched_query_response_pairs: usize, timed_out_queries: usize, timed_out_query_ratio: f64, @@ -326,16 +350,39 @@ fn average_matched_rtt_ms( } } +struct RunSummaryTimings { + processing_seconds: f64, + final_write_post_processing_seconds: f64, + total_runtime_seconds: f64, +} + +impl RunSummaryTimings { + fn new( + processing_seconds: f64, + final_write_post_processing_seconds: f64, + total_runtime_seconds: f64, + ) -> Self { + Self { + processing_seconds, + final_write_post_processing_seconds, + total_runtime_seconds, + } + } +} + fn build_run_summary( args: &AppConfig, execution_budget: crate::config::ExecutionBudget, counters: ProcessingCounters, warnings: RunWarningsSummary, max_memory_usage_kib: usize, - processing_seconds: f64, - final_write_post_processing_seconds: f64, - total_runtime_seconds: f64, + timings: RunSummaryTimings, ) -> RunSummary { + let RunSummaryTimings { + processing_seconds, + final_write_post_processing_seconds, + total_runtime_seconds, + } = timings; let processing_speed_pps = packets_per_second(counters.total_packets_processed, processing_seconds); @@ -359,6 +406,9 @@ fn build_run_summary( match_timeout_ms: args.match_timeout_ms, monotonic_capture: args.monotonic_capture, dns_wire_fast_path: args.dns_wire_fast_path, + max_dns_compression_jumps: args.max_dns_compression_jumps, + allow_fragments: args.allow_fragments, + full_fragments: args.full_fragments, anonymization_enabled: args.anonymize_key_path().is_some(), anonymize_key_path: args.anonymize_key_path().map(canonical_path_string), zstd: args.zstd, @@ -383,6 +433,7 @@ fn build_run_summary( total_dns_queries_processed: counters.dns_query_count, deduplicated_duplicate_queries: counters.duplicated_query_count, total_dns_responses_processed: counters.dns_response_count, + fragmented_response_prefix_count: counters.fragmented_response_prefix_count, total_matched_query_response_pairs: counters.matched_query_response_count, timed_out_queries: counters.timeout_query_count, timed_out_query_ratio: timed_out_query_ratio( @@ -439,6 +490,15 @@ fn display_text_summary(summary: &RunSummary) { .total_dns_responses_processed .to_formatted_string(&Locale::en) ); + if summary.config.allow_fragments { + info!( + "Accepted first IPv4 response fragments: {}", + summary + .metrics + .fragmented_response_prefix_count + .to_formatted_string(&Locale::en) + ); + } info!( "{}", oversized_qname_rejection_summary_line( @@ -542,13 +602,6 @@ pub(crate) fn run(args: AppConfig) -> Result<(), AppRunError> { let packet_count = Arc::new(AtomicUsize::new(0)); - if let Some(requested_threads) = args.requested_threads { - tracing::warn!( - "Ignoring deprecated thread override {}. DPP now auto-sizes execution from all available CPUs.", - requested_threads - ); - } - runtime::log_build_messages()?; runtime::log_system_info(&args, affinity_plan.is_enabled())?; runtime::log_accessible_input_file(&args)?; @@ -559,6 +612,7 @@ pub(crate) fn run(args: AppConfig) -> Result<(), AppRunError> { display_parquet_format_information(&args); display_anonymization(&args); display_parser_mode(&args); + display_fragment_mode(&args); display_match_timeout(&args); display_monotonic_capture_mode(&args); @@ -569,7 +623,10 @@ pub(crate) fn run(args: AppConfig) -> Result<(), AppRunError> { args.match_timeout_micros(), args.monotonic_capture, ) - .map_err(|source| AppRunError::DnsProcessorInit { source })?, + .map_err(|source| AppRunError::DnsProcessorInit { source })? + .with_max_dns_compression_jumps(args.max_dns_compression_jumps) + .with_allow_fragments(args.allow_fragments) + .with_full_fragments(args.full_fragments), ); let mut packet_parser = match PacketParser::new_with_shutdown( &args.input_source, @@ -601,9 +658,11 @@ pub(crate) fn run(args: AppConfig) -> Result<(), AppRunError> { &mut packet_parser, &packet_count, &tx, - execution_budget, - affinity_plan, - true, + PipelineExecutionConfig { + execution_budget, + affinity_plan, + shard_parallelism_enabled: true, + }, Arc::clone(&shutdown_requested), Arc::clone(&output_closed), ) @@ -679,9 +738,11 @@ pub(crate) fn run(args: AppConfig) -> Result<(), AppRunError> { counters, warnings, max_memory_kib, - processing_seconds, - final_write_post_processing_seconds, - total_runtime_seconds, + RunSummaryTimings::new( + processing_seconds, + final_write_post_processing_seconds, + total_runtime_seconds, + ), ); match args.report_format { @@ -728,6 +789,9 @@ mod tests { bonded: 0, anonymize: None, dns_wire_fast_path: false, + max_dns_compression_jumps: crate::config::DEFAULT_MAX_DNS_COMPRESSION_JUMPS, + allow_fragments: false, + full_fragments: false, } } @@ -761,9 +825,7 @@ mod tests { ProcessingCounters::default(), RunWarningsSummary::default(), 0, - 1.0, - 0.5, - 1.5, + RunSummaryTimings::new(1.0, 0.5, 1.5), ); let serialized = serde_json::to_value(&summary).expect("summary serializes"); @@ -790,6 +852,29 @@ mod tests { ); } + #[test] + fn run_summary_reports_accepted_ipv4_response_prefixes() { + let mut config = test_config(); + config.allow_fragments = true; + config.full_fragments = true; + let summary = build_run_summary( + &config, + config.execution_budget(), + ProcessingCounters { + fragmented_response_prefix_count: 2, + ..ProcessingCounters::default() + }, + RunWarningsSummary::default(), + 0, + RunSummaryTimings::new(1.0, 0.5, 1.5), + ); + + let serialized = serde_json::to_value(&summary).expect("summary serializes"); + assert_eq!(serialized["config"]["allow_fragments"], true); + assert_eq!(serialized["config"]["full_fragments"], true); + assert_eq!(serialized["metrics"]["fragmented_response_prefix_count"], 2); + } + #[test] fn finalize_output_shutdown_prefers_writer_failure() { let (tx, rx) = crossbeam::channel::bounded(1); @@ -924,6 +1009,9 @@ mod tests { bonded: 0, anonymize: None, dns_wire_fast_path: false, + max_dns_compression_jumps: crate::config::DEFAULT_MAX_DNS_COMPRESSION_JUMPS, + allow_fragments: false, + full_fragments: false, } } @@ -939,9 +1027,7 @@ mod tests { }, RunWarningsSummary::default(), 123, - 2.0, - 0.75, - 2.75, + RunSummaryTimings::new(2.0, 0.75, 2.75), ); assert_eq!(summary.metrics.processing_speed_pps, 2_000); @@ -959,9 +1045,7 @@ mod tests { ProcessingCounters::default(), RunWarningsSummary::default(), 123, - 1.0, - 0.5, - 1.5, + RunSummaryTimings::new(1.0, 0.5, 1.5), ); let serialized = serde_json::to_value(&summary).expect("summary serializes"); @@ -989,9 +1073,7 @@ mod tests { ..RunWarningsSummary::default() }, 0, - 1.0, - 0.5, - 1.5, + RunSummaryTimings::new(1.0, 0.5, 1.5), ); let serialized = serde_json::to_value(&summary).expect("summary serializes"); @@ -1028,9 +1110,7 @@ mod tests { ..RunWarningsSummary::default() }, 0, - 1.0, - 0.5, - 1.5, + RunSummaryTimings::new(1.0, 0.5, 1.5), ); let serialized = serde_json::to_value(&summary).expect("summary serializes"); @@ -1064,9 +1144,7 @@ mod tests { }, RunWarningsSummary::default(), 0, - 1.0, - 0.5, - 1.5, + RunSummaryTimings::new(1.0, 0.5, 1.5), ); assert_eq!(summary.metrics.timed_out_queries, 2); @@ -1086,9 +1164,7 @@ mod tests { }, RunWarningsSummary::default(), 0, - 1.0, - 0.5, - 1.5, + RunSummaryTimings::new(1.0, 0.5, 1.5), ); assert_eq!(summary.metrics.dns_messages_rejected_oversized_qname, 3); @@ -1145,9 +1221,7 @@ mod tests { ProcessingCounters::default(), RunWarningsSummary::default(), 0, - 1.0, - 0.5, - 1.5, + RunSummaryTimings::new(1.0, 0.5, 1.5), ); let mut sink = BrokenPipeJsonSink { bytes_until_broken_pipe: 4, diff --git a/src/cli.rs b/src/cli.rs index ef75ba8..7e527f3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -6,8 +6,8 @@ */ use crate::config::{ - AppConfig, DEFAULT_MATCH_TIMEOUT_MS, InputSource, MAX_MATCH_TIMEOUT_MS, OutputFormat, - OutputTarget, ReportFormat, output_target_for_path, + AppConfig, DEFAULT_MATCH_TIMEOUT_MS, DEFAULT_MAX_DNS_COMPRESSION_JUMPS, InputSource, + MAX_MATCH_TIMEOUT_MS, OutputFormat, OutputTarget, ReportFormat, output_target_for_path, }; use anyhow::{Context, Result, anyhow, bail}; use clap::{Arg, ArgAction, ArgMatches, Command}; @@ -30,10 +30,11 @@ pub(crate) fn parse_args() -> Result { let env_v2 = parse_env_bool("DPP_V2"); let env_affinity = parse_env_bool("DPP_AFFINITY"); let env_dns_wire_fast_path = parse_env_bool("DPP_DNS_WIRE_FAST_PATH"); + let env_max_dns_compression_jumps = env::var_os("DPP_MAX_DNS_COMPRESSION_JUMPS"); + let env_allow_fragments = parse_env_bool("DPP_ALLOW_FRAGMENTS"); + let env_full_fragments = parse_env_bool("DPP_FULL_FRAGMENTS"); let env_monotonic_capture = parse_env_bool("DPP_MONOTONIC_CAPTURE"); - let env_threads = env::var("DPP_THREADS") - .ok() - .and_then(|value| value.parse::().ok()); + let env_threads = env::var_os("DPP_THREADS"); let env_bonded = env::var_os("DPP_BONDED"); let matches = build_cli(version).get_matches(); @@ -67,12 +68,11 @@ pub(crate) fn parse_args() -> Result { .map(|parallelism| parallelism.get()) .unwrap_or(1); - let requested_threads = matches - .get_one::("threads") - .and_then(|value| value.parse::().ok()) - .or(env_threads); + let requested_threads = resolve_thread_limit(&matches, env_threads.as_deref())?; let bonded = resolve_bonded(&matches, env_bonded.as_deref())?; + let max_dns_compression_jumps = + resolve_max_dns_compression_jumps(&matches, env_max_dns_compression_jumps.as_deref())?; let output_filename = matches .get_one::("output_filename") @@ -83,6 +83,7 @@ pub(crate) fn parse_args() -> Result { let output_target = output_target_for_path(&output_filename); validate_output_path(&output_filename)?; validate_distinct_input_output(&input_source, &output_filename)?; + validate_distinct_key_output(anonymize.as_deref(), &output_filename)?; validate_output_mode(output_target, format, report_format)?; let zstd = matches.get_flag("zstd") || env_zstd; @@ -90,6 +91,8 @@ pub(crate) fn parse_args() -> Result { let silent = resolve_silent_mode(&matches, env_silent, output_target); let affinity = matches.get_flag("affinity") || env_affinity; let dns_wire_fast_path = matches.get_flag("dns_wire_fast_path") || env_dns_wire_fast_path; + let (allow_fragments, full_fragments) = + resolve_fragment_flags(&matches, env_allow_fragments, env_full_fragments); let monotonic_capture = matches.get_flag("monotonic_capture") || env_monotonic_capture; validate_parquet_only_flags(format, zstd, v2)?; @@ -110,6 +113,9 @@ pub(crate) fn parse_args() -> Result { bonded, anonymize, dns_wire_fast_path, + max_dns_compression_jumps, + allow_fragments, + full_fragments, }) } @@ -126,8 +132,13 @@ fn build_cli(version: &'static str) -> Command { DPP_AFFINITY Set to 'true' to apply CPU affinity to processing threads DPP_DNS_WIRE_FAST_PATH Set to 'true' to enable the optional question-only DNS wire fast path with hickory fallback + DPP_MAX_DNS_COMPRESSION_JUMPS + Maximum compression-pointer jumps per DNS name; default is 32, 0 disables the limit (used if --max-dns-compression-jumps is not specified) + DPP_ALLOW_FRAGMENTS Set to 'true' to match queries with observable first IPv4 response fragments without reassembly + DPP_FULL_FRAGMENTS Set to 'true' to reassemble IPv4 fragments and enable first-fragment response matching DPP_MONOTONIC_CAPTURE Set to 'true' to assume globally monotonic packet timestamps, enable batched timeout eviction, and abort on timestamp regressions + DPP_THREADS Maximum CPU execution budget (positive integer, capped to available CPUs; used if --threads is not specified) DPP_REPORT_FORMAT Final process report format: text or json (used if --report-format is not specified; json cannot be combined with stdout output) DPP_MATCH_TIMEOUT_MS DNS match timeout in milliseconds; allowed range is 1..=5000, default is 1200 DPP_BONDED=N Set IO channel capacity in records; internally rounded up to batched messages of up to 1024 records; 0 uses the safe default bounded capacity @@ -194,7 +205,7 @@ LICENSE INFORMATION: Arg::new("threads") .long("threads") .short('t') - .hide(true) + .help("Cap the CPU execution budget (positive integer, never exceeds available CPUs)") .value_name("N") .num_args(1), ) @@ -233,6 +244,26 @@ LICENSE INFORMATION: .help("Enable the optional question-only DNS wire fast path with hickory fallback") .action(ArgAction::SetTrue), ) + .arg( + Arg::new("max_dns_compression_jumps") + .long("max-dns-compression-jumps") + .help("Maximum compression-pointer jumps per DNS name (default: 32; 0 disables the limit)") + .value_name("N") + .num_args(1) + .value_parser(clap::value_parser!(usize)), + ) + .arg( + Arg::new("allow_fragments") + .long("allow-fragments") + .help("Match queries with observable first IPv4 response fragments without reassembly; full UDP/DNS content remains unverified") + .action(ArgAction::SetTrue), + ) + .arg( + Arg::new("full_fragments") + .long("full-fragments") + .help("Reassemble IPv4 fragments into complete datagrams; also enables --allow-fragments") + .action(ArgAction::SetTrue), + ) .arg( Arg::new("anonymize") .long("anonymize") @@ -274,6 +305,12 @@ fn parse_env_bool(key: &str) -> bool { }) } +fn resolve_fragment_flags(matches: &ArgMatches, env_allow: bool, env_full: bool) -> (bool, bool) { + let full_fragments = matches.get_flag("full_fragments") || env_full; + let allow_fragments = matches.get_flag("allow_fragments") || env_allow || full_fragments; + (allow_fragments, full_fragments) +} + fn resolve_match_timeout_ms(matches: &ArgMatches, env_value: Option<&str>) -> Result { match matches .get_one::("match_timeout_ms") @@ -285,6 +322,23 @@ fn resolve_match_timeout_ms(matches: &ArgMatches, env_value: Option<&str>) -> Re } } +fn resolve_thread_limit(matches: &ArgMatches, env_value: Option<&OsStr>) -> Result> { + let value = if let Some(cli_value) = matches.get_one::("threads") { + cli_value.as_str() + } else if let Some(env_value) = env_value { + env_value + .to_str() + .ok_or_else(|| anyhow!("DPP_THREADS must be valid UTF-8"))? + } else { + return Ok(None); + }; + let limit = value + .parse::() + .with_context(|| format!("Failed to parse CPU thread limit from '{value}'"))?; + anyhow::ensure!(limit > 0, "CPU thread limit must be greater than 0."); + Ok(Some(limit)) +} + fn resolve_bonded(matches: &ArgMatches, env_value: Option<&OsStr>) -> Result { if let Some(value) = matches.get_one::("bonded") { return Ok(*value); @@ -300,6 +354,29 @@ fn resolve_bonded(matches: &ArgMatches, env_value: Option<&OsStr>) -> Result, +) -> Result { + if let Some(value) = matches.get_one::("max_dns_compression_jumps") { + return Ok(*value); + } + + match env_value { + Some(value) => value + .to_str() + .ok_or_else(|| anyhow!("DPP_MAX_DNS_COMPRESSION_JUMPS must contain valid UTF-8"))? + .parse::() + .with_context(|| { + format!( + "Failed to parse DPP_MAX_DNS_COMPRESSION_JUMPS from '{}'", + value.display() + ) + }), + None => Ok(DEFAULT_MAX_DNS_COMPRESSION_JUMPS), + } +} + fn resolve_silent_mode( matches: &ArgMatches, env_silent: bool, @@ -382,6 +459,27 @@ fn validate_distinct_input_output(input_source: &InputSource, output_path: &Path Ok(()) } +fn validate_distinct_key_output(key_path: Option<&Path>, output_path: &Path) -> Result<()> { + let Some(key_path) = key_path else { + return Ok(()); + }; + if matches!(output_target_for_path(output_path), OutputTarget::Stdout) || !output_path.exists() + { + return Ok(()); + } + + if same_file::is_same_file(key_path, output_path) + .context("Failed to compare anonymization key and output file identities")? + { + bail!( + "Error: Anonymization key '{}' and output path '{}' refer to the same file; refusing to overwrite the key.", + key_path.display(), + output_path.display() + ); + } + Ok(()) +} + fn validate_output_mode( output_target: OutputTarget, format: OutputFormat, @@ -618,6 +716,71 @@ mod tests { assert_eq!(resolved, DEFAULT_MATCH_TIMEOUT_MS); } + #[test] + fn compression_jump_limit_defaults_to_32_and_accepts_zero() { + let defaults = build_cli("test") + .try_get_matches_from(["dpp", "input.pcap"]) + .expect("cli parses"); + assert_eq!( + resolve_max_dns_compression_jumps(&defaults, None).unwrap(), + DEFAULT_MAX_DNS_COMPRESSION_JUMPS + ); + assert_eq!(DEFAULT_MAX_DNS_COMPRESSION_JUMPS, 32); + for value in ["0", "64"] { + let expected = value.parse::().unwrap(); + assert_eq!( + resolve_max_dns_compression_jumps(&defaults, Some(OsStr::new(value))).unwrap(), + expected + ); + let cli = build_cli("test") + .try_get_matches_from(["dpp", "--max-dns-compression-jumps", value, "input.pcap"]) + .expect("cli parses"); + assert_eq!( + resolve_max_dns_compression_jumps(&cli, Some(OsStr::new("invalid"))).unwrap(), + expected + ); + } + } + + #[test] + fn invalid_compression_jump_limits_are_rejected() { + let defaults = build_cli("test") + .try_get_matches_from(["dpp", "input.pcap"]) + .expect("cli parses"); + for value in ["-1", "invalid", "184467440737095516160"] { + assert!( + build_cli("test") + .try_get_matches_from([ + "dpp", + &format!("--max-dns-compression-jumps={value}"), + "input.pcap", + ]) + .is_err() + ); + assert!(resolve_max_dns_compression_jumps(&defaults, Some(OsStr::new(value))).is_err()); + } + } + + #[cfg(unix)] + #[test] + fn non_utf8_compression_jump_env_is_rejected_unless_cli_overrides_it() { + use std::os::unix::ffi::OsStrExt; + + let invalid = OsStr::from_bytes(&[0xff]); + let defaults = build_cli("test") + .try_get_matches_from(["dpp", "input.pcap"]) + .expect("cli parses"); + assert!(resolve_max_dns_compression_jumps(&defaults, Some(invalid)).is_err()); + + let cli = build_cli("test") + .try_get_matches_from(["dpp", "--max-dns-compression-jumps", "0", "input.pcap"]) + .expect("cli parses"); + assert_eq!( + resolve_max_dns_compression_jumps(&cli, Some(invalid)).unwrap(), + 0 + ); + } + #[test] fn env_match_timeout_wins_when_cli_is_absent() { let matches = build_cli("test") @@ -640,6 +803,57 @@ mod tests { assert_eq!(resolved, 500); } + #[test] + fn thread_limit_uses_env_and_cli_takes_precedence() { + let env_only = build_cli("test") + .try_get_matches_from(["dpp", "input.pcap"]) + .expect("cli parses"); + assert_eq!( + resolve_thread_limit(&env_only, Some(OsStr::new("4"))).unwrap(), + Some(4) + ); + + let cli = build_cli("test") + .try_get_matches_from(["dpp", "--threads", "2", "input.pcap"]) + .expect("cli parses"); + assert_eq!( + resolve_thread_limit(&cli, Some(OsStr::new("4"))).unwrap(), + Some(2) + ); + } + + #[test] + fn invalid_thread_limits_are_rejected() { + let env_only = build_cli("test") + .try_get_matches_from(["dpp", "input.pcap"]) + .expect("cli parses"); + for invalid in ["0", "bad", "184467440737095516160"] { + assert!(resolve_thread_limit(&env_only, Some(OsStr::new(invalid))).is_err()); + } + + let cli = build_cli("test") + .try_get_matches_from(["dpp", "--threads", "0", "input.pcap"]) + .expect("cli parses"); + assert!(resolve_thread_limit(&cli, None).is_err()); + } + + #[cfg(unix)] + #[test] + fn non_utf8_thread_env_is_rejected_unless_cli_overrides_it() { + use std::os::unix::ffi::OsStrExt; + + let invalid = OsStr::from_bytes(&[0xff]); + let env_only = build_cli("test") + .try_get_matches_from(["dpp", "input.pcap"]) + .expect("cli parses"); + assert!(resolve_thread_limit(&env_only, Some(invalid)).is_err()); + + let cli = build_cli("test") + .try_get_matches_from(["dpp", "--threads", "2", "input.pcap"]) + .expect("cli parses"); + assert_eq!(resolve_thread_limit(&cli, Some(invalid)).unwrap(), Some(2)); + } + #[test] fn cli_monotonic_capture_flag_is_recognized() { let matches = build_cli("test") @@ -649,6 +863,39 @@ mod tests { assert!(matches.get_flag("monotonic_capture")); } + #[test] + fn cli_allow_fragments_flag_is_opt_in() { + let default_matches = build_cli("test") + .try_get_matches_from(["dpp", "input.pcap"]) + .expect("cli parses"); + assert!(!default_matches.get_flag("allow_fragments")); + + let enabled_matches = build_cli("test") + .try_get_matches_from(["dpp", "--allow-fragments", "input.pcap"]) + .expect("cli parses"); + assert!(enabled_matches.get_flag("allow_fragments")); + } + + #[test] + fn full_fragments_implies_allow_fragments() { + let matches = build_cli("test") + .try_get_matches_from(["dpp", "--full-fragments", "input.pcap"]) + .expect("cli parses"); + assert_eq!(resolve_fragment_flags(&matches, false, false), (true, true)); + + let default_matches = build_cli("test") + .try_get_matches_from(["dpp", "input.pcap"]) + .expect("cli parses"); + assert_eq!( + resolve_fragment_flags(&default_matches, false, true), + (true, true) + ); + assert_eq!( + resolve_fragment_flags(&default_matches, false, false), + (false, false) + ); + } + #[test] fn zero_match_timeout_is_rejected() { let error = parse_match_timeout_ms("0").expect_err("zero timeout is invalid"); @@ -692,6 +939,24 @@ mod tests { fs::remove_file(path).expect("removes temp anonymization key"); } + #[test] + fn anonymization_key_and_hard_link_cannot_be_output() { + let key_path = unique_temp_path("anonymize.key"); + let alias_path = unique_temp_path("anonymize-output.csv"); + fs::write(&key_path, b"secret").expect("writes temp anonymization key"); + fs::hard_link(&key_path, &alias_path).expect("creates hard link to key"); + + for output_path in [&key_path, &alias_path] { + let error = validate_distinct_key_output(Some(&key_path), output_path) + .expect_err("key alias must not be used as output"); + assert!(error.to_string().contains("refusing to overwrite the key")); + } + assert_eq!(fs::read(&key_path).expect("reads preserved key"), b"secret"); + + fs::remove_file(alias_path).expect("removes hard link"); + fs::remove_file(key_path).expect("removes key file"); + } + #[test] fn stdout_output_sentinel_is_accepted() { validate_output_path(Path::new("-")).expect("stdout sentinel is accepted"); diff --git a/src/config.rs b/src/config.rs index aaeedb3..78676b8 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,6 +17,9 @@ pub(crate) const PACKET_BATCH_SIZE: usize = 65_536; /// Default DNS match timeout, expressed in milliseconds. pub(crate) const DEFAULT_MATCH_TIMEOUT_MS: u64 = 1_200; +/// Default maximum number of compression-pointer jumps in one DNS name. +pub(crate) const DEFAULT_MAX_DNS_COMPRESSION_JUMPS: usize = 32; + /// Maximum supported DNS match timeout, expressed in milliseconds. pub(crate) const MAX_MATCH_TIMEOUT_MS: u64 = 5_000; @@ -166,6 +169,9 @@ pub(crate) struct AppConfig { pub(crate) bonded: usize, pub(crate) anonymize: Option, pub(crate) dns_wire_fast_path: bool, + pub(crate) max_dns_compression_jumps: usize, + pub(crate) allow_fragments: bool, + pub(crate) full_fragments: bool, } #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] @@ -258,7 +264,10 @@ impl AppConfig { } pub(crate) fn execution_budget(&self) -> ExecutionBudget { - ExecutionBudget::from_available_cpus(self.num_cpus) + let budget_cpus = self + .requested_threads + .map_or(self.num_cpus, |limit| self.num_cpus.min(limit)); + ExecutionBudget::from_available_cpus(budget_cpus) } } @@ -283,6 +292,9 @@ mod tests { bonded: 0, anonymize: None, dns_wire_fast_path: false, + max_dns_compression_jumps: DEFAULT_MAX_DNS_COMPRESSION_JUMPS, + allow_fragments: false, + full_fragments: false, } } @@ -352,4 +364,22 @@ mod tests { assert_eq!(budget.staged_worker_budget, 14); assert!(budget.uses_staged_pipeline()); } + + #[test] + fn thread_limit_caps_budget_without_exceeding_available_cpus() { + let mut config = test_config(); + config.num_cpus = 16; + config.requested_threads = Some(3); + + let capped = config.execution_budget(); + assert_eq!(capped.available_cpus, 3); + assert_eq!(capped.model, ExecutionModel::PhaseParallel); + assert_eq!(capped.rayon_threads, Some(3)); + + config.requested_threads = Some(32); + let uncapped = config.execution_budget(); + assert_eq!(uncapped.available_cpus, 16); + assert_eq!(uncapped.model, ExecutionModel::Staged); + assert_eq!(uncapped.staged_worker_budget, 14); + } } diff --git a/src/custom_types.rs b/src/custom_types.rs index 4cdb4e5..485c2fc 100644 --- a/src/custom_types.rs +++ b/src/custom_types.rs @@ -321,7 +321,7 @@ fn parse_response_code_text(value: &str) -> Option { } } -const DNS_NAME_INLINE_CAPACITY: usize = 255; +const DNS_NAME_INLINE_CAPACITY: usize = 64; /// Maximum escaped presentation length for a 255-octet wire name: 250 label bytes, each expanded /// to a four-byte octal escape, plus three label separators. pub const DNS_NAME_PRESENTATION_MAX_LENGTH: usize = 1003; @@ -333,8 +333,8 @@ struct InlineDnsName { } #[derive(Clone)] -// Boxing the inline variant would allocate for every common QNAME. Keep the large no-allocation -// hot path and spill only the rare presentation that exceeds 255 bytes. +// Keep common QNAMEs inline while limiting the size of matcher identity keys. Longer +// presentations spill without changing the 1003-byte validation limit. #[allow(clippy::large_enum_variant)] enum DnsNameStorage { Inline(InlineDnsName), @@ -695,7 +695,7 @@ mod tests { #[test] fn dns_name_buf_spills_presentation_larger_than_inline_capacity() { - let spilled = "a".repeat(256); + let spilled = "a".repeat(super::DNS_NAME_INLINE_CAPACITY + 1); let name = DnsNameBuf::new(&spilled).expect("spill presentation fits"); assert_eq!(name.as_str(), spilled); @@ -729,7 +729,13 @@ mod tests { #[test] fn dns_name_buf_keeps_common_names_inline_without_kilobyte_layout() { - assert!(size_of::() <= 272); + assert!(size_of::() <= 80); + let at_boundary = DnsNameBuf::new(&"a".repeat(super::DNS_NAME_INLINE_CAPACITY)) + .expect("inline presentation fits"); + assert!(matches!( + at_boundary.storage, + super::DnsNameStorage::Inline(_) + )); } #[test] diff --git a/src/dns_processor.rs b/src/dns_processor.rs index 9b25298..ba14592 100644 --- a/src/dns_processor.rs +++ b/src/dns_processor.rs @@ -7,15 +7,18 @@ mod anonymizer; mod matcher; +mod name_decoder; mod parser; mod pipeline; +mod reassembly; #[cfg(test)] mod tests; mod types; #[cfg(test)] use crate::config::DEFAULT_MATCH_TIMEOUT_MS; -pub(crate) use pipeline::ProcessingCounters; +use crate::config::DEFAULT_MAX_DNS_COMPRESSION_JUMPS; +pub(crate) use pipeline::{PipelineExecutionConfig, ProcessingCounters}; use std::io; use std::net::IpAddr; use std::path::Path; @@ -23,8 +26,11 @@ use std::path::Path; pub struct DnsProcessor { anonymizer: anonymizer::Anonymizer, dns_wire_fast_path: bool, + max_dns_compression_jumps: usize, match_timeout_micros: i64, monotonic_capture: bool, + allow_fragments: bool, + full_fragments: bool, } impl DnsProcessor { @@ -63,11 +69,30 @@ impl DnsProcessor { Ok(DnsProcessor { anonymizer: anonymizer::Anonymizer::new(anonymize_key_path)?, dns_wire_fast_path, + max_dns_compression_jumps: DEFAULT_MAX_DNS_COMPRESSION_JUMPS, match_timeout_micros, monotonic_capture, + allow_fragments: false, + full_fragments: false, }) } + pub fn with_allow_fragments(mut self, allow_fragments: bool) -> Self { + self.allow_fragments = allow_fragments; + self + } + + pub fn with_max_dns_compression_jumps(mut self, limit: usize) -> Self { + self.max_dns_compression_jumps = limit; + self + } + + pub fn with_full_fragments(mut self, full_fragments: bool) -> Self { + self.full_fragments = full_fragments; + self.allow_fragments |= full_fragments; + self + } + fn anonymize_ip(&self, ip: &IpAddr) -> IpAddr { self.anonymizer.anonymize_ip(ip) } diff --git a/src/dns_processor/anonymizer.rs b/src/dns_processor/anonymizer.rs index 7c215c5..cfff7d5 100644 --- a/src/dns_processor/anonymizer.rs +++ b/src/dns_processor/anonymizer.rs @@ -10,50 +10,93 @@ use aes::cipher::{Block, BlockCipherEncrypt, KeyInit}; use pbkdf2::pbkdf2_hmac; use sha2::Sha256; use std::fs; -use std::io::{self, Read}; +use std::io; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::path::Path; -// The community edition uses a fixed PBKDF2 salt on purpose so that the same passphrase yields the -// same deterministic pseudonymization output across runs and hosts. The passphrase remains the -// operator-controlled secret; rotating it rotates all derived pseudonyms. +// Legacy text key files retain this salt so existing pseudonyms remain stable across runs and hosts. static SALT: [u8; 32] = [ 199, 76, 160, 70, 220, 85, 167, 75, 67, 93, 117, 51, 223, 17, 109, 52, 125, 192, 43, 44, 172, 36, 193, 95, 137, 81, 216, 92, 201, 141, 252, 241, ]; +const SALTED_KEY_MAGIC: &[u8] = b"\x89DPP-ANON-KEY-v2\n"; +const IPV4_FEISTEL_ROUNDS: u8 = 8; +const IPV4_KEY_LABEL: &[u8] = b"dpp-ipv4-feistel-v1"; + +struct AnonymizationCiphers { + ipv4: Aes256, + ipv6: Aes256, +} pub(super) struct Anonymizer { - cipher: Option, + ciphers: Option, } impl Anonymizer { pub(super) fn new(anonymize_key_path: Option<&Path>) -> io::Result { - let cipher = match anonymize_key_path { + let ciphers = match anonymize_key_path { Some(path) => { - let passphrase = Self::read_key_from_file(path)?; - Some(Self::derive_cipher(&passphrase)?) + let (passphrase, salt) = Self::read_key_from_file(path)?; + Some(Self::derive_ciphers(&passphrase, &salt)?) } None => None, }; - Ok(Self { cipher }) + Ok(Self { ciphers }) } pub(super) fn anonymize_ip(&self, ip: &IpAddr) -> IpAddr { - let Some(cipher) = &self.cipher else { + let Some(ciphers) = &self.ciphers else { return *ip; }; match ip { - IpAddr::V4(ipv4) => IpAddr::V4(Self::encrypt_ipv4(cipher, ipv4)), - IpAddr::V6(ipv6) => IpAddr::V6(Self::encrypt_ipv6(cipher, ipv6)), + IpAddr::V4(ipv4) => IpAddr::V4(Self::encrypt_ipv4(&ciphers.ipv4, ipv4)), + IpAddr::V6(ipv6) => IpAddr::V6(Self::encrypt_ipv6(&ciphers.ipv6, ipv6)), } } - fn read_key_from_file(path: &Path) -> Result, io::Error> { - let mut file = fs::File::open(path)?; - let mut passphrase = String::new(); - file.read_to_string(&mut passphrase)?; + fn read_key_from_file(path: &Path) -> io::Result<(Box, [u8; 32])> { + let contents = fs::read(path)?; + let (passphrase, salt) = if let Some(body) = contents.strip_prefix(SALTED_KEY_MAGIC) { + let salt_line_end = body.iter().position(|byte| *byte == b'\n').ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "salted key file has no salt line", + ) + })?; + let salt_hex = &body[..salt_line_end]; + if salt_hex.len() != 64 { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "salted key file must contain exactly 64 hexadecimal salt digits", + )); + } + if !salt_hex.iter().all(u8::is_ascii_hexdigit) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid hexadecimal salt", + )); + } + let mut salt = [0u8; 32]; + for (output, pair) in salt.iter_mut().zip(salt_hex.as_chunks::<2>().0) { + let digits = std::str::from_utf8(pair).expect("ASCII hex digits checked"); + *output = u8::from_str_radix(digits, 16).expect("ASCII hex digits checked"); + } + ( + std::str::from_utf8(&body[salt_line_end + 1..]).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "passphrase must be valid UTF-8") + })?, + salt, + ) + } else { + ( + std::str::from_utf8(&contents).map_err(|_| { + io::Error::new(io::ErrorKind::InvalidData, "passphrase must be valid UTF-8") + })?, + SALT, + ) + }; let passphrase = passphrase.trim(); if passphrase.is_empty() { return Err(io::Error::new( @@ -62,12 +105,23 @@ impl Anonymizer { )); } - Ok(passphrase.into()) + Ok((passphrase.into(), salt)) } - fn derive_cipher(passphrase: &str) -> Result { - let key = Self::derive_key_from_passphrase(passphrase, &SALT)?; - Ok(Aes256::new((&key).into())) + fn derive_ciphers( + passphrase: &str, + salt: &[u8; 32], + ) -> Result { + // Keep the original key for IPv6 so existing IPv6 pseudonyms remain stable. + let ipv6_key = Self::derive_key_from_passphrase(passphrase, salt)?; + let mut ipv4_salt = salt.to_vec(); + ipv4_salt.extend_from_slice(IPV4_KEY_LABEL); + let ipv4_key = Self::derive_key_from_passphrase(passphrase, &ipv4_salt)?; + + Ok(AnonymizationCiphers { + ipv4: Aes256::new((&ipv4_key).into()), + ipv6: Aes256::new((&ipv6_key).into()), + }) } fn derive_key_from_passphrase(passphrase: &str, salt: &[u8]) -> Result<[u8; 32], io::Error> { @@ -79,19 +133,28 @@ impl Anonymizer { } fn encrypt_ipv4(cipher: &Aes256, ipv4: &Ipv4Addr) -> Ipv4Addr { - let mut block = [0u8; 16]; - block[..4].copy_from_slice(&ipv4.octets()); + let octets = ipv4.octets(); + let mut left = u16::from_be_bytes([octets[0], octets[1]]); + let mut right = u16::from_be_bytes([octets[2], octets[3]]); + + // Each Feistel round is invertible regardless of collisions in its round function. + for round in 0..IPV4_FEISTEL_ROUNDS { + (left, right) = (right, left ^ Self::ipv4_round(cipher, round, right)); + } - let mut encrypted_block = Block::::default(); - encrypted_block.copy_from_slice(&block); - cipher.encrypt_block(&mut encrypted_block); + let mut output = [0u8; 4]; + output[..2].copy_from_slice(&left.to_be_bytes()); + output[2..].copy_from_slice(&right.to_be_bytes()); + Ipv4Addr::from(output) + } - Ipv4Addr::new( - encrypted_block[0], - encrypted_block[1], - encrypted_block[2], - encrypted_block[3], - ) + fn ipv4_round(cipher: &Aes256, round: u8, right: u16) -> u16 { + let mut block = Block::::default(); + block[..12].copy_from_slice(b"dpp-ipv4-v1:"); + block[12] = round; + block[14..].copy_from_slice(&right.to_be_bytes()); + cipher.encrypt_block(&mut block); + u16::from_be_bytes([block[0], block[1]]) } fn encrypt_ipv6(cipher: &Aes256, ipv6: &Ipv6Addr) -> Ipv6Addr { @@ -107,14 +170,15 @@ impl Anonymizer { #[cfg(test)] mod tests { - use super::Anonymizer; + use super::{Anonymizer, SALT, SALTED_KEY_MAGIC}; + use std::collections::HashSet; use std::fs; use std::io::ErrorKind; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; - fn temp_key_file(contents: &str) -> PathBuf { + fn temp_key_file(contents: impl AsRef<[u8]>) -> PathBuf { let mut path = std::env::temp_dir(); let unique = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -145,10 +209,109 @@ mod tests { assert_eq!(first, second); assert_ne!(first, ip); + assert_eq!( + first, + IpAddr::V6(Ipv6Addr::new( + 0x4733, 0xfbc0, 0xdf1c, 0xdc0e, 0x97e5, 0x4929, 0x9f74, 0x5421 + )), + "existing IPv6 pseudonyms must remain stable" + ); + assert_eq!( + anonymizer.anonymize_ip(&IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))), + IpAddr::V4(Ipv4Addr::new(54, 5, 4, 44)), + "IPv4 pseudonyms must remain stable across runs and hosts" + ); fs::remove_file(key_path).expect("removes temp key file"); } + #[test] + fn salted_key_changes_both_families_and_is_stable_across_reloads() { + let legacy_path = temp_key_file("secret-passphrase\n"); + let mut salted_contents = SALTED_KEY_MAGIC.to_vec(); + salted_contents.extend_from_slice( + b"00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff\nsecret-passphrase\n", + ); + let salted_path = temp_key_file(salted_contents); + + let legacy = Anonymizer::new(Some(&legacy_path)).expect("legacy key loads"); + let salted = Anonymizer::new(Some(&salted_path)).expect("salted key loads"); + let reloaded = Anonymizer::new(Some(&salted_path)).expect("salted key reloads"); + for ip in [ + IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)), + IpAddr::V6(Ipv6Addr::LOCALHOST), + ] { + assert_ne!(salted.anonymize_ip(&ip), legacy.anonymize_ip(&ip)); + assert_eq!(salted.anonymize_ip(&ip), reloaded.anonymize_ip(&ip)); + } + + fs::remove_file(legacy_path).expect("removes legacy key file"); + fs::remove_file(salted_path).expect("removes salted key file"); + } + + #[test] + fn malformed_salted_keys_are_rejected_without_legacy_fallback() { + for body in [ + b"short\nsecret".as_slice(), + b"00112233445566778899aabbccddeeff00112233445566778899aabbccddeefg\nsecret", + b"00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff", + b"00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff\n ", + b"00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff\n\xff", + ] { + let mut contents = SALTED_KEY_MAGIC.to_vec(); + contents.extend_from_slice(body); + let key_path = temp_key_file(contents); + let error = Anonymizer::new(Some(&key_path)) + .err() + .expect("malformed salted key must fail"); + assert!( + matches!( + error.kind(), + ErrorKind::InvalidData | ErrorKind::InvalidInput + ), + "unexpected error: {error}" + ); + fs::remove_file(key_path).expect("removes malformed key file"); + } + + let mut contents = SALTED_KEY_MAGIC.to_vec(); + let mut body = + b"00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff\nsecret".to_vec(); + body[0] = b'+'; + contents.extend_from_slice(&body); + let key_path = temp_key_file(contents); + assert_eq!( + Anonymizer::new(Some(&key_path)) + .err() + .expect("plus sign is not a hex digit") + .kind(), + ErrorKind::InvalidData + ); + fs::remove_file(key_path).expect("removes malformed key file"); + } + + #[test] + fn text_that_looks_like_a_key_header_remains_a_legacy_passphrase() { + let passphrase = "DPP-ANON-KEY-v2\n00112233445566778899aabbccddeeff"; + let key_path = temp_key_file(passphrase); + let (loaded, salt) = Anonymizer::read_key_from_file(&key_path).expect("legacy key loads"); + assert_eq!(&*loaded, passphrase); + assert_eq!(salt, SALT); + fs::remove_file(key_path).expect("removes legacy key file"); + } + + #[test] + fn ipv4_pseudonyms_are_unique_for_distinct_addresses() { + let ciphers = Anonymizer::derive_ciphers("secret", &SALT).expect("derives ciphers"); + let mut seen = HashSet::with_capacity(100_000); + + for address in 0..100_000u32 { + let input = Ipv4Addr::from(address.to_be_bytes()); + let output = Anonymizer::encrypt_ipv4(&ciphers.ipv4, &input); + assert!(seen.insert(output), "IPv4 collision for {input}"); + } + } + #[test] fn missing_key_file_is_an_error() { let mut path = std::env::temp_dir(); diff --git a/src/dns_processor/matcher.rs b/src/dns_processor/matcher.rs index e2f4bd6..f982be2 100644 --- a/src/dns_processor/matcher.rs +++ b/src/dns_processor/matcher.rs @@ -50,7 +50,7 @@ impl DnsProcessor { query_identity: &QueryIdentityKey, query_key: TimelineKey, response_timestamp_micros: i64, - response_code: ProtoResponseCode, + response_code: Option, ) -> DnsRecord { let (id, name, client_ip, src_port, query_type, ..) = query_identity; DnsRecord { @@ -61,7 +61,7 @@ impl DnsProcessor { id: *id, name: name.clone(), query_type: ProtoRecordType::from(*query_type), - response_code: Some(response_code), + response_code, } } @@ -238,6 +238,11 @@ impl DnsProcessor { ) { let response_identity = self.response_identity_from_record(record); let response_key = self.timeline_key_from_record(record); + let response_code = if record.partial_first_ipv4_fragment { + record.partial_response_code + } else { + Some(record.response_code) + }; *dns_response_count += 1; if let Some((_, query_handle)) = self.find_closest_query( @@ -255,7 +260,7 @@ impl DnsProcessor { &response_identity, query_handle, response_key.timestamp_micros, - record.response_code, + response_code, )); *matched_query_response_count += 1; *matched_rtt_sum_micros += response_key @@ -263,12 +268,7 @@ impl DnsProcessor { .saturating_sub(query_handle.timestamp_micros) .max(0) as u64; } else { - self.insert_response_entry( - state, - response_identity, - response_key, - record.response_code, - ); + self.insert_response_entry(state, response_identity, response_key, response_code); } } @@ -408,7 +408,7 @@ impl DnsProcessor { state: &mut MatcherShardState, identity: ResponseIdentityKey, timeline_key: TimelineKey, - response_code: ProtoResponseCode, + response_code: Option, ) { let payload = ResponseEventPayload { response_code }; @@ -494,6 +494,7 @@ impl DnsProcessor { resolver_ip, record.query_class, record.opcode, + record.vlan_context.clone(), ) } @@ -513,6 +514,7 @@ impl DnsProcessor { resolver_ip, record.query_class, record.opcode, + record.vlan_context.clone(), ) } @@ -527,6 +529,7 @@ impl DnsProcessor { query.resolver_ip, query.query_class, query.opcode, + super::types::VlanContext::default(), ) } @@ -557,6 +560,9 @@ impl DnsProcessor { }), ); if !record.is_query { + if record.partial_first_ipv4_fragment { + result.fragmented_response_prefix_count += 1; + } self.process_response( &record, state, diff --git a/src/dns_processor/name_decoder.rs b/src/dns_processor/name_decoder.rs new file mode 100644 index 0000000..96e7932 --- /dev/null +++ b/src/dns_processor/name_decoder.rs @@ -0,0 +1,383 @@ +/* + * Nameto Oy © 2026. All rights reserved. + * + * This software is licensed under the GNU General Public License (GPL) version 3. + * Commercial licensing options: . + */ + +use arrayvec::ArrayVec; +use std::collections::HashMap; + +const POINTER_TAG: u8 = 0xc0; +const MAX_POINTER_OFFSET: usize = 0x3fff; +const DIRECT_SUFFIX_BYTES: usize = 64; +const DIRECT_POINTER_JUMPS: usize = 8; + +#[derive(Clone, Copy, Debug)] +pub(super) struct ValidatedName { + /// End of this segment's encoded labels and terminating pointer or root. + wire_end: usize, + pub(super) jumps: usize, + pub(super) expanded_wire_len: usize, + /// First label (or root), bypassing any leading pointer-only segments. + first_label: usize, +} + +struct PendingSegment { + start: usize, + wire_end: usize, + label_bytes: usize, +} + +impl PendingSegment { + fn prepend(self, suffix: ValidatedName) -> ValidatedName { + ValidatedName { + wire_end: self.wire_end, + jumps: suffix.jumps + 1, + expanded_wire_len: self.label_bytes + suffix.expanded_wire_len, + first_label: if self.label_bytes == 0 { + suffix.first_label + } else { + self.start + }, + } + } +} + +/// One immutable DNS message owns all cached validation results. Only referenced +/// suffixes are cached: ordinary flat RR lists do not grow the cache per record. +/// Small messages need no heap allocation; larger caches cover at most the 14-bit +/// pointer address space. No recursion is used, even when the jump limit is zero. +pub(super) struct DnsNameDecoder<'a> { + data: &'a [u8], + max_jumps: usize, + cache: Option, + #[cfg(test)] + visited: usize, +} + +#[derive(Default)] +struct DnsNameCache { + inline: ArrayVec<(usize, ValidatedName), 8>, + overflow: Option>, + pending: ArrayVec, + pending_overflow: Vec, +} + +impl<'a> DnsNameDecoder<'a> { + pub(super) fn new(data: &'a [u8], max_jumps: usize) -> Self { + Self { + data, + max_jumps, + cache: None, + #[cfg(test)] + visited: 0, + } + } + + pub(super) fn data(&self) -> &'a [u8] { + self.data + } + + fn cached(&self, start: usize) -> Option { + let state = self.cache.as_ref()?; + if let Some(cache) = &state.overflow { + cache.get(&start).copied() + } else { + state + .inline + .iter() + .find_map(|&(offset, name)| (offset == start).then_some(name)) + } + } + + fn remember(&mut self, start: usize, name: ValidatedName) { + debug_assert!(start <= MAX_POINTER_OFFSET); + let state = self + .cache + .as_mut() + .expect("an uncached pointer initialized the traversal state"); + if let Some(cache) = &mut state.overflow { + cache.insert(start, name); + } else if !state.inline.is_full() { + state.inline.push((start, name)); + } else { + let mut cache = HashMap::with_capacity(state.inline.len() * 2); + cache.extend(state.inline.drain(..)); + cache.insert(start, name); + state.overflow = Some(cache); + } + } + + fn check_jumps(&self, jumps: usize) -> Result<(), &'static str> { + if self.max_jumps != 0 && jumps > self.max_jumps { + Err("DNS compression jump limit exceeded") + } else { + Ok(()) + } + } + + fn boundary_error(&self, end: usize, truncated: &'static str) -> &'static str { + if end < self.data.len() { + "DNS compression name overlaps its target" + } else { + truncated + } + } + + #[inline] + pub(super) fn read(&mut self, cursor: &mut usize) -> Result { + self.read_inner::(cursor, |_| {}) + } + + #[inline] + pub(super) fn read_with_labels( + &mut self, + cursor: &mut usize, + visit: impl FnMut(&[u8]), + ) -> Result { + self.read_inner::(cursor, visit) + } + + #[inline(always)] + fn read_inner( + &mut self, + cursor: &mut usize, + mut visit: impl FnMut(&[u8]), + ) -> Result { + let start = *cursor; + let mut segment_start = start; + let mut segment_end = self.data.len(); + let mut jumps = 0; + + let mut suffix = 'segments: loop { + let mut position = segment_start; + loop { + #[cfg(test)] + { + self.visited += 1; + } + let length = *self + .data + .get(..segment_end) + .and_then(|segment| segment.get(position)) + .ok_or_else(|| self.boundary_error(segment_end, "DNS name truncated"))?; + match length { + 0 => { + break 'segments ValidatedName { + wire_end: position + 1, + jumps: 0, + expanded_wire_len: position - segment_start + 1, + first_label: segment_start, + }; + } + _ if length & POINTER_TAG == POINTER_TAG => { + let next = *self + .data + .get(..segment_end) + .and_then(|segment| segment.get(position + 1)) + .ok_or_else(|| { + self.boundary_error( + segment_end, + "DNS compression pointer truncated", + ) + })?; + let target = (usize::from(length & 0x3f) << 8) | usize::from(next); + if target >= segment_start { + return Err("DNS compression pointer is not prior to name"); + } + jumps += 1; + self.check_jumps(jumps)?; + let segment = PendingSegment { + start: segment_start, + wire_end: position + 2, + label_bytes: position - segment_start, + }; + if let Some(cached) = self.cached(target) { + // The cached suffix was validated in a possibly wider range. + // Its first segment must still end BEFORE this name begins. + if cached.wire_end > segment_start { + return Err("DNS compression name overlaps its target"); + } + self.check_jumps(jumps + cached.jumps)?; + if VISIT { + for label in self.labels(cached) { + visit(label); + } + } + break 'segments segment.prepend(cached); + } + let state = self.cache.get_or_insert_with(DnsNameCache::default); + if jumps == 1 { + state.pending.clear(); + state.pending_overflow.clear(); + } + if state.pending.is_full() { + state.pending_overflow.push(segment); + } else { + state.pending.push(segment); + } + segment_end = segment_start; + segment_start = target; + continue 'segments; + } + _ if length & POINTER_TAG != 0 => return Err("Unsupported DNS label encoding"), + _ => { + let end = position + 1 + usize::from(length); + let label = self + .data + .get(..segment_end) + .and_then(|segment| segment.get(position + 1..end)) + .ok_or_else(|| { + self.boundary_error(segment_end, "DNS label truncated") + })?; + if VISIT { + visit(label); + } + position = end; + } + } + } + }; + + // Publish only fully validated suffixes. Failed or partial names cannot + // poison later reads, including the semantic fallback in the same message. + if segment_start == start { + *cursor = suffix.wire_end; + return Ok(suffix); + } + self.remember(segment_start, suffix); + while let Some(segment) = { + let state = self + .cache + .as_mut() + .expect("a traversed suffix has pending segments"); + state.pending_overflow.pop().or_else(|| state.pending.pop()) + } { + let offset = segment.start; + suffix = segment.prepend(suffix); + if offset != start { + self.remember(offset, suffix); + } + } + *cursor = suffix.wire_end; + Ok(suffix) + } + + #[inline(always)] + pub(super) fn skip(&mut self, cursor: &mut usize) -> Result { + let start = *cursor; + let mut position = start; + let mut resume = None; + let mut segment_start = start; + let mut segment_end = self.data.len(); + let mut jumps = 0; + loop { + let length = *self + .data + .get(..segment_end) + .and_then(|segment| segment.get(position)) + .ok_or_else(|| self.boundary_error(segment_end, "DNS name truncated"))?; + match length { + 0 => { + *cursor = resume.unwrap_or(position + 1); + // OPT requires a literal root, never an expanded root. + return Ok(resume.is_none() && position == start); + } + _ if length & POINTER_TAG == POINTER_TAG => { + // Every setting permits the first jump. Defer policy work + // until nested compression, keeping literal/direct names cheap. + if jumps != 0 && (jumps == DIRECT_POINTER_JUMPS || jumps == self.max_jumps) { + // Cheap short walks avoid cache bookkeeping. The full + // walker enforces smaller configured limits and caches + // longer chains, counting every cached transition. + return self.skip_cached(cursor); + } + let next = *self + .data + .get(..segment_end) + .and_then(|segment| segment.get(position + 1)) + .ok_or_else(|| { + self.boundary_error(segment_end, "DNS compression pointer truncated") + })?; + let target = (usize::from(length & 0x3f) << 8) | usize::from(next); + if target >= segment_start { + return Err("DNS compression pointer is not prior to name"); + } + jumps += 1; + if resume.is_none() { + resume = Some(position + 2); + } + segment_end = segment_start; + segment_start = target; + position = target; + } + _ if length & POINTER_TAG != 0 => return Err("Unsupported DNS label encoding"), + _ => { + let end = position + 1 + usize::from(length); + if resume.is_some() && end - segment_start > DIRECT_SUFFIX_BYTES { + // Bound repeated literal-label work too, including RR + // owner names whose expanded size is not materialized. + return self.skip_cached(cursor); + } + self.data + .get(..segment_end) + .and_then(|segment| segment.get(position + 1..end)) + .ok_or_else(|| self.boundary_error(segment_end, "DNS label truncated"))?; + position = end; + } + } + } + } + + #[inline(never)] + fn skip_cached(&mut self, cursor: &mut usize) -> Result { + self.read(cursor)?; + Ok(false) + } + + pub(super) fn labels(&self, name: ValidatedName) -> NameLabels<'_, 'a> { + NameLabels { + decoder: self, + position: name.first_label, + } + } +} + +#[cfg(test)] +#[path = "name_decoder_tests.rs"] +mod tests; + +/// Materialization visits only label-bearing segments. Pointer-only chains have +/// already been validated and reduced to their first label by `read`. +pub(super) struct NameLabels<'decoder, 'data> { + decoder: &'decoder DnsNameDecoder<'data>, + position: usize, +} + +impl<'data> Iterator for NameLabels<'_, 'data> { + type Item = &'data [u8]; + + fn next(&mut self) -> Option { + loop { + let data = self.decoder.data; + let length = data[self.position]; + if length == 0 { + return None; + } + if length & POINTER_TAG == POINTER_TAG { + let target = + (usize::from(length & 0x3f) << 8) | usize::from(data[self.position + 1]); + self.position = self + .decoder + .cached(target) + .expect("every followed pointer target has been validated") + .first_label; + } else { + let start = self.position + 1; + self.position = start + usize::from(length); + return Some(&data[start..self.position]); + } + } + } +} diff --git a/src/dns_processor/name_decoder_tests.rs b/src/dns_processor/name_decoder_tests.rs new file mode 100644 index 0000000..83f0f47 --- /dev/null +++ b/src/dns_processor/name_decoder_tests.rs @@ -0,0 +1,328 @@ +/* + * Nameto Oy © 2026. All rights reserved. + * + * This software is licensed under the GNU General Public License (GPL) version 3. + * Commercial licensing options: . + */ + +use super::*; + +fn append_pointer(data: &mut Vec, target: usize) -> usize { + assert!(target <= MAX_POINTER_OFFSET); + let start = data.len(); + data.extend_from_slice(&(0xc000 | target as u16).to_be_bytes()); + start +} + +fn pointer_chain(depth: usize) -> (Vec, Vec) { + let mut data = vec![0]; + let mut offsets = vec![0]; + for _ in 0..depth { + let next = append_pointer(&mut data, *offsets.last().unwrap()); + offsets.push(next); + } + (data, offsets) +} + +#[test] +fn cache_hit_still_rejects_a_suffix_overlapping_its_pointer() { + let mut data = vec![4, b'a', b'b', 0xc0, 0, 0]; + let warm = append_pointer(&mut data, 0); + let mut decoder = DnsNameDecoder::new(&data, 0); + let mut cursor = warm; + decoder.read(&mut cursor).unwrap(); + assert_eq!(decoder.cached(0).unwrap().wire_end, 6); + + let mut overlapping = 3; + assert!(decoder.read(&mut overlapping).is_err()); + assert_eq!(overlapping, 3); +} + +#[test] +fn cached_suffix_depth_counts_toward_the_limit() { + let (data, offsets) = pointer_chain(33); + let mut decoder = DnsNameDecoder::new(&data, 32); + for (depth, &offset) in offsets.iter().take(33).enumerate() { + let mut cursor = offset; + let name = decoder.read(&mut cursor).unwrap(); + assert_eq!(name.jumps, depth); + assert_eq!(cursor, offset + if depth == 0 { 1 } else { 2 }); + } + assert_eq!(decoder.cached(offsets[31]).unwrap().jumps, 31); + assert!(decoder.cached(offsets[32]).is_none()); + + for _ in 0..2 { + let mut cursor = offsets[33]; + assert_eq!( + decoder.read(&mut cursor).unwrap_err(), + "DNS compression jump limit exceeded" + ); + assert_eq!(cursor, offsets[33]); + assert!(decoder.cached(offsets[32]).is_none()); + } + // A failed traversal must not leave pending segments attached to the next name. + assert_eq!(decoder.read(&mut 0).unwrap().jumps, 0); +} + +#[test] +fn unlimited_mode_accepts_the_maximum_backward_pointer_chain() { + let (data, offsets) = pointer_chain(8193); + assert_eq!(offsets[8192], MAX_POINTER_OFFSET); + let mut decoder = DnsNameDecoder::new(&data, 0); + let mut cursor = offsets[8193]; + let name = decoder.read(&mut cursor).unwrap(); + assert_eq!(name.jumps, 8193); + assert_eq!(name.expanded_wire_len, 1); + assert_eq!(cursor, data.len()); + assert_eq!(decoder.labels(name).count(), 0); +} + +#[test] +fn materialization_preserves_labels_across_cached_pointer_only_segments() { + let mut data = b"\x03CoM\0".to_vec(); + let mut target = 0; + for _ in 0..9 { + target = append_pointer(&mut data, target); + } + let example = data.len(); + data.extend_from_slice(b"\x07ExAmPlE"); + append_pointer(&mut data, target); + target = example; + for _ in 0..9 { + target = append_pointer(&mut data, target); + } + let www = data.len(); + data.extend_from_slice(b"\x03WwW"); + append_pointer(&mut data, target); + + let mut decoder = DnsNameDecoder::new(&data, 32); + for start in [target, www, example, www] { + let mut cursor = start; + let name = decoder.read(&mut cursor).unwrap(); + let labels: Vec<_> = decoder.labels(name).collect(); + if start == www { + assert_eq!(labels, vec![&b"WwW"[..], &b"ExAmPlE"[..], &b"CoM"[..]]); + assert_eq!(name.expanded_wire_len, 17); + assert_eq!(name.jumps, 20); + } else { + assert_eq!(labels, vec![&b"ExAmPlE"[..], &b"CoM"[..]]); + } + } +} + +#[test] +fn short_chains_and_repeated_flat_names_use_only_inline_storage() { + let (data, offsets) = pointer_chain(8); + let mut decoder = DnsNameDecoder::new(&data, 32); + let mut cursor = offsets[8]; + decoder.read(&mut cursor).unwrap(); + assert!(decoder.cache.as_ref().unwrap().overflow.is_none()); + assert_eq!( + decoder.cache.as_ref().unwrap().pending_overflow.capacity(), + 0 + ); + assert!(decoder.cache.as_ref().unwrap().pending.is_empty()); + + let mut flat = b"\x07example\x03com\0".to_vec(); + let mut owners = Vec::new(); + for _ in 0..2000 { + owners.push(append_pointer(&mut flat, 0)); + } + let mut decoder = DnsNameDecoder::new(&flat, 32); + for mut owner in owners { + decoder.skip(&mut owner).unwrap(); + } + assert!(decoder.cache.is_none()); +} + +#[test] +fn cached_suffixes_are_local_to_their_message() { + let valid = [1, b'a', 0, 0xc0, 0]; + let invalid = [3, b'a', b'b', 0xc0, 0]; + let mut first = DnsNameDecoder::new(&valid, 0); + let name = first.read(&mut 3).unwrap(); + assert_eq!(first.labels(name).collect::>(), vec![&b"a"[..]]); + assert!(first.cached(0).is_some()); + + let mut second = DnsNameDecoder::new(&invalid, 0); + assert!(second.cached(0).is_none()); + assert!(second.read(&mut 3).is_err()); + assert!(second.cached(0).is_none()); +} + +#[test] +fn repeated_deep_names_require_linear_validation_work() { + let (mut data, offsets) = pointer_chain(1000); + let mut repeated = Vec::new(); + for _ in 0..4000 { + repeated.push(append_pointer(&mut data, offsets[1000])); + } + let mut decoder = DnsNameDecoder::new(&data, 0); + for (index, &offset) in repeated.iter().enumerate() { + let before = decoder.visited; + let mut cursor = offset; + let name = decoder.read(&mut cursor).unwrap(); + assert_eq!(name.jumps, 1001); + assert_eq!(name.expanded_wire_len, 1); + if index > 0 { + assert_eq!(decoder.visited - before, 1); + } + } + assert_eq!(decoder.visited, 1001 + repeated.len()); +} + +#[test] +fn compressed_root_does_not_become_a_literal_root_on_cache_hit() { + let data = [0, 0xc0, 0, 0xc0, 0]; + let mut decoder = DnsNameDecoder::new(&data, 32); + assert!(decoder.skip(&mut 0).unwrap()); + assert!(!decoder.skip(&mut 1).unwrap()); + decoder.read(&mut 1).unwrap(); + assert!(decoder.cached(0).is_some()); + assert!(!decoder.skip(&mut 3).unwrap()); +} + +#[test] +fn shallow_skip_fallback_enforces_the_second_jump_limit() { + let (data, offsets) = pointer_chain(2); + for warm_cache in [false, true] { + let mut decoder = DnsNameDecoder::new(&data, 1); + if warm_cache { + let mut cursor = offsets[1]; + decoder.read(&mut cursor).unwrap(); + } + let mut one_jump = offsets[1]; + assert!(!decoder.skip(&mut one_jump).unwrap()); + assert_eq!(one_jump, offsets[1] + 2); + + let mut two_jumps = offsets[2]; + assert_eq!( + decoder.skip(&mut two_jumps).unwrap_err(), + "DNS compression jump limit exceeded" + ); + assert_eq!(two_jumps, offsets[2]); + } +} + +#[test] +fn shallow_skip_reports_overlap_consistently_with_cold_and_warm_cache() { + let mut data = vec![4, b'a', b'b', 0xc0, 0, 0]; + let warm = append_pointer(&mut data, 0); + for warm_cache in [false, true] { + let mut decoder = DnsNameDecoder::new(&data, 0); + if warm_cache { + let mut cursor = warm; + decoder.read(&mut cursor).unwrap(); + } + let mut overlap = 3; + assert_eq!( + decoder.skip(&mut overlap).unwrap_err(), + "DNS compression name overlaps its target" + ); + assert_eq!(overlap, 3); + } +} + +#[test] +fn shallow_skip_memoizes_long_literal_suffixes() { + let mut data = Vec::new(); + for _ in 0..128 { + data.extend_from_slice(b"\x01x"); + } + data.push(0); + let mut owners = Vec::new(); + for _ in 0..2000 { + owners.push(append_pointer(&mut data, 0)); + } + + let mut decoder = DnsNameDecoder::new(&data, 1); + for (index, &owner) in owners.iter().enumerate() { + let before = decoder.visited; + let mut cursor = owner; + assert!(!decoder.skip(&mut cursor).unwrap()); + assert_eq!(cursor, owner + 2); + assert_eq!(decoder.cached(0).unwrap().expanded_wire_len, 257); + if index > 0 { + assert_eq!(decoder.visited - before, 1); + } + } + assert_eq!(decoder.cache.as_ref().unwrap().inline.len(), 1); + assert!(decoder.cache.as_ref().unwrap().overflow.is_none()); +} + +#[test] +fn streamed_labels_match_materialization_with_cold_and_warm_suffixes() { + let mut data = b"\x03CoM\0".to_vec(); + let mut target = 0; + for _ in 0..10 { + target = append_pointer(&mut data, target); + } + let example = data.len(); + data.extend_from_slice(b"\x07ExAmPlE"); + append_pointer(&mut data, target); + let www = data.len(); + data.extend_from_slice(b"\x03WwW"); + append_pointer(&mut data, example); + + let mut decoder = DnsNameDecoder::new(&data, 32); + for _ in 0..2 { + let mut cursor = www; + let mut visited_labels = Vec::new(); + let name = decoder + .read_with_labels(&mut cursor, |label| visited_labels.push(label.to_vec())) + .unwrap(); + assert_eq!(cursor, data.len()); + assert_eq!(name.jumps, 12); + assert_eq!(name.expanded_wire_len, 17); + assert_eq!( + visited_labels, + vec![b"WwW".to_vec(), b"ExAmPlE".to_vec(), b"CoM".to_vec()] + ); + assert_eq!( + decoder.labels(name).map(<[u8]>::to_vec).collect::>(), + visited_labels + ); + } +} + +#[test] +fn shallow_skip_switches_to_cached_validation_after_eight_jumps() { + let (data, offsets) = pointer_chain(9); + for (limit, accepts_ninth) in [(8, false), (9, true), (32, true), (0, true)] { + let mut decoder = DnsNameDecoder::new(&data, limit); + let mut eight_jumps = offsets[8]; + assert!(!decoder.skip(&mut eight_jumps).unwrap()); + assert_eq!(eight_jumps, offsets[8] + 2); + assert!(decoder.cache.is_none()); + assert_eq!(decoder.visited, 0); + + for _ in 0..2 { + let mut nine_jumps = offsets[9]; + assert_eq!(decoder.skip(&mut nine_jumps).is_ok(), accepts_ninth); + assert_eq!(nine_jumps, offsets[9] + if accepts_ninth { 2 } else { 0 }); + } + if accepts_ninth { + assert_eq!(decoder.cached(offsets[8]).unwrap().jumps, 8); + } + } +} + +#[test] +fn shallow_literal_threshold_keeps_short_suffixes_direct_and_caches_longer_ones() { + for extra_label in [false, true] { + let mut data = vec![63]; + data.extend_from_slice(&[b'x'; 63]); + if extra_label { + data.extend_from_slice(b"\x01y"); + } + data.push(0); + let owner = append_pointer(&mut data, 0); + let mut decoder = DnsNameDecoder::new(&data, 1); + for _ in 0..2 { + let mut cursor = owner; + assert!(!decoder.skip(&mut cursor).unwrap()); + assert_eq!(cursor, owner + 2); + assert_eq!(decoder.cached(0).is_some(), extra_label); + } + } +} diff --git a/src/dns_processor/parser.rs b/src/dns_processor/parser.rs index e221a78..49b2fd2 100644 --- a/src/dns_processor/parser.rs +++ b/src/dns_processor/parser.rs @@ -7,6 +7,7 @@ use hickory_proto::ProtoError; use hickory_proto::op::Header; +#[cfg(test)] use hickory_proto::op::Message; use hickory_proto::op::Query; use hickory_proto::op::ResponseCode as HickoryResponseCode; @@ -16,7 +17,8 @@ use hickory_proto::serialize::binary::{BinDecodable, BinDecoder, DecodeError}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use super::DnsProcessor; -use super::types::ProcessedDnsRecord; +use super::name_decoder::DnsNameDecoder; +use super::types::{ProcessedDnsRecord, VlanContext}; use crate::custom_types::{DnsNameBuf, DnsNameTooLong, ProtoResponseCode}; const ETHERNET_HEADER_LEN: usize = 14; @@ -27,6 +29,9 @@ const DNS_HEADER_LEN: usize = 12; const MIN_DNS_OFFSET: usize = ETHERNET_HEADER_LEN + IPV4_MIN_HEADER_LEN + UDP_HEADER_LEN; const ETHER_TYPE_IPV4: u16 = 0x0800; const ETHER_TYPE_IPV6: u16 = 0x86dd; +const ETHER_TYPE_VLAN: u16 = 0x8100; +const ETHER_TYPE_PROVIDER_BRIDGING: u16 = 0x88a8; +const ETHER_TYPE_LEGACY_QINQ: u16 = 0x9100; const IP_PROTOCOL_UDP: u8 = 17; const IPV6_HOP_BY_HOP: u8 = 0; const IPV6_ROUTING: u8 = 43; @@ -35,16 +40,42 @@ const IP_AUTHENTICATION: u8 = 51; const IPV6_DESTINATION_OPTIONS: u8 = 60; const IPV6_FRAGMENT_OFFSET_AND_MORE: u16 = 0xfff9; const IPV4_MORE_FRAGMENTS: u16 = 0x2000; +const IPV4_DONT_FRAGMENT: u16 = 0x4000; +const IPV4_RESERVED_FLAG: u16 = 0x8000; const IPV4_FRAGMENT_OFFSET_MASK: u16 = 0x1fff; const DNS_PORT: u16 = 53; -const DNS_POINTER_MASK: u8 = 0b1100_0000; -const DNS_POINTER_TAG: u8 = 0b1100_0000; -const DNS_LABEL_LEN_MASK: u8 = 0b0011_1111; -const DNS_COMPRESSION_JUMP_LIMIT: usize = 32; const DNS_RESOURCE_RECORD_FIXED_LEN: usize = 10; const DNS_OPT_RECORD_TYPE: u16 = 41; const DNS_TSIG_RECORD_TYPE: u16 = 250; +/// Decode the encapsulated EtherType, payload start and canonical VLAN context once. +/// Reassembly and DNS matching share this traversal and its VLAN identity semantics. +pub(super) fn ethernet_ethertype_and_payload_offset( + data: &[u8], +) -> Result<(u16, usize, VlanContext), &'static str> { + let ethernet = data + .get(..ETHERNET_HEADER_LEN) + .ok_or("Failed to parse Ethernet packet")?; + let mut ethertype = u16::from_be_bytes([ethernet[12], ethernet[13]]); + let mut payload_offset = ETHERNET_HEADER_LEN; + let mut tags = Vec::new(); + + while matches!( + ethertype, + ETHER_TYPE_VLAN | ETHER_TYPE_PROVIDER_BRIDGING | ETHER_TYPE_LEGACY_QINQ + ) { + let tag = data + .get(payload_offset..payload_offset + 4) + .ok_or("Failed to parse Ethernet VLAN tag")?; + let vlan_id = u16::from_be_bytes([tag[0], tag[1]]) & 0x0fff; + tags.push((u32::from(ethertype) << 16) | u32::from(vlan_id)); + ethertype = u16::from_be_bytes([tag[2], tag[3]]); + payload_offset += 4; + } + + Ok((ethertype, payload_offset, VlanContext::from_tags(tags))) +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) struct CanonicalFlowKey { pub(super) client_ip: IpAddr, @@ -52,22 +83,25 @@ pub(super) struct CanonicalFlowKey { pub(super) resolver_ip: IpAddr, } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, Eq, PartialEq)] pub(super) struct ParsedUdpDnsMeta { pub(super) flow_key: CanonicalFlowKey, + vlan_context: VlanContext, // DNS starts at least at byte 42. A maximum IPv6 frame ends at byte 65589; // reserving the validated 12-byte DNS header bounds this delta to 65535. dns_offset_delta: u16, pub(super) dns_len: u16, pub(super) is_response: bool, + /// The first IPv4 fragment contains a complete question but not the complete UDP datagram. + pub(super) partial_first_ipv4_fragment: bool, } impl ParsedUdpDnsMeta { - fn dns_offset(self) -> usize { + fn dns_offset(&self) -> usize { MIN_DNS_OFFSET + usize::from(self.dns_offset_delta) } - fn src_ip(self) -> IpAddr { + fn src_ip(&self) -> IpAddr { if self.is_response { self.flow_key.resolver_ip } else { @@ -75,7 +109,7 @@ impl ParsedUdpDnsMeta { } } - fn dst_ip(self) -> IpAddr { + fn dst_ip(&self) -> IpAddr { if self.is_response { self.flow_key.client_ip } else { @@ -83,7 +117,7 @@ impl ParsedUdpDnsMeta { } } - fn src_port(self) -> u16 { + fn src_port(&self) -> u16 { if self.is_response { DNS_PORT } else { @@ -91,7 +125,7 @@ impl ParsedUdpDnsMeta { } } - fn dst_port(self) -> u16 { + fn dst_port(&self) -> u16 { if self.is_response { self.flow_key.client_port } else { @@ -99,7 +133,7 @@ impl ParsedUdpDnsMeta { } } - fn dns_data(self, data: &[u8]) -> Result<&[u8], &'static str> { + fn dns_data<'a>(&self, data: &'a [u8]) -> Result<&'a [u8], &'static str> { let start = self.dns_offset(); let end = start .checked_add(usize::from(self.dns_len)) @@ -112,6 +146,7 @@ struct DecodedDnsHeader { id: u16, opcode: u8, response_code: ProtoResponseCode, + partial_response_code: Option, } struct DecodedDnsQuestion { @@ -162,8 +197,19 @@ struct AdditionalResponseCodeFields { impl DnsProcessor { #[inline] + #[cfg(test)] pub(super) fn packet_routing_meta(data: &[u8]) -> Option { - Self::extract_udp_dns_meta(data).ok().flatten() + Self::packet_routing_meta_with_fragments(data, false) + } + + #[inline] + pub(super) fn packet_routing_meta_with_fragments( + data: &[u8], + allow_first_ipv4_response_fragment: bool, + ) -> Option { + Self::extract_udp_dns_meta_with_fragments(data, allow_first_ipv4_response_fragment) + .ok() + .flatten() } #[inline] @@ -339,8 +385,11 @@ impl DnsProcessor { meta: ParsedUdpDnsMeta, records: &mut Vec, ) -> Result<(), DnsQuestionDecodeError> { - let (header, queries) = - self.decode_dns_questions(meta.dns_data(data)?, meta.is_response)?; + let (header, queries) = self.decode_dns_questions( + meta.dns_data(data)?, + meta.is_response, + meta.partial_first_ipv4_fragment, + )?; self.build_dns_records_into( &header, @@ -389,6 +438,9 @@ impl DnsProcessor { query_class: query.query_class, opcode: header.opcode, response_code, + vlan_context: meta.vlan_context.clone(), + partial_first_ipv4_fragment: meta.partial_first_ipv4_fragment, + partial_response_code: header.partial_response_code, }); } } @@ -397,12 +449,31 @@ impl DnsProcessor { &self, dns_data: &[u8], decode_extended_rcode: bool, + partial_first_ipv4_fragment: bool, ) -> Result<(DecodedDnsHeader, Vec), DnsQuestionDecodeError> { + let mut names = DnsNameDecoder::new(dns_data, self.max_dns_compression_jumps); if self.dns_wire_fast_path { - Self::decode_dns_questions_fast(dns_data, decode_extended_rcode) - .or_else(|_| Self::decode_dns_questions_hickory(dns_data, decode_extended_rcode)) + Self::decode_dns_questions_fast( + dns_data, + &mut names, + decode_extended_rcode, + partial_first_ipv4_fragment, + ) + .or_else(|_| { + Self::decode_dns_questions_hickory( + dns_data, + &mut names, + decode_extended_rcode, + partial_first_ipv4_fragment, + ) + }) } else { - Self::decode_dns_questions_hickory(dns_data, decode_extended_rcode) + Self::decode_dns_questions_hickory( + dns_data, + &mut names, + decode_extended_rcode, + partial_first_ipv4_fragment, + ) } } @@ -421,9 +492,17 @@ impl DnsProcessor { opcode == 0 && query_count > 1 } + fn question_capacity(dns_len: usize, count: usize) -> usize { + // Even a root question occupies five bytes. An untrusted count alone + // must not reserve tens of MiB for presentation-form names. + count.min(dns_len.saturating_sub(DNS_HEADER_LEN) / 5) + } + fn decode_dns_questions_fast( dns_data: &[u8], + names: &mut DnsNameDecoder<'_>, decode_extended_rcode: bool, + partial_first_ipv4_fragment: bool, ) -> Result<(DecodedDnsHeader, Vec), DnsQuestionDecodeError> { if dns_data.len() < DNS_HEADER_LEN { return Err(DnsQuestionDecodeError::Invalid); @@ -443,9 +522,9 @@ impl DnsProcessor { } let mut cursor = DNS_HEADER_LEN; - let mut queries = Vec::with_capacity(query_count); + let mut queries = Vec::with_capacity(Self::question_capacity(dns_data.len(), query_count)); for _ in 0..query_count { - let name = Self::read_wire_domain_name(dns_data, &mut cursor)?; + let name = Self::read_wire_domain_name(names, &mut cursor)?; let query_type = HickoryRecordType::from(Self::parse_u16_at( dns_data, cursor, @@ -463,6 +542,7 @@ impl DnsProcessor { } let mut response_code = ProtoResponseCode::from(HickoryResponseCode::from(0, low_response_code)); + let mut partial_response_code = None; if decode_extended_rcode { let section_counts = DnsSectionCounts { answers: usize::from(Self::parse_u16_at( @@ -481,12 +561,21 @@ impl DnsProcessor { "Failed to parse DNS additional count", )?), }; - if section_counts.answers > 0 + if partial_first_ipv4_fragment { + partial_response_code = Self::decode_partial_response_code_with_sections( + dns_data, + names, + cursor, + section_counts, + low_response_code, + )?; + } else if section_counts.answers > 0 || section_counts.authorities > 0 || section_counts.additionals > 0 { response_code = Self::decode_response_code_with_sections( dns_data, + names, cursor, section_counts, low_response_code, @@ -497,6 +586,7 @@ impl DnsProcessor { id, opcode, response_code, + partial_response_code, }; Ok((header, queries)) @@ -504,7 +594,9 @@ impl DnsProcessor { fn decode_dns_questions_hickory( dns_data: &[u8], + names: &mut DnsNameDecoder<'_>, decode_extended_rcode: bool, + partial_first_ipv4_fragment: bool, ) -> Result<(DecodedDnsHeader, Vec), DnsQuestionDecodeError> { let mut decoder = BinDecoder::new(dns_data); let header = Header::read(&mut decoder).map_err(|_| DnsQuestionDecodeError::Invalid)?; @@ -512,24 +604,48 @@ impl DnsProcessor { if Self::invalid_question_count(opcode, header.counts.queries as usize) { return Err(DnsQuestionDecodeError::Invalid); } - let queries = Message::read_queries(&mut decoder, header.counts.queries as usize) - .map_err(Self::classify_hickory_question_error)?; + let mut cursor = decoder.index(); + let mut queries = Vec::with_capacity(Self::question_capacity( + dns_data.len(), + header.counts.queries as usize, + )); + for _ in 0..header.counts.queries { + let query = Self::read_hickory_question(dns_data, names, &mut cursor)?; + queries.push(DecodedDnsQuestion { + name: Self::format_domain_name(query.name()) + .map_err(|_| DnsQuestionDecodeError::Invalid)?, + query_type: query.query_type(), + query_class: query.query_class().into(), + }); + } let mut response_code = ProtoResponseCode::from(header.response_code); - if decode_extended_rcode - && (header.counts.answers > 0 - || header.counts.authorities > 0 - || header.counts.additionals > 0) - { - response_code = Self::decode_response_code_with_sections( - dns_data, - decoder.index(), - DnsSectionCounts { - answers: header.counts.answers as usize, - authorities: header.counts.authorities as usize, - additionals: header.counts.additionals as usize, - }, - header.response_code.low(), - )?; + let mut partial_response_code = None; + if decode_extended_rcode { + let section_counts = DnsSectionCounts { + answers: header.counts.answers as usize, + authorities: header.counts.authorities as usize, + additionals: header.counts.additionals as usize, + }; + if partial_first_ipv4_fragment { + partial_response_code = Self::decode_partial_response_code_with_sections( + dns_data, + names, + cursor, + section_counts, + header.response_code.low(), + )?; + } else if section_counts.answers > 0 + || section_counts.authorities > 0 + || section_counts.additionals > 0 + { + response_code = Self::decode_response_code_with_sections( + dns_data, + names, + cursor, + section_counts, + header.response_code.low(), + )?; + } } Ok(( @@ -537,23 +653,15 @@ impl DnsProcessor { id: header.id, opcode, response_code, + partial_response_code, }, - queries - .into_iter() - .map(|query: Query| { - Ok(DecodedDnsQuestion { - name: Self::format_domain_name(query.name()) - .map_err(|_| DnsQuestionDecodeError::Invalid)?, - query_type: query.query_type(), - query_class: query.query_class().into(), - }) - }) - .collect::, DnsQuestionDecodeError>>()?, + queries, )) } fn decode_response_code_with_sections( dns_data: &[u8], + names: &mut DnsNameDecoder<'_>, mut cursor: usize, section_counts: DnsSectionCounts, low_response_code: u8, @@ -561,10 +669,23 @@ impl DnsProcessor { let response_code = ProtoResponseCode::from(HickoryResponseCode::from(0, low_response_code)); - Self::skip_dns_resource_records(dns_data, &mut cursor, section_counts.answers, true)?; - Self::skip_dns_resource_records(dns_data, &mut cursor, section_counts.authorities, true)?; + Self::skip_dns_resource_records( + dns_data, + names, + &mut cursor, + section_counts.answers, + true, + )?; + Self::skip_dns_resource_records( + dns_data, + names, + &mut cursor, + section_counts.authorities, + true, + )?; let additional_fields = Self::read_additional_response_code_fields( dns_data, + names, &mut cursor, section_counts.additionals, )?; @@ -585,14 +706,83 @@ impl DnsProcessor { } } + fn decode_partial_response_code_with_sections( + dns_data: &[u8], + names: &mut DnsNameDecoder<'_>, + cursor: usize, + section_counts: DnsSectionCounts, + low_response_code: u8, + ) -> Result, DnsQuestionDecodeError> { + let low_code = ProtoResponseCode::from(HickoryResponseCode::from(0, low_response_code)); + let mut boundary_cursor = cursor; + let record_count = + section_counts.answers + section_counts.authorities + section_counts.additionals; + let mut additional_fields = AdditionalResponseCodeFields::default(); + for index in 0..record_count { + match Self::read_dns_resource_record_meta(dns_data, names, &mut boundary_cursor) { + Ok(meta) => { + if index < section_counts.answers + section_counts.authorities { + if meta.record_type == DNS_OPT_RECORD_TYPE { + return Err(DnsQuestionDecodeError::Invalid); + } + } else { + let additional_index = + index - section_counts.answers - section_counts.authorities; + Self::include_additional_response_code_fields( + dns_data, + names, + &mut additional_fields, + &meta, + additional_index, + section_counts.additionals, + ) + .map_err(|_| DnsQuestionDecodeError::Invalid)?; + } + } + Err(error) if Self::is_incomplete_fragment_section(error) => { + // OPT and TSIG are additional records. With no additionals, the header's + // low RCODE is final even when an answer spans later IP fragments. + return Ok((section_counts.additionals == 0).then_some(low_code)); + } + Err(_) => return Err(DnsQuestionDecodeError::Invalid), + } + } + + // All declared records fit in the first fragment. Decode their complete response-code + // context, including OPT and TSIG, using the same validation as full datagrams. + Self::decode_response_code_with_sections( + dns_data, + names, + cursor, + section_counts, + low_response_code, + ) + .map(Some) + .map_err(|_| DnsQuestionDecodeError::Invalid) + } + + fn is_incomplete_fragment_section(error: &str) -> bool { + matches!( + error, + "DNS name truncated" + | "DNS compression pointer truncated" + | "DNS label truncated" + | "DNS resource record truncated" + ) + } + + // Inline the section loop together with the metadata reader so callers can + // keep the cursor local and discard unused RR fields in the hot scan. + #[inline(always)] fn skip_dns_resource_records( dns_data: &[u8], + names: &mut DnsNameDecoder<'_>, cursor: &mut usize, count: usize, reject_opt: bool, ) -> Result<(), &'static str> { for _ in 0..count { - let meta = Self::read_dns_resource_record_meta(dns_data, cursor)?; + let meta = Self::read_dns_resource_record_meta(dns_data, names, cursor)?; if reject_opt && meta.record_type == DNS_OPT_RECORD_TYPE { return Err("OPT record outside additional section"); } @@ -603,46 +793,67 @@ impl DnsProcessor { fn read_additional_response_code_fields( dns_data: &[u8], + names: &mut DnsNameDecoder<'_>, cursor: &mut usize, additional_count: usize, ) -> Result { let mut fields = AdditionalResponseCodeFields::default(); for index in 0..additional_count { - let meta = Self::read_dns_resource_record_meta(dns_data, cursor)?; + let meta = Self::read_dns_resource_record_meta(dns_data, names, cursor)?; + Self::include_additional_response_code_fields( + dns_data, + names, + &mut fields, + &meta, + index, + additional_count, + )?; + } - match meta.record_type { - DNS_OPT_RECORD_TYPE => { - if fields.edns_response_code_high.is_some() { - return Err("Multiple OPT records"); - } - if !meta.owner_is_root { - return Err("OPT record owner must be root"); - } + Ok(fields) + } - fields.edns_response_code_high = Some((meta.ttl >> 24) as u8); + fn include_additional_response_code_fields( + dns_data: &[u8], + names: &mut DnsNameDecoder<'_>, + fields: &mut AdditionalResponseCodeFields, + meta: &DnsResourceRecordMeta, + index: usize, + additional_count: usize, + ) -> Result<(), &'static str> { + match meta.record_type { + DNS_OPT_RECORD_TYPE => { + if fields.edns_response_code_high.is_some() { + return Err("Multiple OPT records"); } - DNS_TSIG_RECORD_TYPE => { - if fields.tsig_error.is_some() { - return Err("Multiple TSIG records"); - } - if index + 1 != additional_count { - return Err("TSIG record must be last additional"); - } - - fields.tsig_error = Some(Self::read_tsig_error(dns_data, &meta)?); + if !meta.owner_is_root { + return Err("OPT record owner must be root"); + } + fields.edns_response_code_high = Some((meta.ttl >> 24) as u8); + } + DNS_TSIG_RECORD_TYPE => { + if fields.tsig_error.is_some() { + return Err("Multiple TSIG records"); + } + if index + 1 != additional_count { + return Err("TSIG record must be last additional"); } - _ => {} + fields.tsig_error = Some(Self::read_tsig_error(dns_data, names, meta)?); } + _ => {} } - - Ok(fields) + Ok(()) } - fn read_tsig_error(dns_data: &[u8], meta: &DnsResourceRecordMeta) -> Result { + fn read_tsig_error( + dns_data: &[u8], + names: &mut DnsNameDecoder<'_>, + meta: &DnsResourceRecordMeta, + ) -> Result { let mut cursor = meta.rdata_start; let rdata_end = meta.rdata_end; - Self::skip_wire_domain_name_in_range(dns_data, &mut cursor, rdata_end)?; + Self::skip_wire_domain_name_in_range(names, &mut cursor, rdata_end)?; Self::skip_bytes_in_range(&mut cursor, rdata_end, 6 + 2)?; let mac_size = usize::from(Self::parse_u16_at( dns_data, @@ -667,11 +878,14 @@ impl DnsProcessor { Ok(error) } + // Inlining lets answer/authority scans omit TTL and metadata materialization. + #[inline(always)] fn read_dns_resource_record_meta( dns_data: &[u8], + names: &mut DnsNameDecoder<'_>, cursor: &mut usize, ) -> Result { - let owner_is_root = Self::skip_wire_domain_name(dns_data, cursor)?; + let owner_is_root = names.skip(cursor)?; let fixed_start = *cursor; let fixed_end = fixed_start .checked_add(DNS_RESOURCE_RECORD_FIXED_LEN) @@ -700,7 +914,7 @@ impl DnsProcessor { } fn skip_wire_domain_name_in_range( - dns_data: &[u8], + names: &mut DnsNameDecoder<'_>, cursor: &mut usize, limit: usize, ) -> Result<(), &'static str> { @@ -708,7 +922,7 @@ impl DnsProcessor { return Err("DNS name truncated"); } - Self::skip_wire_domain_name(dns_data, cursor)?; + names.skip(cursor)?; if *cursor > limit { return Err("DNS name truncated"); } @@ -729,143 +943,46 @@ impl DnsProcessor { Ok(()) } - fn skip_wire_domain_name(dns_data: &[u8], cursor: &mut usize) -> Result { - let start = *cursor; - let mut position = *cursor; - let mut resume_position = None; - let mut jump_count = 0; - let mut segment_start = position; - let mut segment_end = dns_data.len(); - - loop { - let length = *dns_data - .get(..segment_end) - .and_then(|segment| segment.get(position)) - .ok_or("DNS name truncated")?; - - match length { - 0 => { - position += 1; - *cursor = resume_position.unwrap_or(position); - return Ok(resume_position.is_none() && position == start + 1); - } - _ if (length & DNS_POINTER_MASK) == DNS_POINTER_TAG => { - let next = *dns_data - .get(..segment_end) - .and_then(|segment| segment.get(position + 1)) - .ok_or("DNS compression pointer truncated")?; - let offset = - (((length & DNS_LABEL_LEN_MASK) as usize) << 8) | usize::from(next); - - if offset >= segment_start { - return Err("DNS compression pointer is not prior to name"); - } - - if resume_position.is_none() { - resume_position = Some(position + 2); - } - - jump_count += 1; - if jump_count > DNS_COMPRESSION_JUMP_LIMIT { - return Err("DNS compression pointer loop"); - } - - segment_end = segment_start; - segment_start = offset; - position = offset; - } - _ if (length & DNS_POINTER_MASK) != 0 => { - return Err("Unsupported DNS label encoding"); - } - _ => { - let label_len = usize::from(length); - dns_data - .get(..segment_end) - .and_then(|segment| segment.get(position + 1..position + 1 + label_len)) - .ok_or("DNS label truncated")?; - position += 1 + label_len; - } - } - } - } - fn read_wire_domain_name( - dns_data: &[u8], + names: &mut DnsNameDecoder<'_>, cursor: &mut usize, ) -> Result { - let mut output = DnsNameBuf::default(); + // Most questions contain no compression at all. Keep their original + // single-pass formatter free of cache metadata and callback overhead. + // A pointer restarts through the shared decoder from the original cursor. + let dns_data = names.data(); let mut position = *cursor; - let mut resume_position = None; - let mut wrote_label = false; - let mut jump_count = 0; + let mut output = DnsNameBuf::default(); let mut expanded_wire_len = 1_usize; let mut oversized = false; - let mut segment_start = position; - let mut segment_end = dns_data.len(); - loop { - let length = *dns_data - .get(..segment_end) - .and_then(|segment| segment.get(position)) - .ok_or("DNS name truncated")?; - + let length = *dns_data.get(position).ok_or("DNS name truncated")?; match length { 0 => { - position += 1; if oversized { return Err(DnsQuestionDecodeError::OversizedQname); } - if !wrote_label { + if output.as_str().is_empty() { output .try_push('.') .map_err(|_| DnsQuestionDecodeError::Invalid)?; } - - *cursor = resume_position.unwrap_or(position); + *cursor = position + 1; return Ok(output); } - _ if (length & DNS_POINTER_MASK) == DNS_POINTER_TAG => { - let next = *dns_data - .get(..segment_end) - .and_then(|segment| segment.get(position + 1)) - .ok_or("DNS compression pointer truncated")?; - let offset = - (((length & DNS_LABEL_LEN_MASK) as usize) << 8) | usize::from(next); - - // Each pointer must refer to an earlier, nonoverlapping name, - // matching Hickory's question decoder (RFC 1035 section 4.1.4). - if offset >= segment_start { - return Err(DnsQuestionDecodeError::Invalid); - } - - if resume_position.is_none() { - resume_position = Some(position + 2); - } - - jump_count += 1; - if jump_count > DNS_COMPRESSION_JUMP_LIMIT { - return Err(DnsQuestionDecodeError::Invalid); - } - - segment_end = segment_start; - segment_start = offset; - position = offset; - } - _ if (length & DNS_POINTER_MASK) != 0 => { - return Err(DnsQuestionDecodeError::Invalid); + _ if length & 0xc0 == 0xc0 => { + return Self::read_compressed_wire_domain_name(names, cursor); } + _ if length & 0xc0 != 0 => return Err(DnsQuestionDecodeError::Invalid), _ => { - let label_len = usize::from(length); + let end = position + 1 + usize::from(length); let label = dns_data - .get(..segment_end) - .and_then(|segment| segment.get(position + 1..position + 1 + label_len)) + .get(position + 1..end) .ok_or("DNS label truncated")?; - - expanded_wire_len = expanded_wire_len.saturating_add(1 + label_len); + expanded_wire_len = expanded_wire_len.saturating_add(1 + label.len()); oversized |= expanded_wire_len > Name::MAX_LENGTH; - if !oversized { - if wrote_label { + if !output.as_str().is_empty() { output .try_push('.') .map_err(|_| DnsQuestionDecodeError::Invalid)?; @@ -874,12 +991,73 @@ impl DnsProcessor { return Err(DnsQuestionDecodeError::Invalid); } } + position = end; + } + } + } + } - wrote_label = true; - position += 1 + label_len; + fn read_compressed_wire_domain_name( + names: &mut DnsNameDecoder<'_>, + cursor: &mut usize, + ) -> Result { + let mut output = DnsNameBuf::default(); + let mut presentation_valid = true; + let name = names.read_with_labels(cursor, |label| { + if presentation_valid { + if !output.as_str().is_empty() { + presentation_valid = output.try_push('.').is_ok(); } + presentation_valid &= Self::write_label_ascii(label, &mut output); } + })?; + if name.expanded_wire_len > Name::MAX_LENGTH { + return Err(DnsQuestionDecodeError::OversizedQname); + } + if !presentation_valid { + return Err(DnsQuestionDecodeError::Invalid); + } + if output.as_str().is_empty() { + output + .try_push('.') + .map_err(|_| DnsQuestionDecodeError::Invalid)?; + } + Ok(output) + } + + fn read_hickory_question( + dns_data: &[u8], + names: &mut DnsNameDecoder<'_>, + cursor: &mut usize, + ) -> Result { + let start = *cursor; + let name = names.read(cursor)?; + if name.expanded_wire_len > Name::MAX_LENGTH { + return Err(DnsQuestionDecodeError::OversizedQname); } + let fields = dns_data + .get(*cursor..*cursor + 4) + .ok_or("DNS question truncated")?; + *cursor += 4; + if name.jumps == 0 { + return Query::read(&mut BinDecoder::new(&dns_data[start..*cursor])) + .map_err(|error| Self::classify_hickory_question_error(error.into())); + } + + // Hickory keeps semantic ownership of questions, but never recursively + // follows the original chain again. The validated name fits on the stack. + let mut expanded = [0_u8; Name::MAX_LENGTH + 4]; + let mut end = 0; + for label in names.labels(name) { + expanded[end] = label.len() as u8; + end += 1; + expanded[end..end + label.len()].copy_from_slice(label); + end += label.len(); + } + end += 1; // terminating root, already zeroed + expanded[end..end + 4].copy_from_slice(fields); + Query::read(&mut BinDecoder::new(&expanded[..end + 4])) + .map_err(|error| Self::classify_hickory_question_error(error.into())) } #[inline] @@ -893,23 +1071,37 @@ impl DnsProcessor { Ok(u16::from_be_bytes([bytes[0], bytes[1]])) } + #[cfg(test)] fn extract_udp_dns_meta(data: &[u8]) -> Result, &'static str> { - let ethernet = data - .get(..ETHERNET_HEADER_LEN) - .ok_or("Failed to parse Ethernet packet")?; - let ethertype = u16::from_be_bytes([ethernet[12], ethernet[13]]); - let payload = &data[ETHERNET_HEADER_LEN..]; - - match ethertype { - ETHER_TYPE_IPV4 => Self::extract_udp_dns_from_ipv4(payload, ETHERNET_HEADER_LEN), - ETHER_TYPE_IPV6 => Self::extract_udp_dns_from_ipv6(payload, ETHERNET_HEADER_LEN), + Self::extract_udp_dns_meta_with_fragments(data, false) + } + + fn extract_udp_dns_meta_with_fragments( + data: &[u8], + allow_first_ipv4_response_fragment: bool, + ) -> Result, &'static str> { + let (ethertype, l3_offset, vlan_context) = ethernet_ethertype_and_payload_offset(data)?; + let payload = &data[l3_offset..]; + + let mut meta = match ethertype { + ETHER_TYPE_IPV4 => Self::extract_udp_dns_from_ipv4( + payload, + l3_offset, + allow_first_ipv4_response_fragment, + ), + ETHER_TYPE_IPV6 => Self::extract_udp_dns_from_ipv6(payload, l3_offset), _ => Ok(None), + }?; + if let Some(meta) = meta.as_mut() { + meta.vlan_context = vlan_context; } + Ok(meta) } fn extract_udp_dns_from_ipv4( data: &[u8], l3_offset: usize, + allow_first_ipv4_response_fragment: bool, ) -> Result, &'static str> { let header = data .get(..IPV4_MIN_HEADER_LEN) @@ -938,10 +1130,21 @@ impl DnsProcessor { let flags_and_fragment_offset = Self::parse_u16_at(header, 6, "Failed to parse IPv4 packet")?; - if flags_and_fragment_offset & (IPV4_MORE_FRAGMENTS | IPV4_FRAGMENT_OFFSET_MASK) != 0 { - // DNS extraction has no IPv4 fragment reassembly stage. + let fragmented = + flags_and_fragment_offset & (IPV4_MORE_FRAGMENTS | IPV4_FRAGMENT_OFFSET_MASK) != 0; + let first_fragment = flags_and_fragment_offset & IPV4_MORE_FRAGMENTS != 0 + && flags_and_fragment_offset & IPV4_FRAGMENT_OFFSET_MASK == 0; + if fragmented && !(allow_first_ipv4_response_fragment && first_fragment) { return Ok(None); } + if first_fragment + && flags_and_fragment_offset & (IPV4_RESERVED_FLAG | IPV4_DONT_FRAGMENT) != 0 + { + return Err("Contradictory IPv4 fragmentation flags"); + } + if first_fragment && (total_length - header_len) % 8 != 0 { + return Err("Non-final IPv4 fragment payload length is not divisible by eight"); + } if header[9] != IP_PROTOCOL_UDP { return Ok(None); @@ -959,6 +1162,8 @@ impl DnsProcessor { src_ip, dst_ip, l3_offset + header_len, + first_fragment, + usize::from(u16::MAX) - header_len, ) } @@ -1002,6 +1207,8 @@ impl DnsProcessor { src_ip, dst_ip, l3_offset + IPV6_HEADER_LEN + cursor, + false, + usize::from(u16::MAX), ); } @@ -1054,6 +1261,8 @@ impl DnsProcessor { src_ip: IpAddr, dst_ip: IpAddr, l4_offset: usize, + partial_first_ipv4_fragment: bool, + maximum_udp_length: usize, ) -> Result, &'static str> { if data.len() < UDP_HEADER_LEN { return Err("Failed to parse UDP packet"); @@ -1069,7 +1278,17 @@ impl DnsProcessor { if udp_length < UDP_HEADER_LEN { return Err("Failed to parse UDP packet"); } - let datagram = data.get(..udp_length).ok_or("Failed to parse UDP packet")?; + if udp_length > maximum_udp_length { + return Err("UDP length exceeds the IP datagram limit"); + } + let datagram = if partial_first_ipv4_fragment { + if udp_length <= data.len() { + return Err("First IPv4 fragment does not contain a partial UDP datagram"); + } + data + } else { + data.get(..udp_length).ok_or("Failed to parse UDP packet")? + }; let dns_data = datagram .get(UDP_HEADER_LEN..) .ok_or("Failed to parse UDP packet")?; @@ -1080,6 +1299,10 @@ impl DnsProcessor { if (is_response && src_port != DNS_PORT) || (!is_response && dst_port != DNS_PORT) { return Ok(None); } + if partial_first_ipv4_fragment && !is_response { + // A first-fragment query is not useful for the response-only opt-in mode. + return Ok(None); + } let dns_offset = l4_offset .checked_add(UDP_HEADER_LEN) @@ -1087,6 +1310,7 @@ impl DnsProcessor { Ok(Some(ParsedUdpDnsMeta { flow_key: Self::canonical_flow_key(src_ip, dst_ip, src_port, dst_port, is_response), + vlan_context: VlanContext::default(), dns_offset_delta: u16::try_from( dns_offset .checked_sub(MIN_DNS_OFFSET) @@ -1096,6 +1320,7 @@ impl DnsProcessor { dns_len: u16::try_from(dns_data.len()) .map_err(|_| "UDP DNS payload exceeds supported range")?, is_response, + partial_first_ipv4_fragment, })) } @@ -1150,6 +1375,29 @@ mod protocol_regression_tests { make_udp_dns_packet_with_payload(src, dst, src_port, dst_port, dns) } + fn with_vlan_tags(packet: &[u8], tags: &[(u16, u16)]) -> Vec { + let mut tagged = Vec::with_capacity(packet.len() + tags.len() * 4); + tagged.extend_from_slice(&packet[..12]); + for &(ethertype, tci) in tags { + tagged.extend_from_slice(ðertype.to_be_bytes()); + tagged.extend_from_slice(&tci.to_be_bytes()); + } + tagged.extend_from_slice(&packet[12..]); + tagged + } + + fn first_ipv4_fragment(mut packet: Vec, fragment_payload_len: usize) -> Vec { + assert_eq!(fragment_payload_len % 8, 0); + let fragment_len = IPV4_MIN_HEADER_LEN + fragment_payload_len; + assert!(packet.len() > ETHERNET_HEADER_LEN + fragment_len); + packet.truncate(ETHERNET_HEADER_LEN + fragment_len); + packet[ETHERNET_HEADER_LEN + 2..ETHERNET_HEADER_LEN + 4] + .copy_from_slice(&(fragment_len as u16).to_be_bytes()); + packet[ETHERNET_HEADER_LEN + 6..ETHERNET_HEADER_LEN + 8] + .copy_from_slice(&IPV4_MORE_FRAGMENTS.to_be_bytes()); + packet + } + fn ipv6(dns: &[u8], extensions: &[(u8, Vec)]) -> Vec { let mut payload = Vec::new(); for (index, (_, extension)) in extensions.iter().enumerate() { @@ -1198,6 +1446,67 @@ mod protocol_regression_tests { } } + #[test] + fn vlan_and_qinq_frames_route_and_decode_dns() { + let query_dns = question(0x0100, 1); + let response_dns = question(0x8180, 1); + let tags = [ + vec![(ETHER_TYPE_VLAN, 100)], + vec![(ETHER_TYPE_PROVIDER_BRIDGING, 200), (ETHER_TYPE_VLAN, 100)], + vec![(ETHER_TYPE_LEGACY_QINQ, 200), (ETHER_TYPE_VLAN, 100)], + ]; + + for tag_stack in &tags { + let query = with_vlan_tags(&ipv4(&query_dns, false, 53000), tag_stack); + let response = with_vlan_tags(&ipv4(&response_dns, true, 53000), tag_stack); + let query_meta = DnsProcessor::packet_routing_meta(&query).expect("tagged query"); + let response_meta = + DnsProcessor::packet_routing_meta(&response).expect("tagged response"); + assert_eq!(query_meta.flow_key, response_meta.flow_key); + assert_eq!(query_meta.dns_data(&query), Ok(query_dns.as_slice())); + assert_eq!( + response_meta.dns_data(&response), + Ok(response_dns.as_slice()) + ); + + for processor in processors() { + assert_eq!(processor.process_packet_batch(&query, 0).unwrap().len(), 1); + assert_eq!( + processor + .process_packet_batch(&response, 100) + .unwrap() + .len(), + 1 + ); + } + } + + let ipv6_query = with_vlan_tags( + &ipv6(&query_dns, &[]), + &[(ETHER_TYPE_PROVIDER_BRIDGING, 200), (ETHER_TYPE_VLAN, 100)], + ); + let ipv6_meta = DnsProcessor::packet_routing_meta(&ipv6_query).expect("tagged IPv6"); + assert_eq!(ipv6_meta.dns_data(&ipv6_query), Ok(query_dns.as_slice())); + } + + #[test] + fn truncated_vlan_tag_is_rejected() { + let mut frame = vec![0; ETHERNET_HEADER_LEN + 4]; + frame[12..14].copy_from_slice(ÐER_TYPE_VLAN.to_be_bytes()); + for len in ETHERNET_HEADER_LEN..ETHERNET_HEADER_LEN + 4 { + assert_eq!( + ethernet_ethertype_and_payload_offset(&frame[..len]), + Err("Failed to parse Ethernet VLAN tag") + ); + } + + frame[16..18].copy_from_slice(ÐER_TYPE_VLAN.to_be_bytes()); + assert_eq!( + ethernet_ethertype_and_payload_offset(&frame), + Err("Failed to parse Ethernet VLAN tag") + ); + } + #[test] fn qr_does_not_create_candidates_on_wrong_server_port() { for packet in [ @@ -1208,6 +1517,166 @@ mod protocol_regression_tests { } } + #[test] + fn opt_in_first_ipv4_response_fragment_requires_complete_question() { + let mut dns = question(0x8180, 1); + dns[6..8].copy_from_slice(&1_u16.to_be_bytes()); + dns[10..12].copy_from_slice(&1_u16.to_be_bytes()); + dns.extend_from_slice(&[0xc0, 0x0c, 0, 16, 0, 1, 0, 0, 0, 60, 0, 16]); + dns.extend_from_slice(&[b'x'; 16]); + dns.extend_from_slice(&[0; 16]); + let complete = ipv4(&dns, true, 53000); + let first = first_ipv4_fragment(complete, 40); + + assert!(DnsProcessor::packet_routing_meta(&first).is_none()); + let meta = DnsProcessor::packet_routing_meta_with_fragments(&first, true) + .expect("first response fragment routes when enabled"); + assert!(meta.partial_first_ipv4_fragment); + assert_eq!(meta.dns_data(&first).unwrap().len(), 32); + + for processor in processors() { + let records = match processor.process_packet_batch_with_meta(&first, 100, meta.clone()) + { + PacketProcessingOutcome::Records(records) => records, + other => panic!("partial response rejected: {other:?}"), + }; + assert_eq!(records.len(), 1); + assert_eq!(records[0].id, 0xbeef); + assert_eq!(records[0].name.as_str(), "example.com"); + assert!(!records[0].is_query); + assert!(records[0].partial_first_ipv4_fragment); + assert!(records[0].partial_response_code.is_none()); + } + + let incomplete_question = first_ipv4_fragment(ipv4(&dns, true, 53000), 24); + let meta = DnsProcessor::packet_routing_meta_with_fragments(&incomplete_question, true) + .expect("header-only first fragment routes for decode validation"); + for processor in processors() { + assert!(matches!( + processor.process_packet_batch_with_meta(&incomplete_question, 100, meta.clone()), + PacketProcessingOutcome::Invalid + )); + } + } + + #[test] + fn opt_in_does_not_accept_other_fragments_or_truncated_unfragmented_responses() { + let mut dns = question(0x8180, 1); + dns[6..8].copy_from_slice(&1_u16.to_be_bytes()); + dns.extend_from_slice(&[0xc0, 0x0c, 0, 16, 0, 1, 0, 0, 0, 60, 0, 16]); + dns.extend_from_slice(&[b'x'; 16]); + + let mut query_dns = dns.clone(); + query_dns[2..4].copy_from_slice(&0x0100_u16.to_be_bytes()); + let query_first = first_ipv4_fragment(ipv4(&query_dns, false, 53000), 40); + assert!(DnsProcessor::packet_routing_meta_with_fragments(&query_first, true).is_none()); + + let mut noninitial = first_ipv4_fragment(ipv4(&dns, true, 53000), 40); + noninitial[ETHERNET_HEADER_LEN + 6..ETHERNET_HEADER_LEN + 8] + .copy_from_slice(&(IPV4_MORE_FRAGMENTS | 1).to_be_bytes()); + assert!(DnsProcessor::packet_routing_meta_with_fragments(&noninitial, true).is_none()); + + for flags in [ + IPV4_MORE_FRAGMENTS | IPV4_DONT_FRAGMENT, + IPV4_MORE_FRAGMENTS | IPV4_RESERVED_FLAG, + ] { + let mut invalid_flags = first_ipv4_fragment(ipv4(&dns, true, 53000), 40); + invalid_flags[ETHERNET_HEADER_LEN + 6..ETHERNET_HEADER_LEN + 8] + .copy_from_slice(&flags.to_be_bytes()); + assert!( + DnsProcessor::packet_routing_meta_with_fragments(&invalid_flags, true).is_none() + ); + } + + let mut truncated_full = first_ipv4_fragment(ipv4(&dns, true, 53000), 40); + truncated_full[ETHERNET_HEADER_LEN + 6..ETHERNET_HEADER_LEN + 8] + .copy_from_slice(&0_u16.to_be_bytes()); + assert!(DnsProcessor::packet_routing_meta_with_fragments(&truncated_full, true).is_none()); + + let mut missing_answer = question(0x8180, 1); + missing_answer[6..8].copy_from_slice(&1_u16.to_be_bytes()); + let full_packet = ipv4(&missing_answer, true, 53000); + let meta = DnsProcessor::packet_routing_meta_with_fragments(&full_packet, true) + .expect("complete response routes for strict validation"); + assert!(!meta.partial_first_ipv4_fragment); + for processor in processors() { + assert!(matches!( + processor.process_packet_batch_with_meta(&full_packet, 100, meta.clone()), + PacketProcessingOutcome::Invalid + )); + } + } + + #[test] + fn partial_response_code_is_reported_only_when_complete_in_first_fragment() { + let mut no_additionals = question(0x8183, 1); + no_additionals[6..8].copy_from_slice(&1_u16.to_be_bytes()); + no_additionals.extend_from_slice(&[0xc0, 0x0c, 0, 16, 0, 1, 0, 0, 0, 60, 0, 16]); + no_additionals.extend_from_slice(&[b'x'; 16]); + let first = first_ipv4_fragment(ipv4(&no_additionals, true, 53000), 40); + let meta = DnsProcessor::packet_routing_meta_with_fragments(&first, true).unwrap(); + for processor in processors() { + let records = match processor.process_packet_batch_with_meta(&first, 100, meta.clone()) + { + PacketProcessingOutcome::Records(records) => records, + other => panic!("partial response rejected: {other:?}"), + }; + assert_eq!(records[0].partial_response_code.unwrap().as_u16(), 3); + } + + let mut with_opt = question(0x8180, 1); + with_opt[10..12].copy_from_slice(&1_u16.to_be_bytes()); + with_opt.extend_from_slice(&[0, 0, 41, 4, 208, 1, 0, 0, 0, 0, 0]); + with_opt.extend_from_slice(&[0; 16]); + let first = first_ipv4_fragment(ipv4(&with_opt, true, 53000), 48); + let meta = DnsProcessor::packet_routing_meta_with_fragments(&first, true).unwrap(); + for processor in processors() { + let records = match processor.process_packet_batch_with_meta(&first, 100, meta.clone()) + { + PacketProcessingOutcome::Records(records) => records, + other => panic!("partial response rejected: {other:?}"), + }; + assert_eq!(records[0].partial_response_code.unwrap().as_u16(), 16); + } + + let mut with_tsig = tsig_response(16, 0, &[]); + let fragment_payload_len = (UDP_HEADER_LEN + with_tsig.len()).next_multiple_of(8); + with_tsig.extend_from_slice(&[0; 16]); + let first = first_ipv4_fragment(ipv4(&with_tsig, true, 53000), fragment_payload_len); + let meta = DnsProcessor::packet_routing_meta_with_fragments(&first, true).unwrap(); + for processor in processors() { + let records = match processor.process_packet_batch_with_meta(&first, 100, meta.clone()) + { + PacketProcessingOutcome::Records(records) => records, + other => panic!("partial response rejected: {other:?}"), + }; + assert_eq!(records[0].partial_response_code.unwrap().as_u16(), 16); + assert_eq!( + records[0].partial_response_code.unwrap().as_str(), + "TSIG Failure" + ); + } + } + + #[test] + fn partial_response_rejects_visible_opt_in_answer_even_if_later_record_is_incomplete() { + let mut dns = question(0x8180, 1); + dns[6..8].copy_from_slice(&2_u16.to_be_bytes()); + // A complete OPT in the answer section is already invalid, although the + // following answer extends beyond the first IPv4 fragment. + dns.extend_from_slice(&[0, 0, 41, 4, 208, 0, 0, 0, 0, 0, 0]); + dns.extend_from_slice(&[0xc0, 0x0c, 0, 16, 0, 1, 0, 0, 0, 60, 0, 16]); + dns.extend_from_slice(&[b'x'; 16]); + let first = first_ipv4_fragment(ipv4(&dns, true, 53000), 56); + let meta = DnsProcessor::packet_routing_meta_with_fragments(&first, true).unwrap(); + for processor in processors() { + assert!(matches!( + processor.process_packet_batch_with_meta(&first, 100, meta.clone()), + PacketProcessingOutcome::Invalid + )); + } + } + #[test] fn both_decoders_preserve_query_class_and_every_opcode() { for opcode in 0..16 { @@ -1287,7 +1756,11 @@ mod protocol_regression_tests { #[test] fn compression_rejects_overlapping_names_but_accepts_prior_questions() { let overlap = [4, b'a', b'b', 0xc0, 0, 0]; - assert!(DnsProcessor::read_wire_domain_name(&overlap, &mut 3).is_err()); + assert!( + DnsProcessor::read_wire_domain_name(&mut DnsNameDecoder::new(&overlap, 0), &mut 3) + .is_err() + ); + assert!(DnsNameDecoder::new(&overlap, 0).skip(&mut 3).is_err()); assert!(Name::read(&mut BinDecoder::new(&overlap).clone(3)).is_err()); let mut dns = question(0x0900, 1); @@ -1302,6 +1775,179 @@ mod protocol_regression_tests { } } + fn response_with_compression_chain(count: u16) -> (Vec, usize) { + let mut dns = question(0x8180, 1); + dns[6..8].copy_from_slice(&count.to_be_bytes()); + let mut previous_owner = DNS_HEADER_LEN; + let mut last_owner = 0; + for index in 0..count { + last_owner = dns.len(); + dns.extend_from_slice(&(0xc000 | previous_owner as u16).to_be_bytes()); + dns.extend_from_slice(&[0, 16, 0, 1, 0, 0, 0, 60, 0, 2, 1, index as u8]); + previous_owner = last_owner; + } + (dns, last_owner) + } + + #[test] + fn compression_limit_counts_cached_suffixes_in_both_decoders() { + for count in [1, 2, 32, 33, 256] { + let (dns, _) = response_with_compression_chain(count); + if count == 33 { + assert_eq!(Message::from_vec(&dns).unwrap().answers.len(), 33); + } + for limit in [1, 32, 33, 0] { + for processor in processors() { + let fast = processor.dns_wire_fast_path; + let result = processor + .with_max_dns_compression_jumps(limit) + .process_packet_batch(&ipv4(&dns, true, 53000), 100); + assert_eq!( + result.is_some(), + limit == 0 || usize::from(count) <= limit, + "depth={count}, limit={limit}, fast={fast}" + ); + } + } + } + let (dns, _) = response_with_compression_chain(33); + for processor in processors() { + assert!( + processor + .process_packet_batch(&ipv4(&dns, true, 53000), 100) + .is_none(), + "default limit must be 32, including after fast-path fallback" + ); + } + } + + #[test] + fn compression_limit_applies_to_questions_and_unlimited_avoids_recursive_decoding() { + for count in [32, 33, 2700] { + let mut dns = question(0x0900, 1); // IQUERY permits multiple questions. + dns[4..6].copy_from_slice(&(count + 1_u16).to_be_bytes()); + let mut previous = DNS_HEADER_LEN; + for _ in 0..count { + let current = dns.len(); + dns.extend_from_slice(&(0xc000 | previous as u16).to_be_bytes()); + dns.extend_from_slice(&[0, 1, 0, 1]); + previous = current; + } + for limit in [32, 0] { + for processor in processors() { + let result = processor + .with_max_dns_compression_jumps(limit) + .process_packet_batch(&ipv4(&dns, false, 53000), 0); + assert_eq!(result.is_some(), limit == 0 || count <= 32); + if let Some(records) = result { + assert_eq!(records.len(), usize::from(count) + 1); + assert!( + records + .iter() + .all(|record| record.name.as_str() == "example.com") + ); + } + } + } + } + } + + #[test] + fn compression_limit_applies_to_tsig_algorithm_and_partial_responses() { + let (mut dns, last_owner) = response_with_compression_chain(32); + dns[10..12].copy_from_slice(&1_u16.to_be_bytes()); + dns.extend_from_slice(&[0, 0, 250, 0, 255, 0, 0, 0, 0, 0, 18]); + dns.extend_from_slice(&(0xc000 | last_owner as u16).to_be_bytes()); + dns.extend_from_slice(&[0; 12]); // time, fudge, MAC length and original ID + dns.extend_from_slice(&[0, 16, 0, 0]); // TSIG error and Other Data length + for limit in [32, 33, 0] { + for processor in processors() { + let result = processor + .with_max_dns_compression_jumps(limit) + .decode_dns_questions(&dns, true, false); + assert_eq!(result.is_ok(), limit != 32); + if let Ok((header, _)) = result { + assert_eq!(header.response_code.as_u16(), 16); + } + } + } + let (mut partial, last_owner) = response_with_compression_chain(33); + partial.truncate(last_owner + 2); // complete owner, absent RR fields/data + for processor in processors() { + assert!(matches!( + processor.decode_dns_questions(&partial, true, true), + Err(DnsQuestionDecodeError::Invalid) + )); + let (header, _) = processor + .with_max_dns_compression_jumps(0) + .decode_dns_questions(&partial, true, true) + .unwrap(); + assert_eq!(header.partial_response_code.unwrap().as_u16(), 0); + } + } + + #[test] + fn cached_expanded_root_does_not_allow_compressed_opt_owner() { + let mut dns = encode_dns_header(0xbeef, 0x8180, 1); + dns[6..8].copy_from_slice(&1_u16.to_be_bytes()); + dns[10..12].copy_from_slice(&1_u16.to_be_bytes()); + dns.extend_from_slice(&[0, 0, 1, 0, 1]); // root question + dns.extend_from_slice(&[0xc0, 12, 0, 16, 0, 1, 0, 0, 0, 0, 0, 1, 0]); + dns.extend_from_slice(&[0xc0, 12, 0, 41, 4, 208, 0, 0, 0, 0, 0, 0]); + for processor in processors() { + assert!( + processor + .process_packet_batch(&ipv4(&dns, true, 53000), 0) + .is_none() + ); + } + } + + #[test] + fn overlapping_compression_is_not_treated_as_an_incomplete_fragment() { + let mut dns = question(0x8180, 1); + dns[6..8].copy_from_slice(&2_u16.to_be_bytes()); + dns.extend_from_slice(&[0xc0, 12, 0, 16, 0, 1, 0, 0, 0, 0, 0, 3]); + let target = dns.len(); + dns.extend_from_slice(&[4, b'a', b'b']); + // The preceding bytes pretend to be a label crossing this owner's + // pointer. Those bytes are present; this is overlap, not missing data. + dns.extend_from_slice(&(0xc000 | target as u16).to_be_bytes()); + for processor in processors() { + assert!(matches!( + processor.decode_dns_questions(&dns, true, true), + Err(DnsQuestionDecodeError::Invalid) + )); + } + } + + #[test] + fn compression_walkers_reject_cycles_and_truncation() { + for (case, bytes, offset) in [ + ("self pointer", &[0xc0, 0][..], 0), + ("pointer cycle", &[0xc0, 2, 0xc0, 0][..], 2), + ("truncated pointer", &[0xc0][..], 0), + ("truncated label", &[2, b'a'][..], 0), + ] { + let mut read_cursor = offset; + assert!( + DnsProcessor::read_wire_domain_name( + &mut DnsNameDecoder::new(bytes, 0), + &mut read_cursor + ) + .is_err(), + "{case}" + ); + let mut skip_cursor = offset; + assert!( + DnsNameDecoder::new(bytes, 0) + .skip(&mut skip_cursor) + .is_err(), + "{case}" + ); + } + } + #[test] fn ipv6_walks_extension_chains_and_atomic_fragments() { let hbh = (IPV6_HOP_BY_HOP, vec![0; 8]); diff --git a/src/dns_processor/pipeline.rs b/src/dns_processor/pipeline.rs index d43cb88..2883b56 100644 --- a/src/dns_processor/pipeline.rs +++ b/src/dns_processor/pipeline.rs @@ -19,6 +19,7 @@ use std::thread; use super::DnsProcessor; use super::parser::{CanonicalFlowKey, PacketProcessingOutcome, ParsedUdpDnsMeta}; +use super::reassembly::Ipv4FragmentReassembler; use super::types::{MatcherShardState, ProcessedDnsRecord, ShardProcessingResult}; use crate::config::{ExecutionBudget, MATCHER_SHARD_FACTOR, PACKET_BATCH_SIZE}; use crate::output::{OutputMessage, OutputRecordBatches}; @@ -30,6 +31,29 @@ const BATCH_PREFETCH_DEPTH: usize = 2; const MATCHER_WORKER_QUEUE_DEPTH: usize = 2; const AGGREGATOR_REORDER_BUFFER_CAPACITY: usize = BATCH_PREFETCH_DEPTH + MATCHER_WORKER_QUEUE_DEPTH + 2; +/// Ready packets may wait behind an unresolved fragment for at most this backlog. +/// Reaching either bound releases incomplete datagrams using the capacity fallback. +const FRAGMENT_READY_PACKET_LIMIT: usize = PACKET_BATCH_SIZE; +const FRAGMENT_READY_BYTE_LIMIT: usize = 64 * 1024 * 1024; + +struct WorkerShutdownSignals { + shutdown_requested: Arc, + intake_failed: Arc, + output_closed: Arc, +} + +struct FragmentProcessingConfig { + allow_fragments: bool, + full_fragments: bool, + match_timeout_micros: i64, + monotonic_capture: bool, +} + +pub(crate) struct PipelineExecutionConfig { + pub(crate) execution_budget: ExecutionBudget, + pub(crate) affinity_plan: AffinityPlan, + pub(crate) shard_parallelism_enabled: bool, +} fn staged_matcher_affinity_slot(worker_idx: usize) -> usize { worker_idx @@ -45,6 +69,7 @@ fn staged_aggregator_affinity_slot(worker_count: usize) -> usize { struct PipelineCounters { oversized_qname_message_count: usize, + fragmented_response_prefix_count: usize, dns_query_count: usize, duplicated_query_count: usize, dns_response_count: usize, @@ -59,6 +84,7 @@ impl Default for PipelineCounters { fn default() -> Self { Self { oversized_qname_message_count: 0, + fragmented_response_prefix_count: 0, dns_query_count: 0, duplicated_query_count: 0, dns_response_count: 0, @@ -76,6 +102,7 @@ pub(crate) struct ProcessingCounters { pub(crate) total_packets_processed: usize, /// DNS messages rejected because a decompressed QNAME exceeds the RFC 1035 255-octet limit. pub(crate) oversized_qname_message_count: usize, + pub(crate) fragmented_response_prefix_count: usize, pub(crate) dns_query_count: usize, pub(crate) duplicated_query_count: usize, pub(crate) dns_response_count: usize, @@ -92,6 +119,7 @@ impl PipelineCounters { output_closed: &AtomicBool, ) -> bool { self.oversized_qname_message_count += shard_result.oversized_qname_message_count; + self.fragmented_response_prefix_count += shard_result.fragmented_response_prefix_count; self.dns_query_count += shard_result.dns_query_count; self.duplicated_query_count += shard_result.duplicated_query_count; self.dns_response_count += shard_result.dns_response_count; @@ -118,6 +146,7 @@ impl PipelineCounters { ProcessingCounters { total_packets_processed, oversized_qname_message_count: self.oversized_qname_message_count, + fragmented_response_prefix_count: self.fragmented_response_prefix_count, dns_query_count: self.dns_query_count, duplicated_query_count: self.duplicated_query_count, dns_response_count: self.dns_response_count, @@ -395,6 +424,7 @@ impl ShardRoutingPlan { fn merge_shard_results(merged: &mut ShardProcessingResult, shard_result: ShardProcessingResult) { merged.oversized_qname_message_count += shard_result.oversized_qname_message_count; + merged.fragmented_response_prefix_count += shard_result.fragmented_response_prefix_count; merged.dns_query_count += shard_result.dns_query_count; merged.duplicated_query_count += shard_result.duplicated_query_count; merged.dns_response_count += shard_result.dns_response_count; @@ -519,6 +549,7 @@ fn shard_map_index(flow_key: CanonicalFlowKey, shard_count: usize) -> usize { fn route_batch_to_worker_batches( mut packet_batch: PacketBatch, routing_plan: &ShardRoutingPlan, + allow_fragments: bool, ) -> RoutedWorkerBatches { let shard_count = routing_plan.shard_count(); debug_assert!(shard_count > 0); @@ -541,8 +572,10 @@ fn route_batch_to_worker_batches( .collect(); for packet_data in packet_batch { - let Some(udp_dns_meta) = DnsProcessor::packet_routing_meta(packet_data.data.as_slice()) - else { + let Some(udp_dns_meta) = DnsProcessor::packet_routing_meta_with_fragments( + packet_data.data.as_slice(), + allow_fragments, + ) else { continue; }; @@ -558,6 +591,69 @@ fn route_batch_to_worker_batches( } } +/// Owns only packet-stage backlog; fragment and matcher state keep their existing owners. +struct FragmentRoutingState { + reassembler: Ipv4FragmentReassembler, + ready: PacketBatch, +} + +impl FragmentRoutingState { + fn new(match_timeout_micros: i64, monotonic_capture: bool) -> Self { + Self { + reassembler: Ipv4FragmentReassembler::new(match_timeout_micros, monotonic_capture), + ready: Vec::new(), + } + } + + fn route_batch( + &mut self, + packet_batch: PacketBatch, + routing_plan: &ShardRoutingPlan, + ) -> RoutedWorkerBatches { + let input_max_timestamp_micros = packet_batch + .iter() + .map(|packet| packet.timestamp_micros) + .max(); + let (packets, mut oldest_pending_timestamp_micros) = + self.reassembler.process_batch(packet_batch); + self.ready.extend(packets); + + // A final fragment may precede other parts, so its eventual datagram can + // have an earlier event than a retry already present in `ready`. Hold the + // entire batch until the unresolved fragments have been decided, then + // apply the normal timestamp/ordinal sort to all ready packets together. + // An ordinal-only prefix is unsafe when timestamps regress within a batch. + let ready_bytes: usize = self.ready.iter().map(|packet| packet.data.len()).sum(); + if self.ready.len() > FRAGMENT_READY_PACKET_LIMIT || ready_bytes > FRAGMENT_READY_BYTE_LIMIT + { + self.ready.extend(self.reassembler.finish()); + oldest_pending_timestamp_micros = None; + } + let packets = if oldest_pending_timestamp_micros.is_some() { + Vec::new() + } else { + std::mem::take(&mut self.ready) + }; + let mut routed = route_batch_to_worker_batches(packets, routing_plan, true); + routed.batch_max_timestamp_micros = input_max_timestamp_micros.map(|timestamp| { + let pending_frontier = + oldest_pending_timestamp_micros.map_or(timestamp, |pending| timestamp.min(pending)); + self.ready + .iter() + .map(|packet| packet.timestamp_micros) + .min() + .map_or(pending_frontier, |ready| pending_frontier.min(ready)) + }); + routed + } + + fn finish(&mut self) -> PacketBatch { + self.ready.extend(self.reassembler.finish()); + // Accepted packets must drain even on interrupted or failed intake. + std::mem::take(&mut self.ready) + } +} + fn parse_shard_packets( dns_processor: &DnsProcessor, shard_packets: RoutedDnsPackets, @@ -593,10 +689,13 @@ fn run_phase_processing_worker( tx: Sender, shard_count: usize, shard_parallelism_enabled: bool, - shutdown_requested: Arc, - intake_failed: Arc, - output_closed: Arc, + signals: WorkerShutdownSignals, ) -> anyhow::Result { + let WorkerShutdownSignals { + shutdown_requested, + intake_failed, + output_closed, + } = signals; let mut shard_states: Vec = if shard_parallelism_enabled { (0..shard_count) .map(|_| MatcherShardState::default()) @@ -605,17 +704,49 @@ fn run_phase_processing_worker( vec![MatcherShardState::default()] }; let routing_plan = ShardRoutingPlan::new(shard_count, shard_count); + let mut fragment_routing = dns_processor.full_fragments.then(|| { + FragmentRoutingState::new( + dns_processor.match_timeout_micros, + dns_processor.monotonic_capture, + ) + }); let mut counters = PipelineCounters::default(); - while let Ok(packet_batch) = batch_rx.recv() { + loop { + let received = batch_rx.recv(); + let final_flush = received.is_err(); if output_closed.load(AtomicOrdering::Relaxed) { break; } + let routed = match received { + Ok(packet_batch) => { + if let Some(fragment_routing) = fragment_routing.as_mut() { + fragment_routing.route_batch(packet_batch, &routing_plan) + } else { + route_batch_to_worker_batches( + packet_batch, + &routing_plan, + dns_processor.allow_fragments, + ) + } + } + Err(_) => { + let Some(fragment_routing) = fragment_routing.as_mut() else { + break; + }; + let remaining = fragment_routing.finish(); + if remaining.is_empty() { + break; + } + route_batch_to_worker_batches(remaining, &routing_plan, true) + } + }; + let RoutedWorkerBatches { batch_max_timestamp_micros, worker_batches: shard_batches, - } = route_batch_to_worker_batches(packet_batch, &routing_plan); + } = routed; let mut shard_results: Vec<(usize, ShardProcessingResult)> = shard_batches .into_par_iter() @@ -641,6 +772,9 @@ fn run_phase_processing_worker( return Ok(counters); } } + if final_flush { + break; + } } if !stop_requested(shutdown_requested.as_ref(), output_closed.as_ref()) @@ -669,18 +803,49 @@ fn run_parser_stage( worker_txs: Vec>, routing_plan: ShardRoutingPlan, output_closed: Arc, + fragment_config: FragmentProcessingConfig, ) -> anyhow::Result<()> { + let FragmentProcessingConfig { + allow_fragments, + full_fragments, + match_timeout_micros, + monotonic_capture, + } = fragment_config; let mut batch_seq = 0_u64; + let mut fragment_routing = + full_fragments.then(|| FragmentRoutingState::new(match_timeout_micros, monotonic_capture)); - while let Ok(packet_batch) = batch_rx.recv() { + loop { + let received = batch_rx.recv(); + let final_flush = received.is_err(); if output_closed.load(AtomicOrdering::Relaxed) { break; } + let routed = match received { + Ok(packet_batch) => { + if let Some(fragment_routing) = fragment_routing.as_mut() { + fragment_routing.route_batch(packet_batch, &routing_plan) + } else { + route_batch_to_worker_batches(packet_batch, &routing_plan, allow_fragments) + } + } + Err(_) => { + let Some(fragment_routing) = fragment_routing.as_mut() else { + break; + }; + let remaining = fragment_routing.finish(); + if remaining.is_empty() { + break; + } + route_batch_to_worker_batches(remaining, &routing_plan, true) + } + }; + let RoutedWorkerBatches { batch_max_timestamp_micros, worker_batches, - } = route_batch_to_worker_batches(packet_batch, &routing_plan); + } = routed; for (worker_idx, shard_packets) in worker_batches.into_iter().enumerate() { worker_txs[worker_idx] @@ -706,6 +871,9 @@ fn run_parser_stage( } batch_seq = batch_seq.wrapping_add(1); + if final_flush { + break; + } } Ok(()) @@ -717,10 +885,13 @@ fn run_matcher_worker( shard_range: Range, batch_rx: Receiver, result_tx: Sender, - shutdown_requested: Arc, - intake_failed: Arc, - output_closed: Arc, + signals: WorkerShutdownSignals, ) -> anyhow::Result<()> { + let WorkerShutdownSignals { + shutdown_requested, + intake_failed, + output_closed, + } = signals; let logical_shard_count = shard_range.end.saturating_sub(shard_range.start); let mut shard_states: Vec = (0..logical_shard_count) .map(|_| MatcherShardState::default()) @@ -865,10 +1036,13 @@ fn run_staged_processing_pipeline( shard_count: usize, worker_count: usize, affinity_plan: AffinityPlan, - shutdown_requested: Arc, - intake_failed: Arc, - output_closed: Arc, + signals: WorkerShutdownSignals, ) -> anyhow::Result { + let WorkerShutdownSignals { + shutdown_requested, + intake_failed, + output_closed, + } = signals; let routing_plan = ShardRoutingPlan::new(shard_count, worker_count); let (result_tx, result_rx) = crossbeam::channel::bounded(MATCHER_WORKER_QUEUE_DEPTH * worker_count.max(1)); @@ -901,9 +1075,11 @@ fn run_staged_processing_pipeline( shard_range, worker_rx, result_tx, - shutdown_requested, - intake_failed, - output_closed, + WorkerShutdownSignals { + shutdown_requested, + intake_failed, + output_closed, + }, ) } })?, @@ -915,13 +1091,28 @@ fn run_staged_processing_pipeline( .name("DPP_Parser".to_string()) .spawn({ let output_closed = Arc::clone(&output_closed); + let allow_fragments = dns_processor.allow_fragments; + let full_fragments = dns_processor.full_fragments; + let match_timeout_micros = dns_processor.match_timeout_micros; + let monotonic_capture = dns_processor.monotonic_capture; let affinity_plan = affinity_plan.clone(); move || { affinity_plan.apply_to_current_thread( staged_parser_affinity_slot(worker_count), "staged parser", ); - run_parser_stage(batch_rx, worker_txs, routing_plan, output_closed) + run_parser_stage( + batch_rx, + worker_txs, + routing_plan, + output_closed, + FragmentProcessingConfig { + allow_fragments, + full_fragments, + match_timeout_micros, + monotonic_capture, + }, + ) } })?; @@ -951,12 +1142,15 @@ impl DnsProcessor { packet_parser: &mut PacketParser, packet_count: &Arc, tx: &Sender, - execution_budget: ExecutionBudget, - affinity_plan: AffinityPlan, - shard_parallelism_enabled: bool, + config: PipelineExecutionConfig, shutdown_requested: Arc, output_closed: Arc, ) -> anyhow::Result { + let PipelineExecutionConfig { + execution_budget, + affinity_plan, + shard_parallelism_enabled, + } = config; let shard_count = logical_shard_count(execution_budget.available_cpus, shard_parallelism_enabled); let worker_count = @@ -966,14 +1160,14 @@ impl DnsProcessor { if execution_budget.uses_staged_pipeline() { tracing::info!( - "Execution budget: auto using {} CPUs, staged worker budget: {} shard workers, {} reserved service threads", + "Execution budget: {} CPUs, staged worker budget: {} shard workers, {} reserved service threads", execution_budget.available_cpus, worker_count, execution_budget.staged_reserved_service_threads ); } else { tracing::info!( - "Execution budget: auto using {} CPUs, phase-parallel pipeline selected for low-core host, Rayon worker budget: {}", + "Execution budget: {} CPUs, phase-parallel pipeline selected for low-core budget, Rayon worker budget: {}", execution_budget.available_cpus, execution_budget .rayon_threads @@ -998,9 +1192,11 @@ impl DnsProcessor { shard_count, worker_count, affinity_plan, - shutdown_requested, - intake_failed, - output_closed, + WorkerShutdownSignals { + shutdown_requested, + intake_failed, + output_closed, + }, ) } })? @@ -1020,9 +1216,11 @@ impl DnsProcessor { output_tx, shard_count, shard_parallelism_enabled, - shutdown_requested, - intake_failed, - output_closed, + WorkerShutdownSignals { + shutdown_requested, + intake_failed, + output_closed, + }, ) } })? @@ -1035,7 +1233,7 @@ impl DnsProcessor { break; }; - if stop_requested(shutdown_requested.as_ref(), output_closed.as_ref()) { + if output_closed.load(AtomicOrdering::Relaxed) { break; } @@ -1098,7 +1296,7 @@ impl DnsProcessor { mod tests { use super::*; use crate::config::{InputSource, OUTPUT_RECORD_BATCH_SIZE}; - use crate::packet_parser::PacketParser; + use crate::packet_parser::{PacketParser, PacketPayload}; use crate::test_support::{ classic_pcap_bytes, encode_dns_header, make_udp_dns_packet, make_udp_dns_packet_with_payload, temp_test_path, test_dns_record, @@ -1107,6 +1305,241 @@ mod tests { use std::sync::Mutex; use std::sync::atomic::{AtomicBool, AtomicUsize}; + fn test_worker_signals(shutdown_requested: bool) -> WorkerShutdownSignals { + WorkerShutdownSignals { + shutdown_requested: Arc::new(AtomicBool::new(shutdown_requested)), + intake_failed: Arc::new(AtomicBool::new(false)), + output_closed: Arc::new(AtomicBool::new(false)), + } + } + + fn fragment_ordering_query_response() -> (Vec, Vec) { + let mut payload = encode_dns_header(0x3456, 0x0100, 1); + payload.extend_from_slice(b"\x07example\x03com\0\0\x01\0\x01"); + let query = + make_udp_dns_packet_with_payload([10, 0, 0, 1], [8, 8, 8, 8], 53_000, 53, &payload); + payload[2..4].copy_from_slice(&0x8180_u16.to_be_bytes()); + let response = + make_udp_dns_packet_with_payload([8, 8, 8, 8], [10, 0, 0, 1], 53, 53_000, &payload); + (query, response) + } + + fn split_ipv4_test_packet(packet: &[u8]) -> (Vec, Vec) { + let first_end = 14 + 20 + 32; + let mut first = packet[..first_end].to_vec(); + first[16..18].copy_from_slice(&52_u16.to_be_bytes()); + first[18..20].copy_from_slice(&0x4321_u16.to_be_bytes()); + first[20..22].copy_from_slice(&0x2000_u16.to_be_bytes()); + let mut tail = packet[..34].to_vec(); + tail[16..18].copy_from_slice(&(20_u16 + (packet.len() - first_end) as u16).to_be_bytes()); + tail[18..20].copy_from_slice(&0x4321_u16.to_be_bytes()); + tail[20..22].copy_from_slice(&4_u16.to_be_bytes()); + tail.extend_from_slice(&packet[first_end..]); + (first, tail) + } + + fn test_packet(bytes: &[u8], timestamp_micros: i64, packet_ordinal: u64) -> PacketData { + PacketData { + data: PacketPayload::owned(bytes.into()), + timestamp_micros, + packet_ordinal, + } + } + + fn run_fragment_test_batches( + batches: Vec, + staged: bool, + monotonic: bool, + signals: WorkerShutdownSignals, + ) -> (PipelineCounters, Vec) { + let processor = Arc::new( + DnsProcessor::new_with_runtime_options(None, false, 1_200_000, monotonic) + .expect("processor initializes") + .with_full_fragments(true), + ); + let (batch_tx, batch_rx) = crossbeam::channel::unbounded(); + for batch in batches { + batch_tx.send(batch).expect("test batch sends"); + } + drop(batch_tx); + let (output_tx, output_rx) = crossbeam::channel::unbounded(); + let counters = if staged { + run_staged_processing_pipeline( + processor, + batch_rx, + output_tx, + 4, + 2, + AffinityPlan::disabled(), + signals, + ) + } else { + run_phase_processing_worker(processor, batch_rx, output_tx, 1, false, signals) + } + .expect("fragment pipeline completes"); + let records = output_rx + .into_iter() + .flat_map(|message| match message { + OutputMessage::Records(records) => records, + other => panic!("unexpected output message: {other:?}"), + }) + .collect(); + (counters, records) + } + + #[test] + fn full_fragments_preserve_delayed_query_order_in_both_pipeline_models() { + let (query, response) = fragment_ordering_query_response(); + let (first, tail) = split_ipv4_test_packet(&query); + for staged in [false, true] { + for monotonic in [false, true] { + for with_response in [false, true] { + let mut initial = vec![ + test_packet(&tail, 1_000_000, 0), + test_packet(&query, 1_100_000, 1), + ]; + if with_response { + initial.push(test_packet(&response, 1_120_000, 2)); + } + let (counters, records) = run_fragment_test_batches( + vec![initial, vec![test_packet(&first, 1_200_000, 3)]], + staged, + monotonic, + test_worker_signals(false), + ); + assert_eq!(counters.dns_query_count, 2); + assert_eq!(counters.duplicated_query_count, 1); + assert_eq!( + counters.matched_query_response_count, + usize::from(with_response) + ); + assert_eq!(counters.timeout_query_count, usize::from(!with_response)); + assert_eq!(records.len(), 1); + assert_eq!(records[0].request_timestamp, 1_000_000); + assert_eq!( + records[0].response_timestamp, + with_response.then_some(1_120_000) + ); + assert_eq!( + counters.matched_rtt_sum_micros, + if with_response { 120_000 } else { 0 } + ); + } + } + } + } + + #[test] + fn full_fragments_preserve_default_intra_batch_timestamp_order() { + let (query, response) = fragment_ordering_query_response(); + let (first, tail) = split_ipv4_test_packet(&response); + for staged in [false, true] { + let (counters, records) = run_fragment_test_batches( + vec![ + vec![ + test_packet(&query, 1_000_000, 0), + test_packet(&query, 1_300_000, 1), + test_packet(&tail, 1_200_000, 2), + ], + vec![test_packet(&first, 1_400_000, 3)], + ], + staged, + false, + test_worker_signals(false), + ); + assert_eq!(counters.duplicated_query_count, 0); + assert_eq!(counters.matched_query_response_count, 1); + assert_eq!(counters.timeout_query_count, 1); + assert_eq!(records.len(), 2); + assert_eq!(records[0].request_timestamp, 1_000_000); + assert_eq!(records[0].response_timestamp, Some(1_200_000)); + assert_eq!(records[1].request_timestamp, 1_300_000); + assert_eq!(records[1].response_timestamp, None); + } + } + + #[test] + fn full_fragments_hold_watermark_for_buffered_response() { + let (query, response) = fragment_ordering_query_response(); + let (_, unrelated_tail) = split_ipv4_test_packet(&query); + for staged in [false, true] { + let (counters, records) = run_fragment_test_batches( + vec![ + vec![test_packet(&query, 0, 0)], + vec![ + test_packet(&response, 1_100_000, 1), + test_packet(&unrelated_tail, 1_500_000, 2), + ], + ], + staged, + true, + test_worker_signals(false), + ); + assert_eq!(counters.matched_query_response_count, 1); + assert_eq!(counters.timeout_query_count, 0); + assert_eq!(records.len(), 1); + assert_eq!(records[0].response_timestamp, Some(1_100_000)); + } + } + + #[test] + fn full_fragments_release_backlog_at_both_capacity_bounds() { + let (query, _) = fragment_ordering_query_response(); + let (_, tail) = split_ipv4_test_packet(&query); + for byte_bound in [false, true] { + let mut routing = FragmentRoutingState::new(1_200_000, false); + let plan = ShardRoutingPlan::new(1, 1); + let blocked = routing.route_batch( + vec![test_packet(&tail, 0, 0), test_packet(&query, 1, 1)], + &plan, + ); + assert!(blocked.worker_batches[0][0].packets.is_empty()); + let ordinary = if byte_bound { + vec![test_packet(&vec![0; FRAGMENT_READY_BYTE_LIMIT + 1], 2, 2)] + } else { + (0..=FRAGMENT_READY_PACKET_LIMIT) + .map(|ordinal| test_packet(&[], 2, ordinal as u64 + 2)) + .collect() + }; + let routed = routing.route_batch(ordinary, &plan); + assert!(routing.ready.is_empty()); + assert!(routing.reassembler.finish().is_empty()); + assert_eq!(routed.worker_batches[0][0].packets.len(), 1); + assert_eq!(routed.worker_batches[0][0].packets[0].packet_ordinal, 1); + } + } + + #[test] + fn full_fragments_drain_accepted_backlog_after_signal_or_intake_error() { + let (query, response) = fragment_ordering_query_response(); + let (_, tail) = split_ipv4_test_packet(&query); + for staged in [false, true] { + for signal in [false, true] { + let signals = test_worker_signals(signal); + signals.intake_failed.store(!signal, AtomicOrdering::SeqCst); + let mut unmatched = query.clone(); + unmatched[42..44].copy_from_slice(&0x9876_u16.to_be_bytes()); + let (counters, records) = run_fragment_test_batches( + vec![vec![ + test_packet(&tail, 1_000_000, 0), + test_packet(&query, 1_100_000, 1), + test_packet(&response, 1_200_000, 2), + test_packet(&unmatched, 1_300_000, 3), + ]], + staged, + true, + signals, + ); + assert_eq!(counters.dns_query_count, 2); + assert_eq!(counters.matched_query_response_count, 1); + assert_eq!(counters.timeout_query_count, 0); + assert_eq!(records.len(), 1); + assert_eq!(records[0].request_timestamp, 1_100_000); + assert_eq!(records[0].response_timestamp, Some(1_200_000)); + } + } + } + fn shard_result(token: usize) -> ShardProcessingResult { ShardProcessingResult { dns_query_count: token, @@ -1279,9 +1712,11 @@ mod tests { &mut parser, &packet_count, &output_tx, - ExecutionBudget::from_available_cpus(5), - affinity_plan, - true, + PipelineExecutionConfig { + execution_budget: ExecutionBudget::from_available_cpus(5), + affinity_plan, + shard_parallelism_enabled: true, + }, Arc::new(AtomicBool::new(false)), Arc::new(AtomicBool::new(false)), ) @@ -1421,9 +1856,11 @@ mod tests { &mut parser, &packet_count, &output_tx, - ExecutionBudget::from_available_cpus(available_cpus), - AffinityPlan::disabled(), - true, + PipelineExecutionConfig { + execution_budget: ExecutionBudget::from_available_cpus(available_cpus), + affinity_plan: AffinityPlan::disabled(), + shard_parallelism_enabled: true, + }, Arc::new(AtomicBool::new(false)), Arc::new(AtomicBool::new(false)), ); @@ -1467,9 +1904,11 @@ mod tests { &mut parser, &packet_count, &output_tx, - ExecutionBudget::from_available_cpus(available_cpus), - AffinityPlan::disabled(), - true, + PipelineExecutionConfig { + execution_budget: ExecutionBudget::from_available_cpus(available_cpus), + affinity_plan: AffinityPlan::disabled(), + shard_parallelism_enabled: true, + }, Arc::new(AtomicBool::new(false)), Arc::new(AtomicBool::new(false)), ) @@ -1577,9 +2016,11 @@ mod tests { &mut parser, &Arc::new(AtomicUsize::new(0)), &output_tx, - ExecutionBudget::from_available_cpus(available_cpus), - AffinityPlan::disabled(), - true, + PipelineExecutionConfig { + execution_budget: ExecutionBudget::from_available_cpus(available_cpus), + affinity_plan: AffinityPlan::disabled(), + shard_parallelism_enabled: true, + }, Arc::new(AtomicBool::new(false)), Arc::new(AtomicBool::new(false)), ) @@ -1608,6 +2049,138 @@ mod tests { fs::remove_file(path).expect("test pcap removed"); } + #[test] + fn full_ipv4_reassembly_crosses_packet_batch_boundary_in_both_pipeline_models() { + let path = temp_test_path("pipeline-cross-batch-ipv4-fragments", "pcap"); + let mut query_payload = encode_dns_header(0x3456, 0x0100, 1); + query_payload.extend_from_slice(&[ + 7, b'e', b'x', b'a', b'm', b'p', b'l', b'e', 3, b'c', b'o', b'm', 0, + ]); + query_payload.extend_from_slice(&1_u16.to_be_bytes()); + query_payload.extend_from_slice(&1_u16.to_be_bytes()); + let mut response_payload = query_payload.clone(); + response_payload[2..4].copy_from_slice(&0x8180_u16.to_be_bytes()); + response_payload[10..12].copy_from_slice(&1_u16.to_be_bytes()); + // The OPT record changes the full RCODE to EDNS_BADVERS and is split + // between the two IP fragments. + response_payload.extend_from_slice(&[0, 0, 41, 4, 208, 1, 0, 0, 0, 0, 0]); + + let query = make_udp_dns_packet_with_payload( + [10, 0, 0, 1], + [8, 8, 8, 8], + 53_000, + 53, + &query_payload, + ); + let response = make_udp_dns_packet_with_payload( + [8, 8, 8, 8], + [10, 0, 0, 1], + 53, + 53_000, + &response_payload, + ); + let padding = make_udp_dns_packet([10, 0, 0, 1], [8, 8, 8, 8], 53_000, 123); + let first_end = 14 + 20 + 40; + let mut first_fragment = response[..first_end].to_vec(); + first_fragment[16..18].copy_from_slice(&60_u16.to_be_bytes()); + first_fragment[18..20].copy_from_slice(&0x4321_u16.to_be_bytes()); + first_fragment[20..22].copy_from_slice(&0x2000_u16.to_be_bytes()); + let mut final_fragment = response[..34].to_vec(); + final_fragment[16..18] + .copy_from_slice(&(20_u16 + (response.len() - first_end) as u16).to_be_bytes()); + final_fragment[18..20].copy_from_slice(&0x4321_u16.to_be_bytes()); + final_fragment[20..22].copy_from_slice(&5_u16.to_be_bytes()); + final_fragment.extend_from_slice(&response[first_end..]); + + let mut packets = Vec::with_capacity(PACKET_BATCH_SIZE + 1); + packets.push((1, 0, query.as_slice())); + packets.push((1, 100_000, first_fragment.as_slice())); + packets.extend(std::iter::repeat_n( + (1, 150_000, padding.as_slice()), + PACKET_BATCH_SIZE - packets.len(), + )); + packets.push((1, 200_000, final_fragment.as_slice())); + fs::write(&path, classic_pcap_bytes(&packets)).expect("test pcap written"); + + // The unresolved response holds this entire batch. Its earlier buffered + // query must also cap the matcher watermark until both are released. + let mut cap_parser = + PacketParser::new(&InputSource::File(path.clone()), false).expect("parser opens"); + let first_batch = cap_parser + .next_batch(PACKET_BATCH_SIZE) + .expect("batch reads") + .expect("first batch exists"); + let mut fragment_routing = FragmentRoutingState::new(1_200_000, true); + let routed = fragment_routing.route_batch(first_batch, &ShardRoutingPlan::new(1, 1)); + assert_eq!(routed.batch_max_timestamp_micros, Some(1_000_000)); + + for available_cpus in [1, 5] { + let mut parser = + PacketParser::new(&InputSource::File(path.clone()), false).expect("parser opens"); + let packet_count = Arc::new(AtomicUsize::new(0)); + let (output_tx, output_rx) = crossbeam::channel::unbounded(); + let counters = DnsProcessor::dns_processing_loop( + Arc::new( + DnsProcessor::new_with_runtime_options(None, true, 1_200_000, true) + .expect("processor initializes") + .with_full_fragments(true), + ), + &mut parser, + &packet_count, + &output_tx, + PipelineExecutionConfig { + execution_budget: ExecutionBudget::from_available_cpus(available_cpus), + affinity_plan: AffinityPlan::disabled(), + shard_parallelism_enabled: true, + }, + Arc::new(AtomicBool::new(false)), + Arc::new(AtomicBool::new(false)), + ) + .expect("pipeline completes"); + + assert_eq!( + counters.total_packets_processed, + PACKET_BATCH_SIZE + 1, + "available_cpus={available_cpus}" + ); + assert_eq!( + counters.dns_query_count, 1, + "available_cpus={available_cpus}" + ); + assert_eq!( + counters.dns_response_count, 1, + "available_cpus={available_cpus}" + ); + assert_eq!( + counters.matched_query_response_count, 1, + "available_cpus={available_cpus}" + ); + assert_eq!( + counters.timeout_query_count, 0, + "available_cpus={available_cpus}" + ); + assert_eq!(parser.non_monotonic_timestamp_count(), 0); + + drop(output_tx); + let records = output_rx + .into_iter() + .flat_map(|message| match message { + OutputMessage::Records(records) => records, + other => panic!("unexpected output message: {other:?}"), + }) + .collect::>(); + assert_eq!(records.len(), 1, "available_cpus={available_cpus}"); + assert_eq!(records[0].request_timestamp, 1_000_000); + assert_eq!(records[0].response_timestamp, Some(1_200_000)); + assert_eq!( + records[0].response_code.map(|code| code.to_string()), + Some("EDNS_BADVERS".to_string()) + ); + } + + fs::remove_file(path).expect("test pcap removed"); + } + #[test] fn routed_worker_batches_use_global_batch_max_timestamp() { let path = temp_test_path("pipeline-routed-batch-watermark", "pcap"); @@ -1630,7 +2203,7 @@ mod tests { let RoutedWorkerBatches { batch_max_timestamp_micros, worker_batches, - } = route_batch_to_worker_batches(batch, &ShardRoutingPlan::new(4, 1)); + } = route_batch_to_worker_batches(batch, &ShardRoutingPlan::new(4, 1), false); let ordered_packets = worker_batches[0] .iter() @@ -1676,9 +2249,7 @@ mod tests { output_tx, 1, false, - Arc::new(AtomicBool::new(false)), - Arc::new(AtomicBool::new(false)), - Arc::new(AtomicBool::new(false)), + test_worker_signals(false), ) .expect("phase worker completes"); @@ -1714,9 +2285,7 @@ mod tests { output_tx, 1, false, - Arc::new(AtomicBool::new(false)), - Arc::new(AtomicBool::new(false)), - Arc::new(AtomicBool::new(false)), + test_worker_signals(false), ) .expect("phase worker completes"); @@ -1743,9 +2312,7 @@ mod tests { output_tx, 1, false, - Arc::new(AtomicBool::new(true)), - Arc::new(AtomicBool::new(false)), - Arc::new(AtomicBool::new(false)), + test_worker_signals(true), ) .expect("phase worker completes"); @@ -1764,6 +2331,7 @@ mod tests { } = route_batch_to_worker_batches( oversized_qname_batch("matcher-worker-oversized-qname"), &ShardRoutingPlan::new(1, 1), + false, ); let shard_packets = worker_batches.pop().expect("worker batch exists"); let (batch_tx, batch_rx) = crossbeam::channel::bounded(1); @@ -1784,9 +2352,7 @@ mod tests { worker_range, batch_rx, result_tx, - Arc::new(AtomicBool::new(false)), - Arc::new(AtomicBool::new(false)), - Arc::new(AtomicBool::new(false)), + test_worker_signals(false), ) .expect("matcher worker completes"); @@ -1813,6 +2379,7 @@ mod tests { } = route_batch_to_worker_batches( unresolved_query_batch("matcher-worker-signal-shutdown"), &ShardRoutingPlan::new(1, 1), + false, ); let shard_packets = worker_batches.pop().expect("worker batch exists"); let (batch_tx, batch_rx) = crossbeam::channel::bounded(1); @@ -1833,9 +2400,7 @@ mod tests { worker_range, batch_rx, result_tx, - Arc::new(AtomicBool::new(true)), - Arc::new(AtomicBool::new(false)), - Arc::new(AtomicBool::new(false)), + test_worker_signals(true), ) .expect("matcher worker completes"); diff --git a/src/dns_processor/reassembly.rs b/src/dns_processor/reassembly.rs new file mode 100644 index 0000000..02dd07d --- /dev/null +++ b/src/dns_processor/reassembly.rs @@ -0,0 +1,1392 @@ +/* + * Nameto Oy © 2026. All rights reserved. + * + * This software is licensed under the GNU General Public License (GPL) version 3. + * Commercial licensing options: . + */ + +//! Bounded IPv4/UDP fragment assembly before DNS flow routing. +//! +//! Later fragments have no UDP ports, so this state has one owner in the routing stage. +//! A completed datagram replaces its first fragment with one ordinary IPv4 packet. An +//! incomplete first fragment is released only when it expires, is evicted, or input ends; +//! the existing `--allow-fragments` parser then decides whether its DNS prefix is usable. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; + +use super::types::VlanContext; +use crate::packet_parser::{PacketBatch, PacketData, PacketPayload}; + +#[cfg(test)] +const ETHERNET_HEADER_LEN: usize = 14; +const IPV4_MIN_HEADER_LEN: usize = 20; +const UDP_HEADER_LEN: usize = 8; +const ETHERTYPE_IPV4: u16 = 0x0800; +const IP_PROTOCOL_UDP: u8 = 17; +const DNS_PORT: u16 = 53; +const MORE_FRAGMENTS: u16 = 0x2000; +const DONT_FRAGMENT: u16 = 0x4000; +const RESERVED_FLAG: u16 = 0x8000; +const FRAGMENT_OFFSET_MASK: u16 = 0x1fff; + +/// Bounds are independent of capture timestamps, which may regress in normal mode. +const MAX_PENDING_DATAGRAMS: usize = 8_192; +const MAX_PENDING_BYTES: usize = 64 * 1024 * 1024; +const ENTRY_OVERHEAD_ESTIMATE: usize = 256; +const SEGMENT_OVERHEAD_ESTIMATE: usize = 64; +const MAX_RECENT_COMPLETIONS: usize = 4_096; +const MAX_RECENT_BYTES: usize = 16 * 1024 * 1024; +const RECENT_ENTRY_OVERHEAD_ESTIMATE: usize = 128; +const MAX_NON_DNS_KEYS: usize = 4_096; +const MAX_NON_DNS_BYTES: usize = 1024 * 1024; +const MIN_RECENT_RETENTION_MICROS: i64 = 5_000_000; + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct FragmentKey { + source: [u8; 4], + destination: [u8; 4], + identification: u16, + vlan_context: VlanContext, +} + +struct FragmentPart { + key: FragmentKey, + offset: usize, + payload: Box<[u8]>, + more_fragments: bool, + first_udp_length: Option, + first_header_len: Option, + first_l3_offset: Option, +} + +struct FragmentSegment { + payload: Box<[u8]>, + more_fragments: bool, +} + +struct CompletedDatagram { + payload: Arc<[u8]>, + observed_timestamp: i64, + arrival_order: u64, +} + +impl CompletedDatagram { + fn memory_bytes(&self) -> usize { + RECENT_ENTRY_OVERHEAD_ESTIMATE + self.payload.len() + } +} + +struct NonDnsKey { + observed_timestamp: i64, + arrival_order: u64, +} + +enum FragmentInput { + Ordinary, + Skip, + NonDnsFirst(FragmentKey), + Part(FragmentPart), +} + +/// Byte intervals must not overlap, except for an exact duplicate fragment. +struct PendingDatagram { + arrival_order: u64, + first_seen_timestamp: i64, + first: Option, + first_header_len: Option, + first_l3_offset: Option, + udp_length: Option, + final_payload_length: Option, + final_fragment_event: Option<(i64, u64)>, + segments: BTreeMap, + /// Present while every observed fragment matches a recently completed datagram. + recent_payload: Option>, + rejected: bool, +} + +impl PendingDatagram { + fn new(arrival_order: u64, timestamp: i64, recent_payload: Option>) -> Self { + Self { + arrival_order, + first_seen_timestamp: timestamp, + first: None, + first_header_len: None, + first_l3_offset: None, + udp_length: None, + final_payload_length: None, + final_fragment_event: None, + segments: BTreeMap::new(), + recent_payload, + rejected: false, + } + } + + fn memory_bytes(&self) -> usize { + ENTRY_OVERHEAD_ESTIMATE + + self.first.as_ref().map_or(0, |first| first.data.len()) + + self + .segments + .values() + .map(|segment| segment.payload.len() + SEGMENT_OVERHEAD_ESTIMATE) + .sum::() + + self + .recent_payload + .as_ref() + .map_or(0, |payload| payload.len()) + } + + fn reject(&mut self) { + self.first = None; + self.first_header_len = None; + self.first_l3_offset = None; + self.udp_length = None; + self.final_payload_length = None; + self.final_fragment_event = None; + self.segments.clear(); + self.recent_payload = None; + self.rejected = true; + } + + fn into_fallback(self) -> Option { + // A prefix whose observed bytes still match a completed datagram is a + // possible capture duplicate, not a second inferred DNS response. + if self.recent_payload.is_some() { + None + } else { + self.first + } + } + + fn insert(&mut self, part: FragmentPart, packet: PacketData) -> Result { + if self.rejected { + return Err(()); + } + if self + .recent_payload + .as_ref() + .is_some_and(|payload| !fragment_matches_payload(&part, payload)) + { + self.recent_payload = None; + } + let event = (packet.timestamp_micros, packet.packet_ordinal); + let end = part.offset.checked_add(part.payload.len()).ok_or(())?; + if self + .final_payload_length + .is_some_and(|final_end| end > final_end) + { + return Err(()); + } + if part.more_fragments + && (self.udp_length.is_some_and(|udp_length| end >= udp_length) + || self + .final_payload_length + .is_some_and(|final_end| end >= final_end)) + { + return Err(()); + } + if let Some(udp_length) = self.udp_length + && end > udp_length + { + return Err(()); + } + if part.offset == 0 { + if self.first.is_some() { + // A repeated first fragment may be a capture duplicate. A different + // first fragment with the same IPv4 ID is ambiguous, so reject it. + let existing = self.segments.get(&0).ok_or(())?; + return if existing.payload.as_ref() == part.payload.as_ref() + && existing.more_fragments == part.more_fragments + { + Ok(self.is_complete()) + } else { + Err(()) + }; + } + let udp_length = part.first_udp_length.ok_or(())?; + let header_len = part.first_header_len.ok_or(())?; + let l3_offset = part.first_l3_offset.ok_or(())?; + if end > udp_length + || self + .final_payload_length + .is_some_and(|last| last != udp_length) + || self + .segments + .last_key_value() + .is_some_and(|(offset, segment)| { + // Disjoint intervals have increasing ends, so the last retained + // tail is enough to validate the newly known UDP boundary. + let segment_end = offset + segment.payload.len(); + segment_end > udp_length + || (segment.more_fragments && segment_end == udp_length) + }) + { + return Err(()); + } + self.udp_length = Some(udp_length); + self.first_header_len = Some(header_len); + self.first_l3_offset = Some(l3_offset); + self.first = Some(packet); + } + + if let Some((&start, existing)) = self.segments.range(..=part.offset).next_back() { + if start == part.offset { + if existing.payload.as_ref() != part.payload.as_ref() + || existing.more_fragments != part.more_fragments + { + return Err(()); + } + if !part.more_fragments { + self.set_final_length(end, event)?; + } + return Ok(self.is_complete()); + } + if start + existing.payload.len() > part.offset { + return Err(()); + } + } + if let Some((&next_start, _)) = self.segments.range(part.offset..).next() + && end > next_start + { + return Err(()); + } + if let Some(udp_length) = self.udp_length + && end > udp_length + { + return Err(()); + } + if !part.more_fragments { + self.set_final_length(end, event)?; + } + self.segments.insert( + part.offset, + FragmentSegment { + payload: part.payload, + more_fragments: part.more_fragments, + }, + ); + Ok(self.is_complete()) + } + + fn set_final_length(&mut self, end: usize, event: (i64, u64)) -> Result<(), ()> { + if self + .final_payload_length + .is_some_and(|previous| previous != end) + || self.udp_length.is_some_and(|udp_length| udp_length != end) + || self + .segments + .iter() + .any(|(offset, segment)| offset + segment.payload.len() > end) + { + return Err(()); + } + self.final_payload_length = Some(end); + self.final_fragment_event = Some( + self.final_fragment_event + .map_or(event, |old| old.min(event)), + ); + Ok(()) + } + + fn is_complete(&self) -> bool { + let Some(expected) = self.udp_length else { + return false; + }; + if self.first.is_none() || self.final_payload_length != Some(expected) { + return false; + } + let mut cursor = 0; + for (offset, segment) in &self.segments { + if *offset != cursor { + return false; + } + cursor += segment.payload.len(); + } + cursor == expected + } + + fn into_packet(self) -> Option { + let first = self.first?; + let header_len = self.first_header_len?; + let l3_offset = self.first_l3_offset?; + let udp_length = self.udp_length?; + let (timestamp_micros, packet_ordinal) = self.final_fragment_event?; + let total_length = header_len.checked_add(udp_length)?; + let total_length = u16::try_from(total_length).ok()?; + let header_end = l3_offset.checked_add(header_len)?; + let first_header = first.data.as_slice().get(..header_end)?; + + let mut bytes = Vec::with_capacity(l3_offset + usize::from(total_length)); + bytes.extend_from_slice(first_header); + bytes[l3_offset + 2..l3_offset + 4].copy_from_slice(&total_length.to_be_bytes()); + // It is now a complete synthetic IPv4 datagram. Recompute the header checksum. + bytes[l3_offset + 6..l3_offset + 8].copy_from_slice(&0_u16.to_be_bytes()); + bytes[l3_offset + 10..l3_offset + 12].copy_from_slice(&0_u16.to_be_bytes()); + let checksum = ipv4_header_checksum(&bytes[l3_offset..header_end]); + bytes[l3_offset + 10..l3_offset + 12].copy_from_slice(&checksum.to_be_bytes()); + for (_, segment) in self.segments { + bytes.extend_from_slice(&segment.payload); + } + Some(PacketData { + data: PacketPayload::owned(bytes.into_boxed_slice()), + timestamp_micros, + packet_ordinal, + }) + } +} + +fn fragment_matches_payload(part: &FragmentPart, payload: &[u8]) -> bool { + let Some(end) = part.offset.checked_add(part.payload.len()) else { + return false; + }; + end <= payload.len() + && part.more_fragments == (end < payload.len()) + && part.payload.as_ref() == &payload[part.offset..end] +} + +fn ipv4_header_checksum(header: &[u8]) -> u16 { + let mut sum = 0_u32; + for word in header.as_chunks::<2>().0 { + sum += u32::from(u16::from_be_bytes(*word)); + } + while sum >> 16 != 0 { + sum = (sum & 0xffff) + (sum >> 16); + } + !(sum as u16) +} + +/// One instance must survive every batch of a capture, including across worker handoffs. +pub(super) struct Ipv4FragmentReassembler { + pending: HashMap, + arrival_order: BTreeMap, + pending_bytes: usize, + next_arrival_order: u64, + recently_completed: HashMap, + completed_order: BTreeMap, + completed_bytes: usize, + next_completed_order: u64, + known_non_dns: HashMap, + non_dns_order: BTreeMap, + non_dns_bytes: usize, + next_non_dns_order: u64, + max_seen_timestamp: Option, + timeout_micros: i64, + monotonic_capture: bool, +} + +impl Ipv4FragmentReassembler { + pub(super) fn new(match_timeout_micros: i64, monotonic_capture: bool) -> Self { + Self { + pending: HashMap::new(), + arrival_order: BTreeMap::new(), + pending_bytes: 0, + next_arrival_order: 0, + recently_completed: HashMap::new(), + completed_order: BTreeMap::new(), + completed_bytes: 0, + next_completed_order: 0, + known_non_dns: HashMap::new(), + non_dns_order: BTreeMap::new(), + non_dns_bytes: 0, + next_non_dns_order: 0, + max_seen_timestamp: None, + timeout_micros: match_timeout_micros.max(1), + monotonic_capture, + } + } + + /// Returns decodable packets and the oldest unresolved fragment timestamp. + /// The caller must cap monotonic matcher eviction at that timestamp until resolution. + pub(super) fn process_batch(&mut self, input: PacketBatch) -> (PacketBatch, Option) { + let mut output = Vec::with_capacity(input.len()); + for packet in input { + self.max_seen_timestamp = Some( + self.max_seen_timestamp + .map_or(packet.timestamp_micros, |current| { + current.max(packet.timestamp_micros) + }), + ); + match classify_fragment(&packet) { + FragmentInput::Ordinary => output.push(packet), + FragmentInput::Skip => {} + FragmentInput::NonDnsFirst(key) => { + // Later fragments have no ports. Drop any tails retained before + // this first fragment established that the datagram is not DNS. + self.take_entry(&key); + self.remember_non_dns(key, packet.timestamp_micros); + } + FragmentInput::Part(part) => { + self.expire_completed_key_at(&part.key, packet.timestamp_micros); + self.expire_non_dns_key_at(&part.key, packet.timestamp_micros); + self.expire_key_at(&part.key, packet.timestamp_micros, &mut output); + if self.known_non_dns.contains_key(&part.key) { + if part.offset != 0 { + // The known non-DNS first fragment owns this IPv4 ID. + continue; + } + // A new DNS first fragment reuses the IPv4 ID; its later + // fragments may now be assembled again. + self.take_non_dns(&part.key); + } + self.accept_part(part, packet, &mut output); + // One input batch may contain many large fragments. Enforce the + // memory bound per fragment, not only after the whole batch. + self.enforce_limits(&mut output); + } + } + } + self.expire_old(&mut output); + self.expire_recent_completions(); + self.expire_non_dns_keys(); + self.enforce_limits(&mut output); + let oldest_pending_timestamp = self + .pending + .values() + .filter(|entry| !entry.rejected) + .map(|entry| entry.first_seen_timestamp) + .min(); + (output, oldest_pending_timestamp) + } + + /// At EOF, incomplete datagrams can only contribute an observable first prefix. + pub(super) fn finish(&mut self) -> PacketBatch { + let mut output = Vec::new(); + while let Some((order, key)) = self + .arrival_order + .first_key_value() + .map(|(order, key)| (*order, key.clone())) + { + debug_assert_eq!( + self.pending.get(&key).map(|entry| entry.arrival_order), + Some(order) + ); + if let Some(entry) = self.take_entry(&key) + && let Some(first) = entry.into_fallback() + { + output.push(first); + } + } + output + } + + fn accept_part(&mut self, part: FragmentPart, packet: PacketData, output: &mut PacketBatch) { + let key = part.key.clone(); + let observed_timestamp = packet.timestamp_micros; + let mut existing = self.take_entry(&key); + if existing.as_ref().is_some_and(|entry| { + entry.recent_payload.is_some() + && entry.first.is_some() + && part.offset == 0 + && !fragment_matches_payload( + &part, + entry.recent_payload.as_ref().expect("checked above"), + ) + }) { + // A different first fragment starts a new generation of this IPv4 ID. + // The old candidate matched a completed datagram and needs no fallback. + existing = None; + } + let recent_payload = self + .recently_completed + .get(&key) + .map(|entry| Arc::clone(&entry.payload)); + let mut entry = existing.unwrap_or_else(|| { + let arrival_order = self.next_arrival_order; + self.next_arrival_order = self.next_arrival_order.wrapping_add(1); + PendingDatagram::new(arrival_order, observed_timestamp, recent_payload) + }); + match entry.insert(part, packet) { + Ok(true) => { + let previous_payload = entry.recent_payload.clone(); + let header_len = entry + .first_header_len + .expect("complete datagram has first header"); + let l3_offset = entry + .first_l3_offset + .expect("complete datagram has first Ethernet header"); + if let Some(packet) = entry.into_packet() { + let payload = &packet.data[l3_offset + header_len..]; + if !previous_payload + .as_ref() + .is_some_and(|previous| previous.as_ref() == payload) + { + self.remember_completion( + key.clone(), + Arc::<[u8]>::from(payload), + observed_timestamp, + ); + output.push(packet); + } + } + } + Ok(false) => self.insert_entry(key, entry), + Err(()) => { + // Keep a bounded tombstone so later fragments with the same IPv4 ID + // cannot turn a rejected datagram into a prefix match. + entry.reject(); + self.insert_entry(key, entry); + } + } + } + + fn insert_entry(&mut self, key: FragmentKey, entry: PendingDatagram) { + self.pending_bytes += entry.memory_bytes() + key.vlan_context.memory_bytes(); + self.arrival_order.insert(entry.arrival_order, key.clone()); + self.pending.insert(key, entry); + } + + fn take_entry(&mut self, key: &FragmentKey) -> Option { + let entry = self.pending.remove(key)?; + self.pending_bytes -= entry.memory_bytes() + key.vlan_context.memory_bytes(); + self.arrival_order.remove(&entry.arrival_order); + Some(entry) + } + + fn remember_completion( + &mut self, + key: FragmentKey, + payload: Arc<[u8]>, + observed_timestamp: i64, + ) { + self.take_completion(&key); + let arrival_order = self.next_completed_order; + self.next_completed_order = self.next_completed_order.wrapping_add(1); + let completed = CompletedDatagram { + payload, + observed_timestamp, + arrival_order, + }; + self.completed_bytes += completed.memory_bytes() + key.vlan_context.memory_bytes(); + self.completed_order.insert(arrival_order, key.clone()); + self.recently_completed.insert(key, completed); + while self.recently_completed.len() > MAX_RECENT_COMPLETIONS + || self.completed_bytes > MAX_RECENT_BYTES + { + let Some((_, oldest_key)) = self.completed_order.first_key_value() else { + break; + }; + let oldest_key = oldest_key.clone(); + self.take_completion(&oldest_key); + } + } + + fn take_completion(&mut self, key: &FragmentKey) -> Option { + let completed = self.recently_completed.remove(key)?; + self.completed_bytes -= completed.memory_bytes() + key.vlan_context.memory_bytes(); + self.completed_order.remove(&completed.arrival_order); + Some(completed) + } + + fn remember_non_dns(&mut self, key: FragmentKey, observed_timestamp: i64) { + self.take_non_dns(&key); + let arrival_order = self.next_non_dns_order; + self.next_non_dns_order = self.next_non_dns_order.wrapping_add(1); + self.non_dns_bytes += RECENT_ENTRY_OVERHEAD_ESTIMATE + key.vlan_context.memory_bytes(); + self.non_dns_order.insert(arrival_order, key.clone()); + self.known_non_dns.insert( + key, + NonDnsKey { + observed_timestamp, + arrival_order, + }, + ); + while self.known_non_dns.len() > MAX_NON_DNS_KEYS || self.non_dns_bytes > MAX_NON_DNS_BYTES + { + let Some((_, oldest_key)) = self.non_dns_order.first_key_value() else { + break; + }; + let oldest_key = oldest_key.clone(); + self.take_non_dns(&oldest_key); + } + } + + fn take_non_dns(&mut self, key: &FragmentKey) -> Option { + let known = self.known_non_dns.remove(key)?; + self.non_dns_bytes -= RECENT_ENTRY_OVERHEAD_ESTIMATE + key.vlan_context.memory_bytes(); + self.non_dns_order.remove(&known.arrival_order); + Some(known) + } + + fn recent_retention_micros(&self) -> i64 { + self.timeout_micros.max(MIN_RECENT_RETENTION_MICROS) + } + + fn expire_completed_key_at(&mut self, key: &FragmentKey, timestamp: i64) { + if !self.monotonic_capture { + // Capture timestamps may regress arbitrarily in normal mode. The + // entry and byte caps bound this history instead. + return; + } + let expired = self.recently_completed.get(key).is_some_and(|entry| { + timestamp.saturating_sub(entry.observed_timestamp) > self.recent_retention_micros() + }); + if expired { + self.take_completion(key); + } + } + + fn expire_non_dns_key_at(&mut self, key: &FragmentKey, timestamp: i64) { + if !self.monotonic_capture { + return; + } + let expired = self.known_non_dns.get(key).is_some_and(|entry| { + timestamp.saturating_sub(entry.observed_timestamp) > self.recent_retention_micros() + }); + if expired { + self.take_non_dns(key); + } + } + + fn expire_recent_completions(&mut self) { + if !self.monotonic_capture { + return; + } + let Some(frontier) = self.max_seen_timestamp else { + return; + }; + while let Some((_, key)) = self.completed_order.first_key_value() { + let key = key.clone(); + let completed = self + .recently_completed + .get(&key) + .expect("completion order references a cached datagram"); + if frontier.saturating_sub(completed.observed_timestamp) + <= self.recent_retention_micros() + { + break; + } + self.take_completion(&key); + } + } + + fn expire_non_dns_keys(&mut self) { + if !self.monotonic_capture { + return; + } + let Some(frontier) = self.max_seen_timestamp else { + return; + }; + while let Some((_, key)) = self.non_dns_order.first_key_value() { + let key = key.clone(); + let known = self + .known_non_dns + .get(&key) + .expect("non-DNS order references a cached key"); + if frontier.saturating_sub(known.observed_timestamp) <= self.recent_retention_micros() { + break; + } + self.take_non_dns(&key); + } + } + + fn expire_old(&mut self, output: &mut PacketBatch) { + if !self.monotonic_capture { + // A timestamp regression can otherwise expire adjacent capture fragments. + // Entry limits and EOF still bound incomplete state in this mode. + return; + } + let Some(frontier) = self.max_seen_timestamp else { + return; + }; + let mut stale: Vec<(u64, FragmentKey)> = self + .pending + .iter() + .filter_map(|(key, entry)| { + (frontier.saturating_sub(entry.first_seen_timestamp) > self.timeout_micros) + .then_some((entry.arrival_order, key.clone())) + }) + .collect(); + stale.sort_unstable_by_key(|(arrival_order, _)| *arrival_order); + // Match the arrival order used by EOF and capacity eviction. + for (_, key) in stale { + if let Some(entry) = self.take_entry(&key) + && let Some(first) = entry.into_fallback() + { + output.push(first); + } + } + } + + fn expire_key_at(&mut self, key: &FragmentKey, timestamp: i64, output: &mut PacketBatch) { + if !self.monotonic_capture { + return; + } + let expired = self.pending.get(key).is_some_and(|entry| { + timestamp.saturating_sub(entry.first_seen_timestamp) > self.timeout_micros + }); + if expired + && let Some(entry) = self.take_entry(key) + && let Some(first) = entry.into_fallback() + { + output.push(first); + } + } + + fn enforce_limits(&mut self, output: &mut PacketBatch) { + while self.pending.len() > MAX_PENDING_DATAGRAMS || self.pending_bytes > MAX_PENDING_BYTES { + let Some((_, key)) = self.arrival_order.first_key_value() else { + break; + }; + let key = key.clone(); + if let Some(entry) = self.take_entry(&key) + && let Some(first) = entry.into_fallback() + { + output.push(first); + } + } + } +} + +fn classify_fragment(packet: &PacketData) -> FragmentInput { + let bytes = packet.data.as_slice(); + let Ok((ethertype, l3_offset, vlan_context)) = + super::parser::ethernet_ethertype_and_payload_offset(bytes) + else { + return FragmentInput::Ordinary; + }; + if ethertype != ETHERTYPE_IPV4 || bytes.len() < l3_offset + IPV4_MIN_HEADER_LEN { + return FragmentInput::Ordinary; + } + let ip = &bytes[l3_offset..]; + if ip[0] >> 4 != 4 { + return FragmentInput::Ordinary; + } + let flags = u16::from_be_bytes([ip[6], ip[7]]); + let offset = usize::from(flags & FRAGMENT_OFFSET_MASK) * 8; + let more_fragments = flags & MORE_FRAGMENTS != 0; + if !more_fragments && offset == 0 { + return FragmentInput::Ordinary; + } + if ip[9] != IP_PROTOCOL_UDP { + return FragmentInput::Skip; + } + let header_len = usize::from(ip[0] & 0x0f) * 4; + let total_length = usize::from(u16::from_be_bytes([ip[2], ip[3]])); + if header_len < IPV4_MIN_HEADER_LEN + || header_len > ip.len() + || total_length < header_len + || total_length > ip.len() + || flags & RESERVED_FLAG != 0 + || flags & DONT_FRAGMENT != 0 + { + return FragmentInput::Skip; + } + let payload = &ip[header_len..total_length]; + let Some(end) = offset.checked_add(payload.len()) else { + return FragmentInput::Skip; + }; + if payload.is_empty() + || end > usize::from(u16::MAX) - IPV4_MIN_HEADER_LEN + || (more_fragments && !payload.len().is_multiple_of(8)) + { + return FragmentInput::Skip; + } + let key = FragmentKey { + source: ip[12..16].try_into().expect("IPv4 minimum header checked"), + destination: ip[16..20].try_into().expect("IPv4 minimum header checked"), + identification: u16::from_be_bytes([ip[4], ip[5]]), + vlan_context, + }; + let first_udp_length = if offset == 0 { + if payload.len() < UDP_HEADER_LEN { + return FragmentInput::Skip; + } + let source_port = u16::from_be_bytes([payload[0], payload[1]]); + let destination_port = u16::from_be_bytes([payload[2], payload[3]]); + if source_port != DNS_PORT && destination_port != DNS_PORT { + return FragmentInput::NonDnsFirst(key); + } + let udp_length = usize::from(u16::from_be_bytes([payload[4], payload[5]])); + if udp_length < UDP_HEADER_LEN + || udp_length <= payload.len() + || udp_length > usize::from(u16::MAX) - header_len + { + return FragmentInput::Skip; + } + Some(udp_length) + } else { + None + }; + FragmentInput::Part(FragmentPart { + key, + offset, + payload: payload.into(), + more_fragments, + first_udp_length, + first_header_len: (offset == 0).then_some(header_len), + first_l3_offset: (offset == 0).then_some(l3_offset), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn packet(offset: usize, more: bool, ordinal: u64, timestamp: i64) -> PacketData { + let dns: [u8; 32] = [ + 0x12, 0x34, 0x80, 0x00, 0, 1, 0, 0, 0, 0, 0, 0, 1, b'a', 4, b't', b'e', b's', b't', 0, + 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, + ]; + let mut udp = Vec::from([0_u8; UDP_HEADER_LEN]); + udp[0..2].copy_from_slice(&DNS_PORT.to_be_bytes()); + udp[2..4].copy_from_slice(&53000_u16.to_be_bytes()); + udp.extend_from_slice(&dns); + let udp_length = udp.len() as u16; + udp[4..6].copy_from_slice(&udp_length.to_be_bytes()); + let fragment_len = if more { 24 } else { udp.len() - offset }; + let payload = &udp[offset..offset + fragment_len]; + let mut frame = vec![0_u8; ETHERNET_HEADER_LEN + IPV4_MIN_HEADER_LEN]; + frame[12..14].copy_from_slice(ÐERTYPE_IPV4.to_be_bytes()); + frame[14] = 0x45; + frame[16..18] + .copy_from_slice(&((IPV4_MIN_HEADER_LEN + payload.len()) as u16).to_be_bytes()); + frame[18..20].copy_from_slice(&0x1234_u16.to_be_bytes()); + let flags = ((offset / 8) as u16) | if more { MORE_FRAGMENTS } else { 0 }; + frame[20..22].copy_from_slice(&flags.to_be_bytes()); + frame[23] = IP_PROTOCOL_UDP; + frame[26..30].copy_from_slice(&[192, 0, 2, 53]); + frame[30..34].copy_from_slice(&[192, 0, 2, 1]); + frame.extend_from_slice(payload); + PacketData { + data: PacketPayload::owned(frame.into_boxed_slice()), + timestamp_micros: timestamp, + packet_ordinal: ordinal, + } + } + + fn non_dns_first(ordinal: u64, timestamp: i64) -> PacketData { + let mut packet = packet(0, true, ordinal, timestamp); + let mut bytes = packet.data.as_slice().to_vec(); + let udp_offset = ETHERNET_HEADER_LEN + IPV4_MIN_HEADER_LEN; + bytes[udp_offset..udp_offset + 4].copy_from_slice(&[0x04, 0xd2, 0x10, 0xe1]); + packet.data = PacketPayload::owned(bytes.into_boxed_slice()); + packet + } + + fn tagged_packet(mut packet: PacketData, tags: &[(u16, u16)]) -> PacketData { + let bytes = packet.data.as_slice(); + let mut tagged = Vec::with_capacity(bytes.len() + tags.len() * 4); + tagged.extend_from_slice(&bytes[..12]); + for &(ethertype, vlan_id) in tags { + tagged.extend_from_slice(ðertype.to_be_bytes()); + tagged.extend_from_slice(&vlan_id.to_be_bytes()); + } + tagged.extend_from_slice(&bytes[12..]); + packet.data = PacketPayload::owned(tagged.into_boxed_slice()); + packet + } + + #[test] + fn non_dns_first_discards_tails_before_and_after_it() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, true); + let (output, pending) = reassembler.process_batch(vec![packet(24, false, 1, 100)]); + assert!(output.is_empty()); + assert_eq!(pending, Some(100)); + + let (output, pending) = reassembler.process_batch(vec![non_dns_first(2, 101)]); + assert!(output.is_empty()); + assert_eq!(pending, None); + assert_eq!(reassembler.pending_bytes, 0); + + let (output, pending) = reassembler.process_batch(vec![packet(24, false, 3, 102)]); + assert!(output.is_empty()); + assert_eq!(pending, None); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn dns_first_with_reused_id_assembles_after_non_dns_first() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, true); + let (output, pending) = reassembler.process_batch(vec![ + non_dns_first(1, 100), + packet(24, false, 2, 101), + packet(0, true, 3, 102), + packet(24, false, 4, 103), + ]); + assert_eq!(output.len(), 1); + assert_eq!(output[0].timestamp_micros, 103); + assert_eq!(pending, None); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn non_dns_history_is_bounded_without_eviction_of_completed_dns() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, true); + let (complete, _) = + reassembler.process_batch(vec![packet(0, true, 1, 100), packet(24, false, 2, 101)]); + assert_eq!(complete.len(), 1); + let completed_key = reassembler + .recently_completed + .keys() + .next() + .expect("DNS completion is remembered") + .clone(); + + for id in 0..=MAX_NON_DNS_KEYS { + let mut first = non_dns_first(id as u64 + 3, id as i64 + 102); + let mut bytes = first.data.as_slice().to_vec(); + bytes[ETHERNET_HEADER_LEN + 4..ETHERNET_HEADER_LEN + 6] + .copy_from_slice(&(id as u16).to_be_bytes()); + first.data = PacketPayload::owned(bytes.into_boxed_slice()); + reassembler.process_batch(vec![first]); + } + assert!(reassembler.known_non_dns.len() <= MAX_NON_DNS_KEYS); + assert!(reassembler.non_dns_bytes <= MAX_NON_DNS_BYTES); + assert!(reassembler.recently_completed.contains_key(&completed_key)); + + let (duplicate, _) = reassembler.process_batch(vec![packet(0, true, 5_000, 5_000)]); + assert!(duplicate.is_empty()); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn tagged_fragments_assemble_without_cross_vlan_coalescence() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, true); + let vlan_one = &[(0x8100, 100)]; + let vlan_two = &[(0x88a8, 200), (0x8100, 100)]; + let (output, pending) = reassembler.process_batch(vec![ + tagged_packet(packet(0, true, 1, 100), vlan_one), + tagged_packet(packet(24, false, 2, 101), vlan_two), + ]); + assert!(output.is_empty()); + assert_eq!(pending, Some(100)); + + let (output, pending) = reassembler.process_batch(vec![ + tagged_packet(packet(0, true, 3, 102), vlan_two), + tagged_packet(packet(24, false, 4, 103), vlan_one), + ]); + assert_eq!(output.len(), 2); + assert_eq!(pending, None); + for (packet, (tags, l3_offset)) in output.iter().zip([ + (vlan_two.as_slice(), ETHERNET_HEADER_LEN + 8), + (vlan_one.as_slice(), ETHERNET_HEADER_LEN + 4), + ]) { + let bytes = packet.data.as_slice(); + assert_eq!(bytes.len(), l3_offset + IPV4_MIN_HEADER_LEN + 40); + assert_eq!(ipv4_header_checksum(&bytes[l3_offset..l3_offset + 20]), 0); + assert_eq!(&bytes[l3_offset + 6..l3_offset + 8], &[0, 0]); + let expected = tagged_packet(self::packet(0, true, 0, 0), tags); + assert_eq!(&bytes[..l3_offset], &expected.data[..l3_offset]); + } + } + + #[test] + fn tagged_fragments_ignore_priority_and_drop_eligibility_bits() { + let mut assembler = Ipv4FragmentReassembler::new(1_200_000, false); + let first = tagged_packet(packet(0, true, 1, 100), &[(0x88a8, 10), (0x8100, 100)]); + let last = tagged_packet( + packet(24, false, 2, 101), + &[(0x88a8, 0xb00a), (0x8100, 0xf064)], + ); + let (output, pending) = assembler.process_batch(vec![first, last]); + assert_eq!(pending, None); + assert_eq!( + output.len(), + 1, + "QoS changes do not change the fragment key" + ); + let meta = super::super::DnsProcessor::packet_routing_meta(output[0].data.as_slice()) + .expect("reassembled tagged datagram routes"); + assert!(!meta.partial_first_ipv4_fragment); + } + + #[test] + fn assembles_across_batches_once_at_final_fragment_time() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, true); + let (first, pending) = reassembler.process_batch(vec![packet(0, true, 4, 100)]); + assert!(first.is_empty()); + assert_eq!(pending, Some(100)); + let (complete, pending) = reassembler.process_batch(vec![packet(24, false, 5, 101)]); + assert_eq!(pending, None); + assert_eq!(complete.len(), 1); + let frame = &complete[0]; + assert_eq!((frame.timestamp_micros, frame.packet_ordinal), (101, 5)); + assert_eq!(&frame.data[20..22], &[0, 0]); + assert_eq!(ipv4_header_checksum(&frame.data[14..34]), 0); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn completed_datagram_does_not_fallback_duplicate_first_fragment() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, true); + let (complete, pending) = + reassembler.process_batch(vec![packet(0, true, 1, 100), packet(24, false, 2, 101)]); + assert_eq!(complete.len(), 1); + assert_eq!(pending, None); + + let (duplicate, pending) = reassembler.process_batch(vec![packet(0, true, 3, 102)]); + assert!(duplicate.is_empty()); + assert_eq!(pending, Some(102)); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn completed_datagram_is_not_emitted_again_from_duplicate_fragments() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, true); + let (output, _) = reassembler.process_batch(vec![ + packet(0, true, 1, 100), + packet(24, false, 2, 101), + packet(24, false, 3, 102), + packet(0, true, 4, 103), + packet(0, true, 5, 104), + packet(24, false, 6, 105), + ]); + assert_eq!(output.len(), 1); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn reused_ipv4_id_with_different_payload_still_assembles() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, true); + let (original, _) = + reassembler.process_batch(vec![packet(0, true, 1, 100), packet(24, false, 2, 101)]); + assert_eq!(original.len(), 1); + + let mut changed_last = packet(24, false, 4, 103); + let mut bytes = changed_last.data.as_slice().to_vec(); + *bytes.last_mut().expect("fragment has payload") ^= 1; + changed_last.data = PacketPayload::owned(bytes.into_boxed_slice()); + let (different_last, _) = + reassembler.process_batch(vec![packet(0, true, 3, 102), changed_last]); + assert_eq!(different_last.len(), 1); + assert_ne!( + different_last[0].data.as_slice(), + original[0].data.as_slice() + ); + + let mut changed_first = packet(0, true, 5, 104); + let mut bytes = changed_first.data.as_slice().to_vec(); + bytes[ETHERNET_HEADER_LEN + IPV4_MIN_HEADER_LEN + UDP_HEADER_LEN] ^= 1; + changed_first.data = PacketPayload::owned(bytes.into_boxed_slice()); + let (different_first, _) = + reassembler.process_batch(vec![changed_first, packet(24, false, 6, 105)]); + assert_eq!(different_first.len(), 1); + assert_ne!( + different_first[0].data.as_slice(), + different_last[0].data.as_slice() + ); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn reused_ipv4_id_with_new_first_assembles_after_identical_orphan_last() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, true); + let (original, _) = + reassembler.process_batch(vec![packet(0, true, 1, 100), packet(24, false, 2, 101)]); + assert_eq!(original.len(), 1); + + let mut changed_first = packet(0, true, 4, 103); + let mut bytes = changed_first.data.as_slice().to_vec(); + bytes[ETHERNET_HEADER_LEN + IPV4_MIN_HEADER_LEN + UDP_HEADER_LEN] ^= 1; + changed_first.data = PacketPayload::owned(bytes.into_boxed_slice()); + let (new_datagram, pending) = + reassembler.process_batch(vec![packet(24, false, 3, 102), changed_first]); + assert_eq!(new_datagram.len(), 1); + assert_eq!(new_datagram[0].timestamp_micros, 102); + assert_eq!(pending, None); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn timestamp_regression_does_not_forget_recent_completion() { + let mut reassembler = Ipv4FragmentReassembler::new(100, false); + let (complete, _) = + reassembler.process_batch(vec![packet(0, true, 1, 100), packet(24, false, 2, 101)]); + assert_eq!(complete.len(), 1); + let (duplicate, _) = reassembler.process_batch(vec![packet(0, true, 3, -10_000_000)]); + assert!(duplicate.is_empty()); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn duplicate_first_fragment_does_not_fallback_on_timeout() { + let mut reassembler = Ipv4FragmentReassembler::new(100, true); + let (complete, _) = + reassembler.process_batch(vec![packet(0, true, 1, 100), packet(24, false, 2, 101)]); + assert_eq!(complete.len(), 1); + reassembler.process_batch(vec![packet(0, true, 3, 102)]); + + let ordinary = PacketData { + data: PacketPayload::owned(vec![0_u8; ETHERNET_HEADER_LEN].into_boxed_slice()), + timestamp_micros: 203, + packet_ordinal: 4, + }; + let (output, pending) = reassembler.process_batch(vec![ordinary]); + assert_eq!(output.len(), 1); + assert_eq!(pending, None); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn recent_completion_window_expires_and_is_bounded() { + let mut reassembler = Ipv4FragmentReassembler::new(100, true); + let (first, _) = + reassembler.process_batch(vec![packet(0, true, 1, 100), packet(24, false, 2, 101)]); + assert_eq!(first.len(), 1); + + let (later, _) = reassembler.process_batch(vec![ + packet(0, true, 3, 5_000_102), + packet(24, false, 4, 5_000_103), + ]); + assert_eq!(later.len(), 1); + + for id in 0..=MAX_RECENT_COMPLETIONS { + reassembler.remember_completion( + FragmentKey { + source: [192, 0, 2, 1], + destination: [192, 0, 2, 53], + identification: id as u16, + vlan_context: VlanContext::default(), + }, + Arc::<[u8]>::from(&b"payload"[..]), + 5_000_103, + ); + } + assert!(reassembler.recently_completed.len() <= MAX_RECENT_COMPLETIONS); + assert!(reassembler.completed_bytes <= MAX_RECENT_BYTES); + } + + #[test] + fn orphan_later_fragment_can_precede_first() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, true); + let (orphan, pending) = reassembler.process_batch(vec![packet(24, false, 4, 100)]); + assert!(orphan.is_empty()); + assert_eq!(pending, Some(100)); + let (complete, pending) = reassembler.process_batch(vec![packet(0, true, 5, 101)]); + assert_eq!(complete.len(), 1); + assert_eq!(pending, None); + assert_eq!( + (complete[0].timestamp_micros, complete[0].packet_ordinal), + (100, 4) + ); + } + + #[test] + fn incomplete_first_is_released_once_at_finish() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, true); + let (output, pending) = reassembler.process_batch(vec![packet(0, true, 4, 100)]); + assert!(output.is_empty()); + assert_eq!(pending, Some(100)); + let fallback = reassembler.finish(); + assert_eq!(fallback.len(), 1); + assert_eq!(fallback[0].packet_ordinal, 4); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn overlapping_segments_discard_datagram_without_prefix_fallback() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, true); + reassembler.process_batch(vec![packet(0, true, 4, 100)]); + let (output, pending) = reassembler.process_batch(vec![packet(16, false, 5, 101)]); + assert!(output.is_empty()); + assert_eq!(pending, None); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn incomplete_first_expires_to_one_prefix() { + let mut reassembler = Ipv4FragmentReassembler::new(100, true); + reassembler.process_batch(vec![packet(0, true, 4, 100)]); + let ordinary = PacketData { + data: PacketPayload::owned(vec![0_u8; 14].into_boxed_slice()), + timestamp_micros: 201, + packet_ordinal: 6, + }; + let (output, pending) = reassembler.process_batch(vec![ordinary]); + assert_eq!(output.len(), 2); + assert_eq!(pending, None); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn expired_first_fragments_follow_arrival_order() { + let mut reassembler = Ipv4FragmentReassembler::new(100, true); + let first_fragments = (1_u16..=8) + .map(|id| { + let mut first = packet(0, true, u64::from(id), 100 + i64::from(id)); + let mut bytes = first.data.as_slice().to_vec(); + bytes[ETHERNET_HEADER_LEN + 4..ETHERNET_HEADER_LEN + 6] + .copy_from_slice(&id.to_be_bytes()); + first.data = PacketPayload::owned(bytes.into_boxed_slice()); + first + }) + .collect(); + let (output, pending) = reassembler.process_batch(first_fragments); + assert!(output.is_empty()); + assert_eq!(pending, Some(101)); + + let ordinary = PacketData { + data: PacketPayload::owned(vec![0_u8; ETHERNET_HEADER_LEN].into_boxed_slice()), + timestamp_micros: 300, + packet_ordinal: 9, + }; + let (output, pending) = reassembler.process_batch(vec![ordinary]); + assert_eq!(pending, None); + assert_eq!( + output + .iter() + .map(|packet| packet.packet_ordinal) + .collect::>(), + vec![9, 1, 2, 3, 4, 5, 6, 7, 8] + ); + } + + #[test] + fn fragments_too_far_apart_in_same_batch_do_not_assemble() { + let mut reassembler = Ipv4FragmentReassembler::new(100, true); + let (output, pending) = + reassembler.process_batch(vec![packet(0, true, 4, 100), packet(24, false, 5, 201)]); + assert_eq!(output.len(), 1); + assert_eq!( + (output[0].timestamp_micros, output[0].packet_ordinal), + (100, 4) + ); + assert_eq!(pending, Some(201)); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn fragment_expiration_requires_monotonic_capture() { + for monotonic_capture in [false, true] { + for final_first in [false, true] { + let mut reassembler = Ipv4FragmentReassembler::new(1_200_000, monotonic_capture); + let (first_ordinal, final_ordinal) = if final_first { (2, 1) } else { (1, 2) }; + let first_timestamp = 1_100_000 + (first_ordinal as i64 - 1) * 1_300_000; + let final_timestamp = 1_100_000 + (final_ordinal as i64 - 1) * 1_300_000; + let first = packet(0, true, first_ordinal, first_timestamp); + let final_fragment = packet(24, false, final_ordinal, final_timestamp); + let input = if final_first { + vec![final_fragment, first] + } else { + vec![first, final_fragment] + }; + + let (mut output, pending) = reassembler.process_batch(input); + assert_eq!( + pending, + monotonic_capture.then_some(2_400_000), + "monotonic_capture={monotonic_capture}, final_first={final_first}" + ); + output.extend(reassembler.finish()); + assert_eq!(output.len(), 1); + if monotonic_capture { + // Expiration retains only the first-fragment fallback in either order. + assert_eq!(output[0].timestamp_micros, first_timestamp); + assert_eq!(&output[0].data[20..22], &MORE_FRAGMENTS.to_be_bytes()); + } else { + assert_eq!(output[0].timestamp_micros, final_timestamp); + assert_eq!(output[0].packet_ordinal, final_ordinal); + assert_eq!(&output[0].data[20..22], &[0, 0]); + assert_eq!( + output[0].data.len(), + ETHERNET_HEADER_LEN + IPV4_MIN_HEADER_LEN + 40 + ); + } + } + } + } + + #[test] + fn timestamp_regression_does_not_expire_adjacent_fragments() { + let mut reassembler = Ipv4FragmentReassembler::new(100, false); + let unrelated = PacketData { + data: PacketPayload::owned(vec![0_u8; 14].into_boxed_slice()), + timestamp_micros: 10_000, + packet_ordinal: 1, + }; + let (output, pending) = reassembler.process_batch(vec![ + unrelated, + packet(0, true, 2, 100), + packet(24, false, 3, 101), + ]); + assert_eq!(output.len(), 2); + assert_eq!(output[1].timestamp_micros, 101); + assert_eq!(pending, None); + } + + #[test] + fn orphan_final_fragment_holds_monotonic_watermark_until_first_arrives() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200, true); + let (_, pending) = reassembler.process_batch(vec![packet(24, false, 1, 500)]); + assert_eq!(pending, Some(500)); + let unrelated = PacketData { + data: PacketPayload::owned(vec![0_u8; 14].into_boxed_slice()), + timestamp_micros: 1_300, + packet_ordinal: 2, + }; + let (_, pending) = reassembler.process_batch(vec![unrelated]); + assert_eq!(pending, Some(500)); + let (assembled, pending) = reassembler.process_batch(vec![packet(0, true, 3, 1_400)]); + assert_eq!(assembled.len(), 1); + assert_eq!(assembled[0].timestamp_micros, 500); + assert_eq!(pending, None); + } + + #[test] + fn conflicting_fragment_flags_leave_a_tombstone_without_prefix_fallback() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200, true); + let mut nonfinal = packet(24, false, 2, 101); + let mut bytes = nonfinal.data.as_slice().to_vec(); + bytes[20..22].copy_from_slice(&(MORE_FRAGMENTS | 3).to_be_bytes()); + nonfinal.data = PacketPayload::owned(bytes.into_boxed_slice()); + let (output, _) = reassembler.process_batch(vec![ + nonfinal, + packet(24, false, 3, 102), + packet(0, true, 4, 103), + ]); + assert!(output.is_empty()); + assert!(reassembler.finish().is_empty()); + } + + #[test] + fn nonfinal_payload_at_or_beyond_udp_length_is_rejected_in_both_orders() { + // The fixture declares UDP Length=40; these eight-byte tails end at 40 and 48. + for offset in [32_u16, 40] { + for tail_first in [false, true] { + let mut reassembler = Ipv4FragmentReassembler::new(1_200, true); + let (first_ordinal, tail_ordinal) = if tail_first { (2, 1) } else { (1, 2) }; + let mut tail = packet(24, false, tail_ordinal, 99 + tail_ordinal as i64); + let mut bytes = tail.data.as_slice().to_vec(); + bytes.truncate(ETHERNET_HEADER_LEN + IPV4_MIN_HEADER_LEN + 8); + bytes[16..18].copy_from_slice(&(IPV4_MIN_HEADER_LEN as u16 + 8).to_be_bytes()); + bytes[20..22].copy_from_slice(&(MORE_FRAGMENTS | (offset / 8)).to_be_bytes()); + tail.data = PacketPayload::owned(bytes.into_boxed_slice()); + let first = packet(0, true, first_ordinal, 99 + first_ordinal as i64); + let input = if tail_first { + vec![tail, first] + } else { + vec![first, tail] + }; + + let (output, pending) = reassembler.process_batch(input); + assert!( + output.is_empty(), + "offset={offset}, tail_first={tail_first}" + ); + assert_eq!(pending, None, "offset={offset}, tail_first={tail_first}"); + assert!( + reassembler.finish().is_empty(), + "invalid tails must not produce prefix fallback: offset={offset}, tail_first={tail_first}" + ); + } + } + } + + #[test] + fn payload_beyond_final_fragment_is_rejected() { + let mut reassembler = Ipv4FragmentReassembler::new(1_200, true); + let mut extra = packet(24, false, 2, 101); + let mut bytes = extra.data.as_slice().to_vec(); + bytes.truncate(ETHERNET_HEADER_LEN + IPV4_MIN_HEADER_LEN + 8); + bytes[16..18].copy_from_slice(&(IPV4_MIN_HEADER_LEN as u16 + 8).to_be_bytes()); + bytes[20..22].copy_from_slice(&(MORE_FRAGMENTS | 5).to_be_bytes()); + extra.data = PacketPayload::owned(bytes.into_boxed_slice()); + let (output, _) = reassembler.process_batch(vec![ + packet(24, false, 1, 100), + extra, + packet(0, true, 3, 102), + ]); + assert!(output.is_empty()); + assert!(reassembler.finish().is_empty()); + } +} diff --git a/src/dns_processor/tests.rs b/src/dns_processor/tests.rs index 867161b..b78fdd9 100644 --- a/src/dns_processor/tests.rs +++ b/src/dns_processor/tests.rs @@ -13,7 +13,7 @@ use std::net::{IpAddr, Ipv4Addr}; use super::DnsProcessor; use super::parser::PacketProcessingOutcome; -use super::types::{MatcherShardState, ProcessedDnsRecord}; +use super::types::{MatcherShardState, ProcessedDnsRecord, VlanContext}; use crate::custom_types::DnsNameBuf; use crate::test_support::{ encode_dns_header, make_udp_dns_packet, make_udp_dns_packet_with_payload, temp_test_path, @@ -436,6 +436,7 @@ fn insert_query( query.resolver_ip, query.query_class, query.opcode, + VlanContext::default(), ); let key = super::types::TimelineKey::new( query.timestamp_micros, @@ -459,13 +460,14 @@ fn insert_response( response.resolver_ip, response.query_class, response.opcode, + VlanContext::default(), ); let key = super::types::TimelineKey::new( response.timestamp_micros, response.packet_ordinal, response.record_ordinal, ); - processor.insert_response_entry(state, identity, key, response.response_code); + processor.insert_response_entry(state, identity, key, Some(response.response_code)); } fn make_query_record(packet_ordinal: u64, record_ordinal: u32) -> ProcessedDnsRecord { @@ -492,6 +494,9 @@ fn make_query_record_with_timestamp( query_class: 1, opcode: 0, response_code: HickoryResponseCode::ServFail.into(), + vlan_context: VlanContext::default(), + partial_first_ipv4_fragment: false, + partial_response_code: None, } } @@ -515,6 +520,9 @@ fn make_response_record_with_timestamp( query_class: 1, opcode: 0, response_code: HickoryResponseCode::NoError.into(), + vlan_context: VlanContext::default(), + partial_first_ipv4_fragment: false, + partial_response_code: None, } } @@ -556,7 +564,7 @@ fn packet_routing_meta_preserves_standard_packet_processing_output() { .expect("standard parser succeeds"); let routing_meta = DnsProcessor::packet_routing_meta(&packet).expect("routing metadata exists"); let meta_records = - match processor.process_packet_batch_with_meta(&packet, 1_234_567, routing_meta) { + match processor.process_packet_batch_with_meta(&packet, 1_234_567, routing_meta.clone()) { PacketProcessingOutcome::Records(records) => records, outcome => panic!("metadata-assisted parser failed: {outcome:?}"), }; @@ -606,7 +614,7 @@ fn packet_processing_appends_records_with_final_ordinals() { &packet, 1_234_567, 42, - routing_meta, + routing_meta.clone(), &mut records, ); @@ -661,7 +669,7 @@ fn packet_processing_failure_does_not_append_partial_records() { &packet, 1_234_567, 42, - routing_meta, + routing_meta.clone(), &mut records, ); @@ -696,7 +704,7 @@ fn packet_processing_zero_questions_leaves_destination_unchanged() { &packet, 1_234_567, 42, - routing_meta, + routing_meta.clone(), &mut records, ); @@ -730,7 +738,7 @@ fn packet_processing_response_appends_only_first_question() { &packet, 1_234_567, 42, - routing_meta, + routing_meta.clone(), &mut records, ); @@ -771,7 +779,7 @@ fn parser_bounds_dns_decode_to_udp_length() { ] { assert!( matches!( - processor.process_packet_batch_with_meta(&packet, 1_234_567, routing_meta,), + processor.process_packet_batch_with_meta(&packet, 1_234_567, routing_meta.clone(),), PacketProcessingOutcome::Invalid ), "fast_path={fast_path}" @@ -1239,16 +1247,16 @@ fn resolver_flows_in_the_same_shard_match_independently() { } #[test] -fn anonymization_collision_does_not_merge_client_identities() { +fn anonymization_preserves_distinct_client_identities() { let processor = test_processor_with_anonymization_key("secret\n"); let query_client = IpAddr::V4(Ipv4Addr::new(0, 0, 56, 87)); let other_client = IpAddr::V4(Ipv4Addr::new(0, 0, 201, 251)); let pseudonymized_client = processor.anonymize_ip(&query_client); - assert_eq!( + assert_ne!( pseudonymized_client, processor.anonymize_ip(&other_client), - "fixture must retain its known IPv4 pseudonym collision" + "distinct IPv4 clients must have distinct pseudonyms" ); let mut query = make_query_record_with_timestamp(1_000, 1, 0); diff --git a/src/dns_processor/types.rs b/src/dns_processor/types.rs index 7d3c490..848015d 100644 --- a/src/dns_processor/types.rs +++ b/src/dns_processor/types.rs @@ -10,6 +10,7 @@ use hickory_proto::rr::RecordType as HickoryRecordType; use std::collections::BTreeMap; use std::mem; use std::net::IpAddr; +use std::sync::Arc; #[cfg(test)] use std::mem::MaybeUninit; @@ -19,6 +20,23 @@ use crate::output::OutputRecordBatches; const INLINE_TIMELINE_CAPACITY: usize = 1; +/// Ordered Ethernet tag stack, with each tag encoded as TPID and its 12-bit VLAN ID. +/// Untagged traffic needs no allocation; packet metadata and matcher keys share tagged stacks. +#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub(super) struct VlanContext(Option>); + +impl VlanContext { + pub(super) fn from_tags(tags: Vec) -> Self { + Self((!tags.is_empty()).then(|| Arc::from(tags))) + } + + pub(super) fn memory_bytes(&self) -> usize { + self.0 + .as_ref() + .map_or(0, |tags| std::mem::size_of_val(tags.as_ref())) + } +} + // Matcher identity preserves the observed presentation-form QNAME bytes and does not lowercase // them before building in-flight keys. RFC 4343 defines ASCII label comparison as // case-insensitive, and a protocol-compliant response is allowed to differ from the query's 0x20 @@ -26,7 +44,8 @@ const INLINE_TIMELINE_CAPACITY: usize = 1; // Edition intentionally does not canonicalize names here: byte-preserving matching aligns better // with the real behavior we target on offline caching-resolver workloads. As a result, a // query/response pair that differs only by case may fail to match even on otherwise valid DNS -// traffic. Tuple order is id, name, client IP, client port, query type, resolver IP, query class, opcode. +// traffic. Tuple order is id, name, client IP, client port, query type, resolver IP, query class, +// opcode, VLAN context. QoS bits (PCP and DEI) do not distinguish network segments. pub(super) type MatcherIdentityKey = ( u16, DnsNameBuf, @@ -36,6 +55,7 @@ pub(super) type MatcherIdentityKey = ( IpAddr, u16, u8, + VlanContext, ); pub(super) type QueryIdentityKey = MatcherIdentityKey; pub(super) type ResponseIdentityKey = MatcherIdentityKey; @@ -108,7 +128,8 @@ impl QueryEventPayload { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(super) struct ResponseEventPayload { - pub(super) response_code: ProtoResponseCode, + /// None means a response was inferred from its first IPv4 fragment. + pub(super) response_code: Option, } pub(super) enum Timeline { @@ -401,6 +422,7 @@ pub(super) struct ShardProcessingResult { pub(super) dns_query_count: usize, pub(super) duplicated_query_count: usize, pub(super) dns_response_count: usize, + pub(super) fragmented_response_prefix_count: usize, pub(super) matched_query_response_count: usize, pub(super) timeout_query_count: usize, pub(super) matched_rtt_sum_micros: u64, @@ -423,6 +445,10 @@ pub(super) struct ProcessedDnsRecord { pub(super) query_class: u16, pub(super) opcode: u8, pub(super) response_code: ProtoResponseCode, + pub(super) vlan_context: VlanContext, + pub(super) partial_first_ipv4_fragment: bool, + /// Known only when all records needed to determine the response code are in the prefix. + pub(super) partial_response_code: Option, } #[cfg(test)] diff --git a/src/monitor_memory.rs b/src/monitor_memory.rs index 3b44262..a02a905 100644 --- a/src/monitor_memory.rs +++ b/src/monitor_memory.rs @@ -6,10 +6,10 @@ */ use std::error::Error; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering as AtomicOrdering}; - +use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError}; use std::thread::JoinHandle; +use std::time::Duration; use sysinfo::{ProcessesToUpdate, System}; use tracing::info; @@ -39,13 +39,48 @@ use tracing::info; /// memory_thread.join().expect("Memory monitoring thread panicked"); /// ``` pub struct MemoryMonitorHandle { - stop: Arc, + stop: Arc, join_handle: Option>, } +struct StopSignal { + stopped: Mutex, + wake: Condvar, +} + +impl StopSignal { + fn new() -> Self { + Self { + stopped: Mutex::new(false), + wake: Condvar::new(), + } + } + + fn lock_stopped(&self) -> MutexGuard<'_, bool> { + self.stopped.lock().unwrap_or_else(PoisonError::into_inner) + } + + fn stop(&self) { + *self.lock_stopped() = true; + self.wake.notify_one(); + } + + fn is_stopped(&self) -> bool { + *self.lock_stopped() + } + + fn wait_for_next_sample(&self, interval: Duration) -> bool { + let (stopped, _) = self + .wake + .wait_timeout_while(self.lock_stopped(), interval, |stopped| !*stopped) + .unwrap_or_else(PoisonError::into_inner); + *stopped + } +} + impl MemoryMonitorHandle { pub fn stop(&self) { - self.stop.store(true, AtomicOrdering::SeqCst); + self.stop.stop(); } pub fn join(mut self) -> Result<(), Box> { @@ -64,7 +99,7 @@ impl MemoryMonitorHandle { impl Drop for MemoryMonitorHandle { fn drop(&mut self) { - self.stop.store(true, AtomicOrdering::SeqCst); + self.stop(); if let Some(join_handle) = self.join_handle.take() && let Err(err) = join_handle.join() @@ -81,7 +116,7 @@ pub fn start_tracking() // Clone the Arc to move into the monitoring thread. let memory_usage_clone = Arc::clone(&max_memory_usage); - let stop = Arc::new(AtomicBool::new(false)); + let stop = Arc::new(StopSignal::new()); let stop_clone = Arc::clone(&stop); // Spawn a new thread dedicated to monitoring memory usage. @@ -106,7 +141,7 @@ pub fn start_tracking() // Create a list containing only the current PID to limit monitoring scope. let pid_list = vec![pid]; - while !stop_clone.load(AtomicOrdering::SeqCst) { + while !stop_clone.is_stopped() { // Specify that only the processes in `pid_list` should be refreshed. let processes_to_update = ProcessesToUpdate::Some(&pid_list); @@ -122,8 +157,10 @@ pub fn start_tracking() memory_usage_clone.fetch_max(current_rss_kib, AtomicOrdering::SeqCst); } - // Sleep for 100 milliseconds before the next check to reduce CPU usage. - std::thread::sleep(std::time::Duration::from_millis(100)); + // Wait between samples, but wake immediately when shutdown is requested. + if stop_clone.wait_for_next_sample(Duration::from_millis(100)) { + break; + } } })?; @@ -140,6 +177,8 @@ pub fn start_tracking() #[cfg(test)] mod tests { use super::*; + use std::sync::mpsc; + use std::time::Instant; #[test] fn memory_monitor_can_stop_and_join() { @@ -147,4 +186,21 @@ mod tests { monitor.stop(); monitor.join().expect("monitor joins"); } + + #[test] + fn stop_wakes_waiter_before_sampling_timeout() { + let stop = Arc::new(StopSignal::new()); + let waiter_stop = Arc::clone(&stop); + let (ready_tx, ready_rx) = mpsc::channel(); + let waiter = std::thread::spawn(move || { + ready_tx.send(()).expect("waiter is ready"); + waiter_stop.wait_for_next_sample(Duration::from_secs(5)) + }); + + ready_rx.recv().expect("waiter starts"); + let started = Instant::now(); + stop.stop(); + assert!(waiter.join().expect("waiter joins")); + assert!(started.elapsed() < Duration::from_secs(2)); + } } diff --git a/src/packet_parser.rs b/src/packet_parser.rs index c673703..285c78e 100644 --- a/src/packet_parser.rs +++ b/src/packet_parser.rs @@ -35,7 +35,7 @@ use std::os::fd::AsFd; pub struct PacketPayload(Box<[u8]>); impl PacketPayload { - fn owned(data: Box<[u8]>) -> Self { + pub(crate) fn owned(data: Box<[u8]>) -> Self { Self(data) } @@ -236,14 +236,14 @@ impl PacketParser { while packet_buffer.len() < chunk_size { if self.stdin_shutdown_requested() { - return Ok(None); + break; } let next_packet = match self.backend.next_packet_data() { Err(error) if self.stdin_shutdown_requested() && is_stdin_shutdown_error(&error) => { - return Ok(None); + break; } result => result?, }; @@ -538,6 +538,8 @@ fn ensure_libpcap_ethernet_linktype( Ok(()) } +const MAX_PCAPNG_BLOCK_LEN: u32 = 16 * 1024 * 1024; + /// Owns stdin PCAPNG framing and its sole section-local interface table. The dependency's /// stateful reader converts timestamps incorrectly; even `next_raw_block` rejects valid /// resolutions while updating interfaces. Only its stateless block validation is used here. @@ -570,8 +572,7 @@ impl PcapNgStreamReader { } // Read only the fixed prefix before trusting the declared block length. The body - // grows with bytes actually received, so a huge length in a truncated stream cannot - // force an equally huge allocation. Memory remains bounded by the largest read block. + // grows with bytes actually received, while the bound also covers nontruncated streams. let mut prefix = [0_u8; 12]; self.input.read_exact(&mut prefix)?; if is_pcapng_magic(prefix[..4].try_into().expect("four-byte block type")) { @@ -586,6 +587,10 @@ impl PcapNgStreamReader { block_len >= 12 && block_len.is_multiple_of(4), "Invalid pcapng block length {block_len}: expected a multiple of four, at least 12" ); + anyhow::ensure!( + block_len <= MAX_PCAPNG_BLOCK_LEN, + "Pcapng block length {block_len} exceeds maximum {MAX_PCAPNG_BLOCK_LEN} bytes" + ); self.block_bytes.clear(); self.block_bytes.extend_from_slice(&prefix); self.input @@ -762,6 +767,48 @@ mod tests { use std::fs; use std::io::Cursor; + #[derive(Clone, Copy)] + enum ShutdownTiming { + AfterPacket, + OnNextRead, + } + + struct CancellingStream { + bytes: Vec, + offset: usize, + shutdown: Arc, + timing: ShutdownTiming, + } + + impl Read for CancellingStream { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if buf.is_empty() { + return Ok(0); + } + if self.offset == self.bytes.len() { + self.shutdown.store(true, AtomicOrdering::SeqCst); + return Err(io::Error::other(StdinReadCancelled)); + } + + // Keep the capture header separate so opening the parser cannot prefetch the packet. + let chunk_end = if self.offset < 4 { + 4 + } else if self.offset < 24 { + 24 + } else { + self.bytes.len() + }; + let len = (chunk_end - self.offset).min(buf.len()); + buf[..len].copy_from_slice(&self.bytes[self.offset..self.offset + len]); + self.offset += len; + if self.offset == self.bytes.len() && matches!(self.timing, ShutdownTiming::AfterPacket) + { + self.shutdown.store(true, AtomicOrdering::SeqCst); + } + Ok(len) + } + } + fn packet(timestamp_micros: i64, sequence: u64) -> PacketData { PacketData { data: PacketPayload::owned(Box::from([])), @@ -771,10 +818,17 @@ mod tests { } fn parser_from_stream(bytes: Vec) -> PacketParser { + parser_from_reader(Box::new(Cursor::new(bytes)), None) + } + + fn parser_from_reader( + reader: StreamReader, + stdin_shutdown: Option>, + ) -> PacketParser { PacketParser { - backend: PacketBackend::from_stream(Box::new(Cursor::new(bytes)), "test-stream") + backend: PacketBackend::from_stream(reader, "test-stream") .expect("stream parser opens"), - stdin_shutdown: None, + stdin_shutdown, enforce_monotonic_timestamps: false, packet_ordinal: 0, last_timestamp_micros: None, @@ -875,6 +929,35 @@ mod tests { assert_eq!(packet.data.as_slice(), &[1, 2, 3, 4]); } + #[test] + fn stdin_shutdown_returns_packets_already_read_into_batch() { + for timing in [ShutdownTiming::AfterPacket, ShutdownTiming::OnNextRead] { + let shutdown = Arc::new(AtomicBool::new(false)); + let reader = CancellingStream { + bytes: classic_pcap_bytes(&[(1, 2, &[1, 2, 3, 4])]), + offset: 0, + shutdown: Arc::clone(&shutdown), + timing, + }; + let mut parser = parser_from_reader(Box::new(reader), Some(Arc::clone(&shutdown))); + + let batch = parser + .next_batch(8) + .expect("cancelled read is not a parse error") + .expect("already-read packet is returned"); + assert_eq!(batch.len(), 1); + assert_eq!(batch[0].data.as_slice(), &[1, 2, 3, 4]); + assert_eq!(batch[0].packet_ordinal, 0); + assert!(shutdown.load(AtomicOrdering::SeqCst)); + assert!( + parser + .next_batch(8) + .expect("shutdown is complete") + .is_none() + ); + } + } + #[test] fn rejects_non_ethernet_classic_pcap_file_at_open() { let path = temp_test_path("packet-parser-classic-raw", "pcap"); @@ -973,6 +1056,58 @@ mod tests { assert_eq!(packet.data.as_slice(), &[1, 2, 3, 4]); } + #[test] + fn rejects_oversized_pcapng_section_before_reading_body() { + for (byte_order_magic, block_len) in [ + ( + [0x1a, 0x2b, 0x3c, 0x4d], + (MAX_PCAPNG_BLOCK_LEN + 4).to_be_bytes(), + ), + ( + [0x4d, 0x3c, 0x2b, 0x1a], + (MAX_PCAPNG_BLOCK_LEN + 4).to_le_bytes(), + ), + ] { + let mut prefix = Vec::from([0x0a, 0x0d, 0x0d, 0x0a]); + prefix.extend_from_slice(&block_len); + prefix.extend_from_slice(&byte_order_magic); + let stream: StreamReader = Box::new(Cursor::new(prefix)); + let error = PcapNgStreamReader::new(BufReader::new(CaptureInputReader::Stream( + ReplayReader::new(Vec::new(), stream), + ))) + .err() + .expect("oversized section must fail"); + assert!( + error.to_string().contains("exceeds maximum"), + "unexpected error: {error}" + ); + } + } + + #[test] + fn rejects_oversized_pcapng_block_after_valid_section() { + let mut bytes = pcapng_bytes(&[]); + let first_block_len = match &bytes[8..12] { + [0x1a, 0x2b, 0x3c, 0x4d] => u32::from_be_bytes(bytes[4..8].try_into().unwrap()), + [0x4d, 0x3c, 0x2b, 0x1a] => u32::from_le_bytes(bytes[4..8].try_into().unwrap()), + _ => panic!("invalid test pcapng byte order"), + } as usize; + let oversized_len = match &bytes[8..12] { + [0x1a, 0x2b, 0x3c, 0x4d] => (MAX_PCAPNG_BLOCK_LEN + 4).to_be_bytes(), + _ => (MAX_PCAPNG_BLOCK_LEN + 4).to_le_bytes(), + }; + bytes[first_block_len + 4..first_block_len + 8].copy_from_slice(&oversized_len); + + let mut parser = parser_from_stream(bytes); + let error = parser + .next_batch(1) + .expect_err("oversized interface block must fail"); + assert!( + error.to_string().contains("exceeds maximum"), + "unexpected error: {error}" + ); + } + #[test] fn rejects_non_ethernet_pcapng_enhanced_packet() { let mut parser = parser_from_stream(enhanced_pcapng_bytes( diff --git a/src/packet_parser_wire_tests.rs b/src/packet_parser_wire_tests.rs index 96dfaf6..28d21db 100644 --- a/src/packet_parser_wire_tests.rs +++ b/src/packet_parser_wire_tests.rs @@ -331,23 +331,28 @@ fn preserves_block_and_packet_length_validation() { } #[test] -fn rejects_huge_truncated_blocks_without_reserving_the_declared_length() { - let mut capture = WireCapture::new(Order::Little); - capture.bytes.extend(6_u32.to_le_bytes()); - capture.bytes.extend(0xffff_fffc_u32.to_le_bytes()); - capture.bytes.extend([0; 4]); - let mut parser = parser(capture.bytes); - assert!( - parser - .next_batch(8) - .unwrap_err() - .to_string() - .contains("Truncated pcapng block") - ); - let PacketBackend::PcapNg(reader) = &parser.backend else { - panic!("pcapng backend") - }; - assert!(reader.block_bytes.capacity() < 1024 * 1024); +fn rejects_oversized_or_truncated_blocks_without_reserving_the_declared_length() { + for (declared_len, expected_error) in [ + (0xffff_fffc_u32, "exceeds maximum"), + (1024_u32, "Truncated pcapng block"), + ] { + let mut capture = WireCapture::new(Order::Little); + capture.bytes.extend(6_u32.to_le_bytes()); + capture.bytes.extend(declared_len.to_le_bytes()); + capture.bytes.extend([0; 4]); + let mut parser = parser(capture.bytes); + assert!( + parser + .next_batch(8) + .unwrap_err() + .to_string() + .contains(expected_error) + ); + let PacketBackend::PcapNg(reader) = &parser.backend else { + panic!("pcapng backend") + }; + assert!(reader.block_bytes.capacity() < 1024 * 1024); + } } #[test] diff --git a/src/parquet_writer.rs b/src/parquet_writer.rs index 280846a..2fd5e37 100644 --- a/src/parquet_writer.rs +++ b/src/parquet_writer.rs @@ -183,22 +183,49 @@ where Ok(()) } +#[derive(Default)] +struct ParquetWriterScratch { + request_timestamps: Vec, + response_timestamps: Vec, + response_timestamp_definition_levels: Vec, + source_ports: Vec, + ids: Vec, + query_types: Vec, + response_codes: Vec, + response_code_definition_levels: Vec, +} + +impl ParquetWriterScratch { + fn prepare(&mut self, len: usize) { + self.request_timestamps.clear(); + self.request_timestamps.reserve(len); + self.response_timestamps.clear(); + self.response_timestamps.reserve(len); + self.response_timestamp_definition_levels.clear(); + self.response_timestamp_definition_levels.reserve(len); + self.source_ports.clear(); + self.source_ports.reserve(len); + self.ids.clear(); + self.ids.reserve(len); + self.query_types.clear(); + self.query_types.reserve(len); + self.response_codes.clear(); + self.response_codes.reserve(len); + self.response_code_definition_levels.clear(); + self.response_code_definition_levels.reserve(len); + } +} + fn flush_buffer_async_parquet( parquet_writer: &mut SerializedFileWriter, buffer: &mut Vec, + scratch: &mut ParquetWriterScratch, ) -> Result<(), Box> where W: Write + Send, { let len = buffer.len(); - let mut request_timestamps = Vec::with_capacity(len); - let mut response_timestamps = Vec::with_capacity(len); - let mut response_timestamp_definition_levels = Vec::with_capacity(len); - let mut source_ports = Vec::with_capacity(len); - let mut ids = Vec::with_capacity(len); - let mut query_types = Vec::with_capacity(len); - let mut response_codes = Vec::with_capacity(len); - let mut response_code_definition_levels = Vec::with_capacity(len); + scratch.prepare(len); let (source_ip_byte_capacity, name_byte_capacity) = buffer .iter() @@ -212,24 +239,28 @@ where let mut name_builder = PackedByteArrayBuilder::with_capacity(len, name_byte_capacity); for record in buffer.iter() { - request_timestamps.push(record.request_timestamp); + scratch.request_timestamps.push(record.request_timestamp); if let Some(response_timestamp) = record.response_timestamp { - response_timestamps.push(response_timestamp); - response_timestamp_definition_levels.push(1); + scratch.response_timestamps.push(response_timestamp); + scratch.response_timestamp_definition_levels.push(1); } else { - response_timestamp_definition_levels.push(0); + scratch.response_timestamp_definition_levels.push(0); } - source_ports.push(i32::from(record.source_port)); - ids.push(i32::from(record.id)); + scratch.source_ports.push(i32::from(record.source_port)); + scratch.ids.push(i32::from(record.id)); source_ip_builder.push_display(record.source_ip); name_builder.push_bytes(record.name.as_bytes()); - query_types.push(protocol_text_bytes(record.query_type.as_str())); + scratch + .query_types + .push(protocol_text_bytes(record.query_type.as_str())); if let Some(response_code) = &record.response_code { - response_codes.push(protocol_text_bytes(response_code.as_str())); - response_code_definition_levels.push(1); + scratch + .response_codes + .push(protocol_text_bytes(response_code.as_str())); + scratch.response_code_definition_levels.push(1); } else { - response_code_definition_levels.push(0); + scratch.response_code_definition_levels.push(0); } } @@ -241,13 +272,13 @@ where let mut row_group_writer = parquet_writer.next_row_group()?; write_column::( &mut row_group_writer, - &request_timestamps, + &scratch.request_timestamps, "Missing Parquet INT64 column", )?; write_optional_column::( &mut row_group_writer, - &response_timestamps, - &response_timestamp_definition_levels, + &scratch.response_timestamps, + &scratch.response_timestamp_definition_levels, "Missing Parquet INT64 column", )?; write_column::( @@ -257,10 +288,14 @@ where )?; write_column::( &mut row_group_writer, - &source_ports, + &scratch.source_ports, + "Missing Parquet INT32 column", + )?; + write_column::( + &mut row_group_writer, + &scratch.ids, "Missing Parquet INT32 column", )?; - write_column::(&mut row_group_writer, &ids, "Missing Parquet INT32 column")?; write_column::( &mut row_group_writer, &names, @@ -268,13 +303,13 @@ where )?; write_column::( &mut row_group_writer, - &query_types, + &scratch.query_types, "Missing Parquet BYTE_ARRAY column", )?; write_optional_column::( &mut row_group_writer, - &response_codes, - &response_code_definition_levels, + &scratch.response_codes, + &scratch.response_code_definition_levels, "Missing Parquet BYTE_ARRAY column", )?; row_group_writer.close()?; @@ -289,9 +324,10 @@ pub(crate) fn parquet_writer( rx: Receiver, ) -> Result<(), Box> { let mut buffer = Vec::with_capacity(OUTPUT_FLUSH_THRESHOLD); + let mut scratch = ParquetWriterScratch::default(); drain_output_messages(rx, &mut buffer, |buffer| { - flush_buffer_async_parquet(&mut parquet_writer, buffer) + flush_buffer_async_parquet(&mut parquet_writer, buffer, &mut scratch) })?; parquet_writer.close()?; @@ -351,6 +387,9 @@ mod tests { bonded: 0, anonymize: None, dns_wire_fast_path: false, + max_dns_compression_jumps: crate::config::DEFAULT_MAX_DNS_COMPRESSION_JUMPS, + allow_fragments: false, + full_fragments: false, }; let file = File::create(&filename).expect("creates parquet file"); let writer = create_parquet_writer(file, &config).expect("creates parquet writer"); @@ -389,6 +428,9 @@ mod tests { bonded: 0, anonymize: None, dns_wire_fast_path: false, + max_dns_compression_jumps: crate::config::DEFAULT_MAX_DNS_COMPRESSION_JUMPS, + allow_fragments: false, + full_fragments: false, }; let file = File::create(&filename).expect("creates parquet file"); let writer = create_parquet_writer(file, &config).expect("creates parquet writer"); @@ -427,6 +469,9 @@ mod tests { bonded: 0, anonymize: None, dns_wire_fast_path: false, + max_dns_compression_jumps: crate::config::DEFAULT_MAX_DNS_COMPRESSION_JUMPS, + allow_fragments: false, + full_fragments: false, }; let writer = create_parquet_writer( SharedSink { @@ -467,6 +512,9 @@ mod tests { bonded: 0, anonymize: None, dns_wire_fast_path: false, + max_dns_compression_jumps: crate::config::DEFAULT_MAX_DNS_COMPRESSION_JUMPS, + allow_fragments: false, + full_fragments: false, }; let file = File::create(&filename).expect("creates parquet file"); let writer = create_parquet_writer(file, &config).expect("creates parquet writer"); @@ -506,6 +554,9 @@ mod tests { bonded: 0, anonymize: None, dns_wire_fast_path: false, + max_dns_compression_jumps: crate::config::DEFAULT_MAX_DNS_COMPRESSION_JUMPS, + allow_fragments: false, + full_fragments: false, }; let file = File::create(&filename).expect("creates parquet file"); let writer = create_parquet_writer(file, &config).expect("creates parquet writer"); @@ -570,9 +621,13 @@ mod tests { bonded: 0, anonymize: None, dns_wire_fast_path: false, + max_dns_compression_jumps: crate::config::DEFAULT_MAX_DNS_COMPRESSION_JUMPS, + allow_fragments: false, + full_fragments: false, }; let file = File::create(&filename).expect("creates parquet file"); let mut writer = create_parquet_writer(file, &config).expect("creates parquet writer"); + let mut scratch = ParquetWriterScratch::default(); let mut empty_name = test_dns_record(); empty_name.source_ip = "192.0.2.1".parse().expect("IPv4 address parses"); @@ -586,15 +641,40 @@ mod tests { long_ipv6.name = DnsNameBuf::new(&escaped_zero).expect("maximum presentation name fits"); let mut buffer = vec![empty_name, long_ipv6]; - flush_buffer_async_parquet(&mut writer, &mut buffer).expect("first row group is written"); + flush_buffer_async_parquet(&mut writer, &mut buffer, &mut scratch) + .expect("first row group is written"); assert!(buffer.is_empty()); + let retained_capacities = [ + scratch.request_timestamps.capacity(), + scratch.response_timestamps.capacity(), + scratch.response_timestamp_definition_levels.capacity(), + scratch.source_ports.capacity(), + scratch.ids.capacity(), + scratch.query_types.capacity(), + scratch.response_codes.capacity(), + scratch.response_code_definition_levels.capacity(), + ]; let escaped_one = max_escaped_name(1); let mut second_group = test_dns_record(); second_group.source_ip = "2001:db8:ffff::ffff".parse().expect("IPv6 address parses"); second_group.name = DnsNameBuf::new(&escaped_one).expect("maximum presentation name fits"); buffer.push(second_group); - flush_buffer_async_parquet(&mut writer, &mut buffer).expect("second row group is written"); + flush_buffer_async_parquet(&mut writer, &mut buffer, &mut scratch) + .expect("second row group is written"); + assert_eq!( + [ + scratch.request_timestamps.capacity(), + scratch.response_timestamps.capacity(), + scratch.response_timestamp_definition_levels.capacity(), + scratch.source_ports.capacity(), + scratch.ids.capacity(), + scratch.query_types.capacity(), + scratch.response_codes.capacity(), + scratch.response_code_definition_levels.capacity(), + ], + retained_capacities + ); writer.close().expect("parquet writer closes"); let reader = SerializedFileReader::new(File::open(&filename).expect("opens output")) diff --git a/src/record.rs b/src/record.rs index ff0b8a9..263cd05 100644 --- a/src/record.rs +++ b/src/record.rs @@ -11,8 +11,9 @@ use std::net::IpAddr; /// Canonical exported DNS record contract shared by CSV and Parquet writers. /// -/// Timeout records leave response fields absent, which encodes "no matching response was observed -/// inside the configured timeout window". +/// Timeout records leave both response fields absent. A response timestamp with no response code +/// means a query was paired with an observed first IPv4 response fragment, but the DNS response +/// code could not be determined from the available prefix. #[derive(Debug, Clone, Serialize, Deserialize)] pub(crate) struct DnsRecord { pub(crate) request_timestamp: i64, diff --git a/src/runtime.rs b/src/runtime.rs index dc64014..72d877a 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -226,9 +226,15 @@ pub(crate) fn log_system_info( let formatted_os = format_os_name(std::env::consts::OS); info!("OS: {}, ARCH: {}", formatted_os, std::env::consts::ARCH); + let budget = args.execution_budget(); + let budget_source = if args.requested_threads.is_some() { + "operator cap" + } else { + "auto" + }; info!( - "Available parallelism: {}, execution budget: auto (all available CPUs), affinity requested: {}, effective: {}", - args.num_cpus, args.affinity, affinity_effective, + "Available parallelism: {}, execution budget: {} CPUs ({}), affinity requested: {}, effective: {}", + args.num_cpus, budget.available_cpus, budget_source, args.affinity, affinity_effective, ); info!( "Available memory (system reported): {} MB", diff --git a/tests/end_to_end.rs b/tests/end_to_end.rs index 29811cb..7a844bd 100644 --- a/tests/end_to_end.rs +++ b/tests/end_to_end.rs @@ -65,6 +65,35 @@ fn append_opt_record(dns_payload: &mut Vec, extended_high: u8, edns_version: dns_payload.extend_from_slice(&0_u16.to_be_bytes()); } +fn fragment_ipv4_udp_packet(packet: &[u8], first_payload_len: usize) -> (Vec, Vec) { + const IPV4_START: usize = 14; + const IPV4_HEADER_LEN: usize = 20; + assert_eq!( + first_payload_len % 8, + 0, + "nonfinal fragment must be 8-byte aligned" + ); + let first_end = IPV4_START + IPV4_HEADER_LEN + first_payload_len; + assert!(first_end < packet.len(), "fixture needs a later fragment"); + + let mut first_fragment = packet[..first_end].to_vec(); + first_fragment[IPV4_START + 2..IPV4_START + 4] + .copy_from_slice(&((IPV4_HEADER_LEN + first_payload_len) as u16).to_be_bytes()); + first_fragment[IPV4_START + 4..IPV4_START + 6].copy_from_slice(&0x4321_u16.to_be_bytes()); + first_fragment[IPV4_START + 6..IPV4_START + 8].copy_from_slice(&0x2000_u16.to_be_bytes()); + + let remaining_payload = &packet[first_end..]; + let mut later_fragment = packet[..IPV4_START + IPV4_HEADER_LEN].to_vec(); + later_fragment[IPV4_START + 2..IPV4_START + 4] + .copy_from_slice(&((IPV4_HEADER_LEN + remaining_payload.len()) as u16).to_be_bytes()); + later_fragment[IPV4_START + 4..IPV4_START + 6].copy_from_slice(&0x4321_u16.to_be_bytes()); + later_fragment[IPV4_START + 6..IPV4_START + 8] + .copy_from_slice(&((first_payload_len / 8) as u16).to_be_bytes()); + later_fragment.extend_from_slice(remaining_payload); + + (first_fragment, later_fragment) +} + #[cfg(unix)] fn wait_for_child_exit(child: &mut Child) { let deadline = Instant::now() + Duration::from_secs(5); @@ -138,7 +167,12 @@ fn wait_for_stdin_start_log(child: &mut Child) { } #[cfg(unix)] -fn run_open_stdin_signal_test(name: &str, bytes: &[u8], signal: Signal, has_header: bool) { +fn run_open_stdin_signal_test( + name: &str, + bytes: &[u8], + signal: Signal, + has_header: bool, +) -> Option { let output_path = temp_test_path(name, "csv"); let mut child = Command::new(dpp_binary()) .args([ @@ -183,7 +217,7 @@ fn run_open_stdin_signal_test(name: &str, bytes: &[u8], signal: Signal, has_head String::from_utf8_lossy(&output.stderr) ); - if has_header { + let report = if has_header { let report: serde_json::Value = serde_json::from_slice(&output.stdout).expect("JSON report parses"); assert_eq!( @@ -195,17 +229,20 @@ fn run_open_stdin_signal_test(name: &str, bytes: &[u8], signal: Signal, has_head "request_timestamp,response_timestamp,source_ip,source_port,id,name,query_type,response_code\n" ); fs::remove_file(&output_path).expect("remove output csv"); + Some(report) } else { assert!( output.stdout.is_empty(), "no summary exists before capture setup" ); assert!(!output_path.exists(), "writer was not initialized"); - } + None + }; // Keep the parent end of the pipe open until after DPP exits. A closed pipe // would test EOF handling instead of signal cancellation. drop(stdin); + report } #[test] @@ -269,6 +306,268 @@ fn matched_query_response_pair_round_trips_to_exact_csv_record() { fs::remove_file(&output_path).expect("remove output csv"); } +#[test] +fn ipv4_first_fragment_response_requires_opt_in_and_later_fragment_is_ignored() { + let input_path = temp_test_path("first-fragment-response", "pcap"); + let default_output_path = temp_test_path("first-fragment-default", "csv"); + let fragment_output_path = temp_test_path("first-fragment-enabled", "csv"); + + let mut query_payload = encode_dns_header(0x1234, 0x0100, 1); + append_example_a_query(&mut query_payload); + + let mut response_payload = encode_dns_header(0x1234, 0x8180, 1); + response_payload[6..8].copy_from_slice(&1_u16.to_be_bytes()); + response_payload[10..12].copy_from_slice(&1_u16.to_be_bytes()); + append_example_a_query(&mut response_payload); + // The answer starts in the first fragment and ends in the second one. + response_payload.extend_from_slice(&[0xc0, 0x0c, 0, 1, 0, 1, 0, 0, 0, 60, 0, 4, 192, 0, 2, 1]); + // An OPT record beyond the first fragment can change the full RCODE. + append_opt_record(&mut response_payload, 0, 0); + + let query_packet = + make_udp_dns_packet_with_payload([10, 0, 0, 1], [8, 8, 8, 8], 53_000, 53, &query_payload); + let response_packet = make_udp_dns_packet_with_payload( + [8, 8, 8, 8], + [10, 0, 0, 1], + 53, + 53_000, + &response_payload, + ); + let (first_fragment, later_fragment) = fragment_ipv4_udp_packet(&response_packet, 40); + + fs::write( + &input_path, + classic_pcap_bytes(&[ + (1, 0, &query_packet), + (1, 200_000, &first_fragment), + (1, 200_001, &later_fragment), + ]), + ) + .expect("fragmented test pcap written"); + + let default_result = Command::new(dpp_binary()) + .arg("-s") + .arg(&input_path) + .arg(&default_output_path) + .output() + .expect("dpp executed without fragment mode"); + assert!( + default_result.status.success(), + "dpp failed: {}", + String::from_utf8_lossy(&default_result.stderr) + ); + assert_eq!( + fs::read_to_string(&default_output_path).expect("default CSV readable"), + concat!( + "request_timestamp,response_timestamp,source_ip,source_port,id,name,query_type,response_code\n", + "1000000,,10.0.0.1,53000,4660,example.com,A,\n" + ) + ); + + let fragment_result = Command::new(dpp_binary()) + .arg("-s") + .arg("--allow-fragments") + .arg(&input_path) + .arg(&fragment_output_path) + .output() + .expect("dpp executed with fragment mode"); + assert!( + fragment_result.status.success(), + "dpp failed: {}", + String::from_utf8_lossy(&fragment_result.stderr) + ); + assert_eq!( + fs::read_to_string(&fragment_output_path).expect("fragment CSV readable"), + concat!( + "request_timestamp,response_timestamp,source_ip,source_port,id,name,query_type,response_code\n", + "1000000,1200000,10.0.0.1,53000,4660,example.com,A,\n" + ) + ); + + fs::remove_file(input_path).expect("remove input pcap"); + fs::remove_file(default_output_path).expect("remove default output csv"); + fs::remove_file(fragment_output_path).expect("remove fragment output csv"); +} + +#[test] +fn full_ipv4_fragment_reassembly_recovers_extended_response_code_once() { + let input_path = temp_test_path("full-fragments-edns", "pcap"); + let output_path = temp_test_path("full-fragments-edns", "csv"); + + let mut query_payload = encode_dns_header(0x2345, 0x0100, 1); + query_payload[10..12].copy_from_slice(&1_u16.to_be_bytes()); + append_example_a_query(&mut query_payload); + append_opt_record(&mut query_payload, 0, 0); + + let mut response_payload = encode_dns_header(0x2345, 0x8180, 1); + response_payload[10..12].copy_from_slice(&1_u16.to_be_bytes()); + append_example_a_query(&mut response_payload); + append_opt_record(&mut response_payload, 1, 0); + + let query_packet = + make_udp_dns_packet_with_payload([10, 0, 0, 1], [8, 8, 8, 8], 53_000, 53, &query_payload); + let response_packet = make_udp_dns_packet_with_payload( + [8, 8, 8, 8], + [10, 0, 0, 1], + 53, + 53_000, + &response_payload, + ); + // The first fragment contains the DNS header and question, but only the + // beginning of the OPT record that carries the extended response code. + let (first_fragment, later_fragment) = fragment_ipv4_udp_packet(&response_packet, 40); + fs::write( + &input_path, + classic_pcap_bytes(&[ + (1, 0, &query_packet), + (1, 200_000, &first_fragment), + (1, 200_001, &later_fragment), + ]), + ) + .expect("fragmented EDNS test pcap written"); + + let result = Command::new(dpp_binary()) + .arg("-s") + .arg("--full-fragments") + .arg(&input_path) + .arg(&output_path) + .output() + .expect("dpp executed with full fragment reassembly"); + assert!( + result.status.success(), + "dpp failed: {}", + String::from_utf8_lossy(&result.stderr) + ); + assert_eq!( + fs::read_to_string(&output_path).expect("full fragment CSV readable"), + concat!( + "request_timestamp,response_timestamp,source_ip,source_port,id,name,query_type,response_code\n", + "1000000,1200001,10.0.0.1,53000,9029,example.com,A,EDNS_BADVERS\n" + ) + ); + + fs::remove_file(input_path).expect("remove input pcap"); + fs::remove_file(output_path).expect("remove output csv"); +} + +#[test] +fn full_ipv4_reassembly_ignores_first_fragment_duplicate_after_completion() { + let input_path = temp_test_path("full-fragments-duplicate-first", "pcap"); + let output_path = temp_test_path("full-fragments-duplicate-first", "csv"); + + let mut query_payload = encode_dns_header(0x2345, 0x0100, 1); + append_example_a_query(&mut query_payload); + let mut response_payload = encode_dns_header(0x2345, 0x8180, 1); + response_payload[10..12].copy_from_slice(&1_u16.to_be_bytes()); + append_example_a_query(&mut response_payload); + append_opt_record(&mut response_payload, 1, 0); + + let query_packet = + make_udp_dns_packet_with_payload([10, 0, 0, 1], [8, 8, 8, 8], 53_000, 53, &query_payload); + let response_packet = make_udp_dns_packet_with_payload( + [8, 8, 8, 8], + [10, 0, 0, 1], + 53, + 53_000, + &response_payload, + ); + let (first_fragment, final_fragment) = fragment_ipv4_udp_packet(&response_packet, 40); + fs::write( + &input_path, + classic_pcap_bytes(&[ + (1, 0, &query_packet), + (1, 200_000, &first_fragment), + (1, 200_001, &final_fragment), + // The first transaction is already matched. A second query makes + // an inferred response from the duplicate visible in the CSV. + (1, 300_000, &query_packet), + (1, 400_000, &first_fragment), + ]), + ) + .expect("fragmented duplicate test pcap written"); + + let result = Command::new(dpp_binary()) + .args(["-s", "--full-fragments", "--report-format", "json"]) + .arg(&input_path) + .arg(&output_path) + .output() + .expect("dpp executed with full fragment reassembly"); + assert!( + result.status.success(), + "dpp failed: {}", + String::from_utf8_lossy(&result.stderr) + ); + assert_eq!( + fs::read_to_string(&output_path).expect("duplicate fragment CSV readable"), + concat!( + "request_timestamp,response_timestamp,source_ip,source_port,id,name,query_type,response_code\n", + "1000000,1200001,10.0.0.1,53000,9029,example.com,A,EDNS_BADVERS\n", + "1300000,,10.0.0.1,53000,9029,example.com,A,\n" + ) + ); + let report: serde_json::Value = + serde_json::from_slice(&result.stdout).expect("JSON report parses"); + assert_eq!(report["metrics"]["total_dns_responses_processed"], 1); + assert_eq!(report["metrics"]["fragmented_response_prefix_count"], 0); + assert_eq!(report["metrics"]["total_matched_query_response_pairs"], 1); + assert_eq!(report["metrics"]["timed_out_queries"], 1); + + fs::remove_file(input_path).expect("remove input pcap"); + fs::remove_file(output_path).expect("remove output csv"); +} + +#[test] +fn full_ipv4_fragment_mode_falls_back_to_first_fragment_at_eof() { + let input_path = temp_test_path("full-fragments-incomplete", "pcap"); + let output_path = temp_test_path("full-fragments-incomplete", "csv"); + + let mut query_payload = encode_dns_header(0x2345, 0x0100, 1); + append_example_a_query(&mut query_payload); + let mut response_payload = encode_dns_header(0x2345, 0x8180, 1); + response_payload[10..12].copy_from_slice(&1_u16.to_be_bytes()); + append_example_a_query(&mut response_payload); + append_opt_record(&mut response_payload, 1, 0); + + let query_packet = + make_udp_dns_packet_with_payload([10, 0, 0, 1], [8, 8, 8, 8], 53_000, 53, &query_payload); + let response_packet = make_udp_dns_packet_with_payload( + [8, 8, 8, 8], + [10, 0, 0, 1], + 53, + 53_000, + &response_payload, + ); + let (first_fragment, _missing_later_fragment) = fragment_ipv4_udp_packet(&response_packet, 40); + fs::write( + &input_path, + classic_pcap_bytes(&[(1, 0, &query_packet), (1, 200_000, &first_fragment)]), + ) + .expect("incomplete fragment pcap written"); + + let result = Command::new(dpp_binary()) + .arg("-s") + .arg("--full-fragments") + .arg(&input_path) + .arg(&output_path) + .output() + .expect("dpp executed with full fragment mode"); + assert!( + result.status.success(), + "dpp failed: {}", + String::from_utf8_lossy(&result.stderr) + ); + assert_eq!( + fs::read_to_string(&output_path).expect("incomplete fragment CSV readable"), + concat!( + "request_timestamp,response_timestamp,source_ip,source_port,id,name,query_type,response_code\n", + "1000000,1200000,10.0.0.1,53000,9029,example.com,A,\n" + ) + ); + + fs::remove_file(input_path).expect("remove input pcap"); + fs::remove_file(output_path).expect("remove output csv"); +} + #[test] fn maximum_wire_qname_round_trips_with_full_escaped_presentation() { let input_path = temp_test_path("maximum-wire-qname", "pcap"); @@ -323,6 +622,78 @@ fn maximum_wire_qname_round_trips_with_full_escaped_presentation() { fs::remove_file(&output_path).expect("remove output csv"); } +#[test] +fn anonymization_key_is_not_overwritten_by_output_alias() { + let input_path = temp_test_path("key-output-alias", "pcap"); + let key_path = temp_test_path("key-output-alias", "key"); + let alias_path = temp_test_path("key-output-alias", "csv"); + let distinct_output = temp_test_path("key-output-distinct", "csv"); + fs::write(&input_path, classic_pcap_bytes(&[])).expect("empty pcap written"); + fs::write(&key_path, b"secret-passphrase\n").expect("key written"); + fs::hard_link(&key_path, &alias_path).expect("hard link to key created"); + + for output_path in [&key_path, &alias_path] { + let output = Command::new(dpp_binary()) + .arg("--anonymize") + .arg(&key_path) + .arg(&input_path) + .arg(output_path) + .output() + .expect("dpp executed"); + assert!(!output.status.success(), "key alias must be rejected"); + assert!(String::from_utf8_lossy(&output.stderr).contains("refusing to overwrite the key")); + assert_eq!( + fs::read(&key_path).expect("key remains readable"), + b"secret-passphrase\n" + ); + } + + let output = Command::new(dpp_binary()) + .arg("--anonymize") + .arg(&key_path) + .arg(&input_path) + .arg(&distinct_output) + .output() + .expect("dpp executed with distinct output"); + assert!( + output.status.success(), + "dpp failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + fs::remove_file(input_path).expect("remove input pcap"); + fs::remove_file(alias_path).expect("remove key alias"); + fs::remove_file(key_path).expect("remove key"); + fs::remove_file(distinct_output).expect("remove distinct output"); +} + +#[test] +fn thread_cap_changes_reported_execution_budget() { + let input_path = temp_test_path("thread-cap", "pcap"); + let output_path = temp_test_path("thread-cap", "csv"); + fs::write(&input_path, classic_pcap_bytes(&[])).expect("empty pcap written"); + + let output = Command::new(dpp_binary()) + .args(["--threads", "1", "--report-format", "json"]) + .arg(&input_path) + .arg(&output_path) + .output() + .expect("dpp executed"); + assert!( + output.status.success(), + "dpp failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let report: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("JSON report parses"); + assert_eq!(report["execution"]["available_cpus"], 1); + assert_eq!(report["execution"]["model"], "phase_parallel"); + assert_eq!(report["execution"]["rayon_threads"], 1); + + fs::remove_file(&input_path).expect("remove input pcap"); + fs::remove_file(&output_path).expect("remove output csv"); +} + #[test] fn oversized_wire_qname_is_rejected_and_reported() { let input_path = temp_test_path("oversized-wire-qname", "pcap"); @@ -670,7 +1041,34 @@ fn sigint_stops_classic_pcap_stdin_without_eof() { let packet = make_udp_dns_packet_with_payload([10, 0, 0, 1], [8, 8, 8, 8], 53_000, 53, &dns_payload); let bytes = classic_pcap_bytes(&[(1, 0, &packet)]); - run_open_stdin_signal_test("sigint-classic-open-pipe", &bytes, Signal::SIGINT, true); + let _ = run_open_stdin_signal_test("sigint-classic-open-pipe", &bytes, Signal::SIGINT, true); +} + +#[cfg(unix)] +#[test] +fn sigint_processes_completed_packets_in_partial_stdin_batch() { + let mut query_payload = encode_dns_header(0x1234, 0x0100, 1); + append_example_a_query(&mut query_payload); + let mut response_payload = encode_dns_header(0x1234, 0x8180, 1); + append_example_a_query(&mut response_payload); + let query = + make_udp_dns_packet_with_payload([10, 0, 0, 1], [8, 8, 8, 8], 53_000, 53, &query_payload); + let response = make_udp_dns_packet_with_payload( + [8, 8, 8, 8], + [10, 0, 0, 1], + 53, + 53_000, + &response_payload, + ); + let bytes = classic_pcap_bytes(&[(1, 0, &query), (1, 200_000, &response)]); + + let report = + run_open_stdin_signal_test("sigint-partial-stdin-batch", &bytes, Signal::SIGINT, true) + .expect("signal report exists"); + assert_eq!(report["metrics"]["total_packets_processed"], 2); + assert_eq!(report["metrics"]["total_dns_queries_processed"], 1); + assert_eq!(report["metrics"]["total_dns_responses_processed"], 1); + assert_eq!(report["metrics"]["total_matched_query_response_pairs"], 1); } #[cfg(unix)] @@ -684,7 +1082,7 @@ fn sigint_stops_pcapng_stdin_mid_block_without_eof() { // A complete next block prefix declares a body that never arrives. This // exercises read_exact/read_to_end cancellation while stdin stays open. bytes.extend_from_within(..12); - run_open_stdin_signal_test("sigint-pcapng-mid-block", &bytes, Signal::SIGINT, true); + let _ = run_open_stdin_signal_test("sigint-pcapng-mid-block", &bytes, Signal::SIGINT, true); } #[cfg(unix)] @@ -700,13 +1098,13 @@ fn sigterm_stops_classic_pcap_stdin_mid_record_without_eof() { bytes.extend_from_slice(&64_u32.to_le_bytes()); bytes.extend_from_slice(&64_u32.to_le_bytes()); bytes.extend_from_slice(&[0_u8; 3]); - run_open_stdin_signal_test("sigterm-classic-mid-record", &bytes, Signal::SIGTERM, true); + let _ = run_open_stdin_signal_test("sigterm-classic-mid-record", &bytes, Signal::SIGTERM, true); } #[cfg(unix)] #[test] fn sigint_stops_stdin_probe_before_capture_header_without_eof() { - run_open_stdin_signal_test( + let _ = run_open_stdin_signal_test( "sigint-before-capture-header", &[0xd4, 0xc3, 0xb2], Signal::SIGINT, diff --git a/tests/vlan_isolation.rs b/tests/vlan_isolation.rs new file mode 100644 index 0000000..6b605f3 --- /dev/null +++ b/tests/vlan_isolation.rs @@ -0,0 +1,180 @@ +/* + * Nameto Oy © 2026. All rights reserved. + * + * This software is licensed under the GNU General Public License (GPL) version 3. + * Commercial licensing options: . + */ + +#[allow(dead_code)] +mod support; + +use std::fs; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; + +use support::{ + classic_pcap_bytes, encode_dns_header, make_udp_dns_packet_with_payload, temp_test_path, +}; + +fn dns_packet(response: bool, ipv6: bool, tags: &[(u16, u16)]) -> Vec { + let mut dns = encode_dns_header(123, if response { 0x8180 } else { 0x0100 }, 1); + dns.extend_from_slice(b"\x01a\0\0\x01\0\x01"); + let (source, destination, source_port, destination_port) = if response { + ([8, 8, 8, 8], [10, 0, 0, 1], 53, 53000) + } else { + ([10, 0, 0, 1], [8, 8, 8, 8], 53000, 53) + }; + let mut packet = + make_udp_dns_packet_with_payload(source, destination, source_port, destination_port, &dns); + if ipv6 { + let udp = packet[34..].to_vec(); + packet.truncate(14); + packet[12..14].copy_from_slice(&0x86dd_u16.to_be_bytes()); + packet.extend_from_slice(&[0x60, 0, 0, 0]); + packet.extend_from_slice(&(udp.len() as u16).to_be_bytes()); + packet.extend_from_slice(&[17, 64]); + for address in [source, destination] { + packet.extend_from_slice(&[0x20, 1, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0]); + packet.extend_from_slice(&address); + } + packet.extend_from_slice(&udp); + } + let mut tagged = packet[..12].to_vec(); + for &(tpid, tci) in tags { + tagged.extend_from_slice(&tpid.to_be_bytes()); + tagged.extend_from_slice(&tci.to_be_bytes()); + } + tagged.extend_from_slice(&packet[12..]); + tagged +} + +fn output_timestamps( + packets: &[Vec], + threads: &str, + fast_path: bool, + full_fragments: bool, +) -> Vec<(i64, Option)> { + static NEXT_CAPTURE: AtomicU64 = AtomicU64::new(0); + let prefix = format!( + "vlan-isolation-{}", + NEXT_CAPTURE.fetch_add(1, Ordering::Relaxed) + ); + let input = temp_test_path(&prefix, "pcap"); + let output = temp_test_path(&prefix, "csv"); + let capture_packets: Vec<_> = packets + .iter() + .enumerate() + .map(|(index, packet)| (1, index as u32 * 100, packet.as_slice())) + .collect(); + fs::write(&input, classic_pcap_bytes(&capture_packets)).unwrap(); + let mut command = Command::new(env!("CARGO_BIN_EXE_dpp")); + command.args(["--silent", "--threads", threads]); + if fast_path { + command.arg("--dns-wire-fast-path"); + } + if full_fragments { + command.arg("--full-fragments"); + } + let result = command.arg(&input).arg(&output).output().unwrap(); + assert!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + let mut rows: Vec<_> = csv::Reader::from_path(&output) + .unwrap() + .records() + .map(|record| { + let record = record.unwrap(); + (record[0].parse().unwrap(), record[1].parse().ok()) + }) + .collect(); + rows.sort_unstable(); + fs::remove_file(input).unwrap(); + fs::remove_file(output).unwrap(); + rows +} + +#[test] +fn overlapping_dns_tuples_remain_isolated_by_full_vlan_stack() { + for (left, right) in [ + (vec![(0x8100, 100)], vec![(0x8100, 200)]), + (vec![], vec![(0x8100, 100)]), + (vec![(0x8100, 100)], vec![(0x9100, 100)]), + ( + vec![(0x88a8, 10), (0x8100, 100)], + vec![(0x88a8, 20), (0x8100, 100)], + ), + ( + vec![(0x88a8, 10), (0x8100, 100)], + vec![(0x88a8, 10), (0x8100, 200)], + ), + ] { + for ipv6 in [false, true] { + let packets = [ + dns_packet(false, ipv6, &left), + dns_packet(false, ipv6, &right), + dns_packet(true, ipv6, &right), + ]; + for threads in ["1", "6"] { + for fast_path in [false, true] { + assert_eq!( + output_timestamps(&packets, threads, fast_path, false), + [(1_000_000, None), (1_000_100, Some(1_000_200))], + "left={left:?}, right={right:?}, ipv6={ipv6}, threads={threads}, fast_path={fast_path}" + ); + } + } + } + } +} + +#[test] +fn vlan_priority_and_drop_eligibility_changes_preserve_matching() { + for ipv6 in [false, true] { + let packets = [ + dns_packet(false, ipv6, &[(0x88a8, 10), (0x8100, 100)]), + dns_packet(true, ipv6, &[(0x88a8, 0xb00a), (0x8100, 0xf064)]), + ]; + for threads in ["1", "6"] { + for fast_path in [false, true] { + assert_eq!( + output_timestamps(&packets, threads, fast_path, false), + [(1_000_000, Some(1_000_100))] + ); + } + } + } +} + +#[test] +fn reassembled_responses_keep_vlan_identity_despite_fragment_qos_changes() { + const IP_OFFSET: usize = 14 + 8; + const IP_HEADER_LEN: usize = 20; + const FIRST_PAYLOAD_LEN: usize = 24; + let query_one = dns_packet(false, false, &[(0x88a8, 10), (0x8100, 100)]); + let query_two = dns_packet(false, false, &[(0x88a8, 20), (0x8100, 100)]); + let response = dns_packet(true, false, &[(0x88a8, 20), (0x8100, 100)]); + let split = IP_OFFSET + IP_HEADER_LEN + FIRST_PAYLOAD_LEN; + let mut first = response[..split].to_vec(); + first[IP_OFFSET + 2..IP_OFFSET + 4] + .copy_from_slice(&((IP_HEADER_LEN + FIRST_PAYLOAD_LEN) as u16).to_be_bytes()); + first[IP_OFFSET + 6..IP_OFFSET + 8].copy_from_slice(&0x2000_u16.to_be_bytes()); + let mut last = response[..IP_OFFSET + IP_HEADER_LEN].to_vec(); + last.extend_from_slice(&response[split..]); + let last_ip_length = (last.len() - IP_OFFSET) as u16; + last[IP_OFFSET + 2..IP_OFFSET + 4].copy_from_slice(&last_ip_length.to_be_bytes()); + last[IP_OFFSET + 6..IP_OFFSET + 8] + .copy_from_slice(&((FIRST_PAYLOAD_LEN / 8) as u16).to_be_bytes()); + last[14..16].copy_from_slice(&0xb014_u16.to_be_bytes()); + last[18..20].copy_from_slice(&0xf064_u16.to_be_bytes()); + let packets = [query_one, query_two, first, last]; + for threads in ["1", "6"] { + for fast_path in [false, true] { + assert_eq!( + output_timestamps(&packets, threads, fast_path, true), + [(1_000_000, None), (1_000_100, Some(1_000_300))] + ); + } + } +} diff --git a/tools/dns-catalog-builder/Cargo.toml b/tools/dns-catalog-builder/Cargo.toml index 4c1fa98..443028e 100644 --- a/tools/dns-catalog-builder/Cargo.toml +++ b/tools/dns-catalog-builder/Cargo.toml @@ -2,7 +2,7 @@ name = "dns-catalog-builder" version = "0.21.0" edition = "2024" -rust-version = "1.97.1" +rust-version = "1.98.1" [dependencies] clap = { version = "4.6", features = ["derive"] } diff --git a/tools/dns-pcap-generator/Cargo.toml b/tools/dns-pcap-generator/Cargo.toml index 9da2c49..6039a97 100644 --- a/tools/dns-pcap-generator/Cargo.toml +++ b/tools/dns-pcap-generator/Cargo.toml @@ -2,7 +2,7 @@ name = "dns-pcap-generator" version = "0.21.0" edition = "2024" -rust-version = "1.97.1" +rust-version = "1.98.1" [dependencies] clap = { version = "4.6", features = ["derive"] } diff --git a/tools/dns-pcap-generator/src/packet.rs b/tools/dns-pcap-generator/src/packet.rs index e2f59d7..9efb928 100644 --- a/tools/dns-pcap-generator/src/packet.rs +++ b/tools/dns-pcap-generator/src/packet.rs @@ -315,11 +315,11 @@ fn root_name_server_target(entropy: u64) -> &'static str { fn internet_checksum(bytes: &[u8]) -> u16 { let mut sum = 0_u32; - let mut chunks = bytes.chunks_exact(2); - for chunk in &mut chunks { - sum += u32::from(u16::from_be_bytes([chunk[0], chunk[1]])); + let (chunks, remainder) = bytes.as_chunks::<2>(); + for chunk in chunks { + sum += u32::from(u16::from_be_bytes(*chunk)); } - if let [last] = chunks.remainder() { + if let [last] = remainder { sum += u32::from(*last) << 8; } while (sum >> 16) != 0 {