diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7c2ec06..ae1026b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -19,6 +19,10 @@ concurrency:
permissions:
contents: read
+env:
+ # cargo-generate-rpm 0.21.0 has prebuilts for both x86_64 and aarch64.
+ PACKAGING_TOOLS: cargo-deb@3.8.0,cargo-generate-rpm@0.21.0
+
jobs:
clippy_check:
name: Run clippy check
@@ -34,15 +38,10 @@ jobs:
components: clippy
- name: Cache cargo + target
- uses: actions/cache@v4
+ uses: Swatinem/rust-cache@v2
with:
- key: clippy-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
- restore-keys: |
- clippy-${{ runner.os }}-
- path: |
- ~/.cargo/registry
- ~/.cargo/git
- ./target
+ workspaces: . -> target
+ key: clippy
# Frontend dist/ must exist for the server's include_dir! to compile.
# Reuses the artifact produced by the `build_frontend` job.
@@ -58,6 +57,7 @@ jobs:
rustfmt:
name: Check style
+ needs: [image_meta]
runs-on: ubuntu-latest
steps:
- name: Checkout
@@ -69,17 +69,37 @@ jobs:
components: rustfmt
- name: Run cargo fmt
- uses: actions-rs/cargo@v1
- with:
- command: fmt
- args: --all -- --check
+ run: cargo fmt --all -- --check
image_meta:
- name: Compute image tag
+ name: Validate version and compute image tag
runs-on: ubuntu-latest
outputs:
image_tag: ${{ steps.vars.outputs.image_tag }}
image_name: ${{ steps.vars.outputs.image_name }}
+ package_version: ${{ steps.version.outputs.package_version }}
steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Validate release version
+ id: version
+ run: |
+ python3 - <<'PY'
+ import os
+ import tomllib
+
+ with open("privaxy/Cargo.toml", "rb") as manifest:
+ version = tomllib.load(manifest)["package"]["version"]
+ ref = os.environ["GITHUB_REF"]
+ if ref.startswith("refs/tags/v"):
+ tag_version = ref.removeprefix("refs/tags/v")
+ if tag_version != version:
+ print(f"::error::Release tag v{tag_version} does not match privaxy/Cargo.toml version {version}")
+ raise SystemExit(1)
+ with open(os.environ["GITHUB_OUTPUT"], "a") as output:
+ print(f"package_version={version}", file=output)
+ PY
+
- name: Get image tag
id: vars
run: |
@@ -135,6 +155,7 @@ jobs:
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
+ if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: ghcr.io
@@ -147,7 +168,7 @@ jobs:
with:
images: ${{ needs.image_meta.outputs.image_name }}
- - name: Build and push by digest
+ - name: Build image
id: build
uses: docker/build-push-action@v6
with:
@@ -161,15 +182,18 @@ jobs:
annotations: ${{ steps.meta.outputs.annotations }}
cache-from: type=gha,scope=${{ matrix.cache_scope }}
cache-to: type=gha,mode=max,scope=${{ matrix.cache_scope }}
- outputs: type=image,name=${{ needs.image_meta.outputs.image_name }},push-by-digest=true,name-canonical=true,push=true
+ # PRs validate the image build without publishing to the registry.
+ outputs: ${{ github.event_name == 'push' && format('type=image,name={0},push-by-digest=true,name-canonical=true,push=true', needs.image_meta.outputs.image_name) || 'type=cacheonly' }}
- name: Export digest
+ if: github.event_name == 'push'
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
+ if: github.event_name == 'push'
uses: actions/upload-artifact@v4
with:
name: image-digest-${{ matrix.cache_scope }}
@@ -180,6 +204,7 @@ jobs:
image_merge:
name: Merge multi-arch manifest
needs: [image_meta, image_build]
+ if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: read
@@ -237,7 +262,7 @@ jobs:
- name: Cache cargo + wasm target
uses: Swatinem/rust-cache@v2
with:
- workspaces: web_frontend -> target
+ workspaces: . -> target
shared-key: build-frontend
- name: Install node
@@ -315,19 +340,17 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
- - name: Cache build artifacts
- uses: actions/cache@v3
- with:
- key: ${{ matrix.os }}-${{ matrix.target }}-artifacts
- path: |
- ./target
- ~/.cargo
-
- name: Install rust
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
+ - name: Cache build artifacts
+ uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: . -> target
+ key: native-${{ matrix.target }}
+
- name: Install cross build dependencies
if: matrix.target == 'aarch64-unknown-linux-gnu'
run: sudo apt-get update && sudo apt-get install -y gcc-aarch64-linux-gnu libc6-dev-arm64-cross
@@ -352,14 +375,21 @@ jobs:
CC_x86_64_unknown_linux_musl: musl-gcc
CC_aarch64_unknown_linux_musl: musl-gcc
run: cargo build --release --target ${{ matrix.target }} --bin privaxy --target-dir target
+
+ - name: Install packaging tools
+ if: ${{ !endsWith(matrix.target, '-musl') }}
+ uses: taiki-e/install-action@v2
+ with:
+ tool: ${{ env.PACKAGING_TOOLS }}
+
- name: Build packages
if: ${{ !endsWith(matrix.target, '-musl') }}
run: |
# target/ is cached; discard packages from previous versions.
rm -f target/${{ matrix.target }}/release/privaxy_*.deb \
target/${{ matrix.target }}/release/privaxy-*.rpm
- cargo install cargo-deb && cargo deb -p privaxy --target ${{ matrix.target }} -o target/${{ matrix.target }}/release
- cargo install cargo-generate-rpm && cargo generate-rpm -p privaxy --target ${{ matrix.target }} -o target/${{ matrix.target }}/release
+ cargo deb --no-build -p privaxy --target ${{ matrix.target }} -o target/${{ matrix.target }}/release
+ cargo generate-rpm -p privaxy --target ${{ matrix.target }} -o target/${{ matrix.target }}/release
- uses: actions/upload-artifact@v4
if: ${{ !endsWith(matrix.target, '-musl') }}
with:
@@ -401,17 +431,15 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
- - name: Cache build artifacts
- uses: actions/cache@v3
- with:
- key: ${{ matrix.os }}-${{ matrix.target }}-artifacts
- path: |
- ./target
- ~/.cargo
-
- name: Install rust
uses: dtolnay/rust-toolchain@nightly
+ - name: Cache build artifacts
+ uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: . -> target
+ key: mips-${{ matrix.target }}-${{ hashFiles('Cross.toml') }}
+
- name: Install cross
uses: taiki-e/install-action@v2
with:
@@ -432,6 +460,12 @@ jobs:
- name: Build server
run: cross build --release --target ${{ matrix.target }} --bin privaxy --target-dir target
+ - name: Install packaging tools
+ if: ${{ endsWith(matrix.target, '-linux-gnu') }}
+ uses: taiki-e/install-action@v2
+ with:
+ tool: ${{ env.PACKAGING_TOOLS }}
+
- name: Build packages
if: ${{ endsWith(matrix.target, '-linux-gnu') }}
run: |
@@ -439,8 +473,8 @@ jobs:
rm -f target/${{ matrix.target }}/release/privaxy_*.deb \
target/${{ matrix.target }}/release/privaxy-*.rpm
variant=$(echo "${{ matrix.target }}" | cut -d- -f1)
- cargo install cargo-deb && cargo deb --no-build --no-strip --variant "$variant" -p privaxy --target ${{ matrix.target }} -o target/${{ matrix.target }}/release
- cargo install cargo-generate-rpm && cargo generate-rpm -p privaxy --target ${{ matrix.target }} -o target/${{ matrix.target }}/release
+ cargo deb --no-build --no-strip --variant "$variant" -p privaxy --target ${{ matrix.target }} -o target/${{ matrix.target }}/release
+ cargo generate-rpm -p privaxy --target ${{ matrix.target }} -o target/${{ matrix.target }}/release
- uses: actions/upload-artifact@v4
if: ${{ endsWith(matrix.target, '-linux-gnu') }}
@@ -462,7 +496,7 @@ jobs:
release:
name: Create GitHub Release
- needs: [ci, ci_mips, image_merge]
+ needs: [image_meta, ci, ci_mips, image_merge]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
permissions:
@@ -477,10 +511,9 @@ jobs:
path: ./artifacts
pattern: privaxy-*
merge-multiple: false
- - name: Set package version from tag
- run: |
- echo "PKG_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
- name: Flatten artifacts for release
+ env:
+ PKG_VERSION: ${{ needs.image_meta.outputs.package_version }}
run: |
mkdir ./release-assets
for dir in ./artifacts/privaxy-*/; do
diff --git a/.gitignore b/.gitignore
index 8ca8437..1da5405 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,4 +7,7 @@ filters/__pycache__
node_modules
.vscode
**/target/*
-testing/
\ No newline at end of file
+testing/
+
+# Local opt-in request history can contain browsing metadata.
+statistics.redb
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c09ce61..f4bbf8f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,86 @@
## Unreleased
+- Add a shared footer with the running server version linked to the project.
+ Check for stable releases automatically when opened and hourly thereafter,
+ showing an "Update available" release link only when a newer version is found.
+ Update checks are authenticated and time-limited, with successful checks cached
+ for one hour and failures for one minute.
+- Added opt-in persistent statistics and individual request history using redb.
+ History includes time ranges, trends, top domains/clients, and pagination, with
+ privacy levels that remove identifiers before collection. URL paths, query
+ strings, headers, and bodies are never saved in statistics.
+- Added Statistics settings for retention, a request-count limit, clearing saved
+ history, and excluding domain patterns from both statistics and the live
+ Requests log. Exclusions do not change filtering behavior and work even when
+ persistence is disabled.
+- Bounded statistics buffering, batched transactional writes, visible storage
+ errors, and shutdown flushing keep storage failures off the proxy request path.
+ Existing installations retain memory-only statistics until explicitly enabled.
+- Show saved statistics directly on the Dashboard when persistence is enabled.
+ With persistence off or Anonymous mode selected, keep the original live,
+ per-process dashboard. Remove the separate History tab and redirect old
+ `/history` bookmarks to `/`. Both layouts retain the blocking toggle and CA
+ certificate download.
+- Live dashboard top blocked entries now use domains and select the top 50 after
+ sorting; client tracking is bounded.
+- Count each modified HTML response once when both head scripts and cosmetic
+ styles are injected, instead of counting the two injection stages separately.
+- Use TLS ClientHello SNI for IP-address CONNECT requests when choosing
+ interception certificates, applying hostname inclusions and exclusions, and
+ verifying upstream TLS. HTTP requests and WebSocket upgrades still connect to
+ the original CONNECT IP and port. Named CONNECT requests retain their existing
+ identity; DoH redirects and userscript fetches keep their own routing.
+- Tunnel CONNECT traffic to port 853 without interception, preserving end-to-end
+ DNS-over-TLS. The DoH policy continues to apply only to HTTP traffic. Explicit
+ CONNECT-host/IP exclusions bypass protocol probing.
+- Bound CONNECT protocol detection, ClientHello reading, and intercepted TLS
+ handshakes to 10 seconds each, with a 64 KiB ClientHello limit. Reject malformed,
+ truncated, oversized, or stalled handshakes while preserving fragmented TLS
+ records and buffered bytes, including when SNI selects a bypass tunnel.
+- Increase the upstream HTTP/2 response header limit from 16 KiB to 64 KiB and
+ allow 64 KiB of headers on intercepted HTTP/2 requests. This fixes 502 stream
+ protocol errors from responses with large CSP or Set-Cookie headers.
+- Advertise HTTP/2 and HTTP/1.1 through ALPN on intercepted TLS connections so
+ browsers can multiplex page resources. Enable adaptive HTTP/2 flow control on
+ those connections and TCP_NODELAY on accepted proxy sockets, tunnel sockets,
+ and upstream upgrade connections to reduce delays for small writes.
+- Preserve known-empty request bodies so the upstream client can retry HTTP/2
+ REFUSED_STREAM and graceful GOAWAY failures. Nonempty or unknown-length bodies
+ continue to stream without buffering or replay.
+- Include underlying DNS, TLS, socket, and HTTP/2 failure causes in proxy 502
+ pages and logs instead of only reporting "error sending request".
+- Improve live statistics, Requests, and log streams with visible connection
+ status, automatic reconnection, and handling for malformed or unexpected
+ frames. Leaving a live view cancels its connection and reconnect attempts.
+- Show retryable loading errors in settings, filter catalogs, API-key loading,
+ and userscript management. A failed sign-in status check now offers a retry
+ instead of incorrectly showing the login form.
+- Fix settings save feedback: acknowledge only successfully submitted values,
+ preserve text drafts after failures and edits made during a save, and disable
+ unchanged Save buttons and duplicate submissions. Ignore stale responses from
+ previously visited text-settings pages, and preserve pending exclusion edits
+ without restoring previously removed hosts.
+- Keep blocking controls at their confirmed state after failed updates, and
+ restore the confirmed Debug settings after failed automatic saves. Lock
+ Network and PAC fields while saving so later edits cannot be marked saved by
+ an earlier response.
+- Refresh installed filter lists after additions and removals while preserving
+ pending selections. Reset catalog pagination when searching, bound Next to
+ matching results, and keep failed removals visible. Built-in lists cannot be
+ removed through the catalog.
+- Make navigation, settings forms, and dialogs fit phone screens. Improve form
+ labels and error/status announcements, and wrap notification text instead of
+ truncating it.
+- Include [RELEASE.md](RELEASE.md) in generated GitHub release notes and discard cached
+ Debian/RPM packages before packaging to avoid publishing stale versions.
+- Keep Docker builds on pull requests while restricting image and manifest
+ publishing to pushes. Validate release tags against the server package version
+ before building, and use that version in release artifact names.
+- Correct the frontend cache path and key Rust caches by toolchain, dependencies,
+ and target. Use pinned prebuilt packaging tools and reuse compiled binaries
+ when creating Debian packages.
+
## v0.8.0 - 2026-10-02
Release highlights and upgrade notes are in [RELEASE.md](RELEASE.md).
diff --git a/Cargo.lock b/Cargo.lock
index 0ffb544..97948af 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2176,6 +2176,7 @@ dependencies = [
"futures",
"futures-util",
"getrandom 0.3.4",
+ "h2",
"hex",
"hmac",
"http 1.4.2",
@@ -2192,9 +2193,11 @@ dependencies = [
"once_cell",
"openssl",
"portable-atomic",
+ "redb",
"regex",
"reqwest",
"rustls",
+ "semver",
"serde",
"serde-tuple-vec-map",
"serde_json",
@@ -2208,6 +2211,7 @@ dependencies = [
"tokio-stream",
"tokio-util",
"toml",
+ "tower-service",
"uluru",
"url",
"warp",
@@ -2393,6 +2397,15 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "redb"
+version = "4.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de6c3b63e007e90ce536ec2ae4690826136a20ec8dbbbb400daef1bb999d2e36"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -3657,6 +3670,7 @@ dependencies = [
"gloo-net 0.6.0",
"gloo-timers 0.4.0",
"gloo-utils 0.3.0",
+ "js-sys",
"log",
"num-format",
"readonly",
diff --git a/Cross.toml b/Cross.toml
index e01eb01..cbfed87 100644
--- a/Cross.toml
+++ b/Cross.toml
@@ -6,10 +6,6 @@ build-std = true
[target.mipsel-unknown-linux-musl]
build-std = true
-[target.mipsel-unknown-linux-musl.env]
-RUSTFLAGS = "-C target-feature=+crt-static"
[target.mips-unknown-linux-musl]
build-std = true
-[target.mips-unknown-linux-musl.env]
-RUSTFLAGS = "-C target-feature=+crt-static"
diff --git a/README.md b/README.md
index 8b1315b..8910da1 100644
--- a/README.md
+++ b/README.md
@@ -33,6 +33,7 @@ features, dependency updates, an improved UI, and server-friendly configuration
- [Cert-pinned hosts (exclusions)](#4-cert-pinned-hosts-exclusions)
- [Filter only selected hosts (inclusions)](#5-filter-only-selected-hosts-inclusions)
- [HTTP CONNECT and proxy chaining](#6-http-connect-and-proxy-chaining)
+ - [Persistent statistics and privacy](#7-persistent-statistics-and-privacy)
- [Screenshots](#screenshots)
- [Acknowledgements](#acknowledgements)
@@ -73,6 +74,7 @@ blocker more than it strictly replaces one.
- Suppport for [Adblock Plus filters](https://adblockplus.org/filter-cheatsheet), such as [easylist](https://easylist.to/).
- Web graphical user interface with a statistics display as well as a live request explorer.
+- Opt-in persistent statistics and request history, with privacy levels, retention limits, and domain exclusions.
- Support for uBlock origin's `js` syntax.
- Support for uBlock origin's `redirect` syntax.
- Support for uBlock origin's scriptlets.
@@ -285,18 +287,43 @@ exclusions used by ordinary proxy clients. No additional Privaxy setting is
needed. This feature supports chains that feed into Privaxy; configuring an
upstream proxy for Privaxy's own outbound traffic is outside its scope.
-The preceding proxy should preserve the destination hostname in CONNECT, which
-Privaxy uses for certificates and domain matching. An IP-only CONNECT for a
-named HTTPS site does not recover the hostname from TLS SNI and can fail
-certificate validation. Clients must trust Privaxy's CA; certificate-pinned
-services still need exclusions. Excluded or unselected hosts are tunneled
-without protocol probing.
+For an IP CONNECT (including resolved destinations from sing-box/TUN clients),
+Privaxy reads TLS ClientHello SNI before choosing an interception certificate
+or applying hostname inclusions and exclusions. A DNS SNI hostname supplies
+the certificate's DNS SAN, certificate cache identity, HTTP filtering identity,
+and upstream TLS SNI/verification name. The upstream connection still goes to
+the original CONNECT IP and port. No sing-box DNS or LAN routing changes are
+needed. Named CONNECT requests retain their existing behavior.
+
+Without usable DNS SNI, Privaxy retains the CONNECT identity: an intercepted
+literal IP receives an IP SAN, which cannot authenticate a hidden DNS hostname.
+Encrypted ClientHello (ECH) does not reveal its inner hostname to Privaxy.
+Malformed, truncated, oversized (over 64 KiB), or stalled ClientHellos fail
+closed; protocol detection, ClientHello reading, and the intercepted TLS
+handshake each have a 10-second timeout. Fragmented ClientHellos and buffered
+bytes are preserved, including when SNI selects a raw tunnel.
+
+Clients must still trust the existing Privaxy CA; certificate-pinned services
+need exclusions. Explicit CONNECT-host/IP exclusions bypass protocol probing.
+For other IP CONNECTs, hostname rules (including include-only mode) are decided
+after reading SNI. Exclusions take precedence over inclusions.
+
+CONNECT to port **853** is an opaque TCP tunnel for DNS-over-TLS, including
+`9.9.9.9:853` with SNI `dns.quad9.net`. TLS stays end-to-end and decrypted DNS
+never enters the HTTP parser. Privaxy's DoH policy applies only to HTTP traffic;
+it does not filter DoT. Non-HTTP TLS on other ports requires an exclusion.
Filtering covers TCP HTTP/HTTPS. Handle UDP/QUIC and non-HTTP protocols in your
routing policy; HTTP/3 traffic allowed directly will bypass filtering.
Privaxy's client statistics identify the connecting proxy's address, so devices
sharing that address appear together.
+Local TLS regression tests can be run with
+`cargo test -p privaxy --lib proxy::mitm::connect_tests`. They create temporary
+trusted test CAs in memory and use loopback fixtures, with no production config
+or external services. The DoT exchange uses an unprivileged ephemeral port;
+port 853's automatic tunnel selection is tested separately.
+
#### Example: tun2proxy
[tun2proxy](https://github.com/tun2proxy/tun2proxy) is one adapter that can send
@@ -319,6 +346,87 @@ for those steps.
> `password_hash` value from the config file and restart. The web UI will
> force the setup flow again.
+### 7. Persistent statistics and privacy
+
+Enable collection under **Settings → Statistics** to show saved totals,
+hourly/daily trends, top domains/clients, and a paginated request log directly
+on the **Dashboard**. With persistence off or Anonymous mode selected, the
+Dashboard keeps its original layout with the current process's live counters.
+Old `/history` bookmarks redirect to the Dashboard.
+Persistence is **off by default**; enabling it starts collecting new activity,
+without importing the live Requests feed.
+
+History records timestamps, destination hostnames, client IP addresses, and
+whether a request was blocked or successfully forwarded. It never stores URL
+paths, query strings, headers, cookies, or request/response bodies. Raw CONNECT
+tunnels and requests that fail before being forwarded are not counted. Clients
+behind another proxy share that proxy's client address.
+
+The privacy levels follow [Pi-hole's approach](https://docs.pi-hole.net/ftldns/privacylevels/):
+
+| Level | Saved identifiers |
+| --- | --- |
+| Show domains and clients | Hostname and client IP |
+| Hide domains | Client IP only |
+| Hide domains and clients (default) | Neither; request times and outcomes remain |
+| Anonymous | No new history; session counters only |
+
+Hidden identifiers are removed **before queueing or storage**, without retaining
+a reversible mapping or a hash. While collection is enabled, raising privacy
+also redacts identifiers in existing history, including when entering anonymous
+mode. Lowering privacy cannot restore them. These controls apply to statistics;
+the separate live Requests feed and diagnostic logs can still contain full URLs.
+
+**Exclude domains from statistics and request logs** skips matching new traffic
+in session counters, modified-response counts, saved history, and the live
+Requests feed, even with persistence disabled. Filtering and interception still
+apply. Use one case-insensitive hostname or wildcard pattern per line:
+`example.com` matches exactly; `*.example.com` matches its subdomains. Do not
+include schemes, ports, or paths. Existing history and diagnostic logs are
+unaffected.
+
+Defaults are **7 days** and **100,000 saved requests**. The oldest records expire
+when either limit is reached; valid limits are 1–365 days and 100–1,000,000
+requests. History totals and top lists describe retained requests, so they can
+decrease as older entries expire. Modified-response totals are separate hourly
+aggregates retained by age. Time windows include the current hour and preceding
+hours. **Clear saved history** removes the database and starts fresh if enabled.
+Turning collection off stops recording and closes the database; it retains the
+existing file until cleared or collection is re-enabled and retention runs.
+
+The equivalent configuration section is:
+
+```toml
+[statistics]
+enabled = false
+privacy = "hide_domains_and_clients" # full, hide_domains, hide_domains_and_clients, anonymous
+retention_days = 7
+max_entries = 100000
+excluded_domains = ["example.com", "*.example.com"]
+```
+
+UI changes apply immediately; file edits also apply on SIGHUP. Data stays local
+in `statistics.redb` under `PRIVAXY_BASE_PATH` (`/etc/privaxy` by default, `/conf`
+in Docker). Keep `/conf` on a persistent Docker volume for history to survive
+container replacement. No new ports or external database service are required.
+On Unix, the database is restricted to its owner (`0600`). Storage is not
+encrypted, and redaction/deletion is not secure erasure of filesystem snapshots,
+backups, or recoverable disk pages.
+
+[redb](https://github.com/cberner/redb) transactions run on a dedicated worker,
+with a bounded queue and batches flushed every five seconds or 512 events.
+SIGTERM/SIGINT also flush accepted events; an abrupt kill can lose the pending
+batch. Expired rows are pruned every minute and free space is compacted hourly;
+the row limit bounds retained data, not an exact file size. Disk/write errors
+pause recording and appear in Statistics settings without stopping the proxy.
+Free space and save settings again to retry. Queue overflow drops statistics
+events and reports their count instead of delaying proxy requests.
+
+The authenticated APIs are `GET/PUT /api/settings/statistics` and
+`GET/DELETE /api/history`. History accepts `hours`, `limit` (1–200), `before`
+(the previous response's `next_before` cursor), and `blocked=true|false`.
+Pagination and the blocked filter affect request rows, not the window's totals.
+
## Screenshots
diff --git a/privaxy/Cargo.toml b/privaxy/Cargo.toml
index 4fae88e..56a9b79 100644
--- a/privaxy/Cargo.toml
+++ b/privaxy/Cargo.toml
@@ -3,6 +3,7 @@ name = "privaxy"
description = "Next generation tracker and advertisement blocker"
version = "0.8.0"
edition = "2021"
+repository = "https://github.com/joshrmcdaniel/privaxy"
authors = [
"Pierre Barre
",
"Josh McDaniel <80354972+joshrmcdaniel@users.noreply.github.com>"
@@ -64,10 +65,12 @@ hyper-util = { version = "0.1", features = [
http-body-util = "0.1"
tokio = { version = "1.22.0", features = ["full"] }
serde_json = "1.0.89"
+semver = "1"
toml = "1"
serde = { version = "1.0.148", features = ["derive"] }
tokio-util = { version = "0.7.4", features = ["full"] }
tokio-stream = "0.1"
+tower-service = "0.3"
bytes = "1"
socket2 = "0.5"
# default-features = false only to drop `single-thread`: the multi-thread
@@ -137,6 +140,13 @@ tera = { version = "1", default-features = false }
argon2 = { version = "0.5", features = ["std"] }
hmac = "0.12"
filterlists-api = { path = "../filterlists-api", features = ["reqwest"] }
+# redb 4.3+ unconditionally imports libc::flock64 on GNU/Linux, but libc
+# does not expose that type on MIPS. Keep the last compatible release until
+# upstream fixes the range-lock backend; 4.2 retains the v3 database format.
+redb = "=4.2.0"
+
+[dev-dependencies]
+h2 = "0.4"
# getrandom 0.3 (pulled in via warp -> tokio-tungstenite -> rand) selects a
# backend that calls the libc `getrandom()` wrapper on mips{,el}-unknown-linux-gnu,
diff --git a/privaxy/src/server/cert.rs b/privaxy/src/server/cert.rs
index 0acc140..afb857b 100644
--- a/privaxy/src/server/cert.rs
+++ b/privaxy/src/server/cert.rs
@@ -49,10 +49,13 @@ impl SignedWithCaCert {
// (ring) is installed once as the process default at startup.
let private_key =
PrivateKeyDer::try_from(private_key.private_key_to_der().unwrap()).unwrap();
- let server_configuration = ServerConfig::builder()
+ let mut server_configuration = ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, private_key)
.unwrap();
+ // The CONNECT service supports both versions. Advertise HTTP/2 so
+ // browsers can multiplex page resources on one intercepted session.
+ server_configuration.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
Self {
authority,
@@ -218,6 +221,9 @@ impl CertCache {
cache.insert(certificate);
}
+ /// `authority` is the TLS identity (DNS SNI for an IP CONNECT), not
+ /// necessarily the socket destination. Cache and SAN generation must use
+ /// that same identity so virtual hosts sharing an IP cannot share a leaf.
pub async fn get(&self, authority: Authority) -> SignedWithCaCert {
let mut cache = self.cache.lock().await;
diff --git a/privaxy/src/server/configuration/mod.rs b/privaxy/src/server/configuration/mod.rs
index 1cac41a..faeb422 100644
--- a/privaxy/src/server/configuration/mod.rs
+++ b/privaxy/src/server/configuration/mod.rs
@@ -7,6 +7,7 @@ mod ca;
mod filter;
mod filter_failures;
mod network;
+mod statistics;
mod updater;
mod userscript;
pub use auth::*;
@@ -15,6 +16,7 @@ pub use filter::*;
pub use filter_failures::*;
use futures::future::join_all;
pub use network::*;
+pub use statistics::*;
use std::env;
use std::path::{Path, PathBuf};
pub use updater::*;
@@ -85,6 +87,8 @@ pub struct Configuration {
pub auth: Auth,
#[serde(default)]
pub debug: DebugConfig,
+ #[serde(default)]
+ pub statistics: StatisticsConfig,
/// Userscripts and the master switch governing them. Absent from
/// configuration files written before the feature existed, hence the serde
/// default.
@@ -588,6 +592,7 @@ impl Configuration {
custom_filters: Vec::new(),
auth: Auth::new_initialized(),
debug: DebugConfig::default(),
+ statistics: StatisticsConfig::default(),
userscripts: UserScriptsConfig::default(),
})
}
diff --git a/privaxy/src/server/configuration/statistics.rs b/privaxy/src/server/configuration/statistics.rs
new file mode 100644
index 0000000..9f93c39
--- /dev/null
+++ b/privaxy/src/server/configuration/statistics.rs
@@ -0,0 +1,62 @@
+use serde::{Deserialize, Serialize};
+
+/// These levels describe collected data, not just what the UI displays.
+#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
+#[serde(rename_all = "snake_case")]
+pub enum StatisticsPrivacy {
+ Full,
+ HideDomains,
+ #[default]
+ HideDomainsAndClients,
+ Anonymous,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
+#[serde(default, deny_unknown_fields)]
+pub struct StatisticsConfig {
+ pub enabled: bool,
+ pub privacy: StatisticsPrivacy,
+ pub retention_days: u16,
+ pub max_entries: u32,
+ pub excluded_domains: Vec,
+}
+
+impl Default for StatisticsConfig {
+ fn default() -> Self {
+ Self {
+ enabled: false,
+ privacy: StatisticsPrivacy::default(),
+ retention_days: 7,
+ max_entries: 100_000,
+ excluded_domains: Vec::new(),
+ }
+ }
+}
+
+impl StatisticsConfig {
+ pub fn validate(&self) -> Result<(), String> {
+ if self.excluded_domains.len() > 1000
+ || self.excluded_domains.iter().any(|pattern| {
+ let pattern = pattern.trim();
+ pattern.is_empty()
+ || pattern.len() > 253
+ || !pattern
+ .bytes()
+ .all(|b| b.is_ascii_alphanumeric() || b".-*?".contains(&b))
+ })
+ {
+ return Err("Use at most 1,000 domain patterns, without schemes, ports, or paths (for example example.com or *.example.com)".into());
+ }
+ if !(1..=365).contains(&self.retention_days) {
+ return Err("Statistics retention must be between 1 and 365 days".into());
+ }
+ if !(100..=1_000_000).contains(&self.max_entries) {
+ return Err("Request history limit must be between 100 and 1,000,000 entries".into());
+ }
+ Ok(())
+ }
+
+ pub fn persists(&self) -> bool {
+ self.enabled && self.privacy != StatisticsPrivacy::Anonymous
+ }
+}
diff --git a/privaxy/src/server/lib.rs b/privaxy/src/server/lib.rs
index 7e32955..747df5b 100644
--- a/privaxy/src/server/lib.rs
+++ b/privaxy/src/server/lib.rs
@@ -3,17 +3,15 @@ use crate::configuration::{FilterFailureStore, NetworkConfig};
use crate::proxy::exclusions::LocalExclusionStore;
use crate::web_gui::events::Event;
use hyper::service::service_fn;
-use hyper_util::client::legacy::Client;
use hyper_util::rt::{TokioExecutor, TokioIo};
use hyper_util::server::conn::auto;
use hyper_util::server::graceful::GracefulShutdown;
use include_dir::{include_dir, Dir};
use proxy::exclusions;
use proxy::gm::storage::GmStorageStore;
-use proxy::serve::UpgradeClient;
use proxy::tls_failures::TlsFailureStore;
+use proxy::upstream::{http_client_builder, UpstreamClients};
use proxy::userscripts::{PrivateNetworkAccess, UserScriptContext, UserScriptStore};
-use reqwest::redirect::Policy;
use std::env;
use std::net::IpAddr;
use std::net::SocketAddr;
@@ -104,7 +102,7 @@ pub(crate) fn parse_ip_address(ip_str: &str) -> IpAddr {
IpAddr::from_str(ip_str).unwrap()
}
-async fn handle_signals() -> (Arc, Arc) {
+async fn handle_signals(statistics: statistics::Statistics) -> (Arc, Arc) {
let notify_shutdown = Arc::new(Notify::new());
let notify_reload = Arc::new(Notify::new());
let notify_shutdown_clone = notify_shutdown.clone();
@@ -115,6 +113,8 @@ async fn handle_signals() -> (Arc, Arc) {
signal(SignalKind::hangup()).expect("failed to set up SIGHUP signal handler");
let mut term_signal =
signal(SignalKind::terminate()).expect("failed to set up SIGTERM signal handler");
+ let mut int_signal =
+ signal(SignalKind::interrupt()).expect("failed to set up SIGINT signal handler");
loop {
tokio::select! {
@@ -122,9 +122,16 @@ async fn handle_signals() -> (Arc, Arc) {
log::info!("Received SIGHUP signal, restarting child processes...");
notify_reload_clone.notify_waiters();
}
- _ = term_signal.recv() => {
- log::info!("Received SIGTERM signal, shutting down gracefully...");
+ _ = async { tokio::select! { _ = term_signal.recv() => {}, _ = int_signal.recv() => {} } } => {
+ log::info!("Shutting down; flushing persistent statistics...");
notify_shutdown_clone.notify_waiters();
+ if let Some(history) = &statistics.history {
+ match tokio::time::timeout(Duration::from_secs(8), history.shutdown()).await {
+ Ok(Ok(())) => {},
+ Ok(Err(error)) => log::error!("Could not flush statistics: {error}"),
+ Err(_) => log::error!("Timed out flushing statistics"),
+ }
+ }
std::process::exit(0);
}
}
@@ -149,38 +156,7 @@ pub async fn start_privaxy() -> PrivaxyServer {
// We use reqwest instead of hyper's client to perform most of the proxying as it's more convenient
// to handle compression as well as offers a more convenient interface.
- let client = reqwest::Client::builder()
- .use_rustls_tls()
- .redirect(Policy::none())
- .no_proxy()
- .gzip(true)
- .brotli(true)
- .deflate(true)
- // Without these, a proxied request can hang indefinitely on a pooled
- // keep-alive connection the remote has silently dropped: reqwest reuses
- // the dead connection and waits on a peer that will never answer.
- // Retiring idle connections quickly (well under typical server keep-alive
- // windows) plus OS-level keepalive probes bounds that. `connect_timeout`
- // additionally fails fast on unreachable hosts.
- .connect_timeout(Duration::from_secs(10))
- .pool_idle_timeout(Duration::from_secs(30))
- // Bounds a stalled read mid-response: a peer that stops sending without
- // closing would otherwise hang the proxied request forever. Generous
- // enough (5 minutes between reads) not to kill long-polls or quiet SSE
- // streams, which routinely idle for a minute or two between events.
- .read_timeout(Duration::from_secs(300))
- // h2-heavy origins multiplex every subresource over a
- // single connection whose flow-control window defaults to a small,
- // shared 64 KB. When we drain one stream's body slowly (the browser
- // reads slowly, or the HTML rewriter backpressures), that window fills
- // and stalls *every other stream* on the connection — head-of-line
- // stutter across the whole site. Adaptive flow control grows the
- // stream/connection windows based on the bandwidth-delay product,
- // relieving the stall while keeping multiplexing. (This overrides any
- // manual http2_initial_*_window_size, which is why none are set.)
- .http2_adaptive_window(true)
- .build()
- .unwrap();
+ let client = http_client_builder().build().unwrap();
let configuration = match configuration::Configuration::read_from_home().await {
Ok(configuration) => configuration,
@@ -220,7 +196,7 @@ pub async fn start_privaxy() -> PrivaxyServer {
}
};
- let statistics = statistics::Statistics::new();
+ let statistics = statistics::Statistics::with_history(configuration.statistics.clone()).await;
let statistics_clone = statistics.clone();
let tls_failure_store = TlsFailureStore::new(configuration.ignored_tls_failures.clone());
@@ -272,7 +248,7 @@ pub async fn start_privaxy() -> PrivaxyServer {
let configuration_save_lock = Arc::new(tokio::sync::Mutex::new(()));
- let (_notify_shutdown, notify_reload) = handle_signals().await;
+ let (_notify_shutdown, notify_reload) = handle_signals(statistics.clone()).await;
let block_disable_ref = blocking_disabled_store.clone();
let local_exclusion_store_ref = local_exclusion_store.clone();
@@ -361,6 +337,19 @@ pub async fn start_privaxy() -> PrivaxyServer {
// The UI updates this store live; SIGHUP must also pick up file
// edits to the mode and both inclusion/exclusion lists.
local_exclusion_store.replace_configuration(&cfg);
+ {
+ // Serialize this re-read/apply with the settings API. A reload
+ // must not restore a stale privacy policy over a concurrent save.
+ let _guard = cfg_lock_backend.lock().await;
+ match configuration::Configuration::read_from_home().await {
+ Ok(current) => {
+ if let Err(error) = statistics.configure_history(current.statistics).await {
+ log::error!("Could not apply statistics settings: {error}");
+ }
+ }
+ Err(error) => log::error!("Could not reload statistics settings: {error}"),
+ }
+ }
// Same for the TLS-failure ignore set: pick up hand-edited
// `ignored_tls_failures` entries on SIGHUP reload.
tls_failure_store.set_ignored(cfg.ignored_tls_failures.clone());
@@ -742,21 +731,7 @@ async fn privaxy_backend(
configuration_save_lock: Arc>,
notify_reload: Arc,
) {
- // Mirror the reqwest client's connection hardening (see above): without a
- // connect timeout and OS-level keepalive, an upgrade can hang on a pooled
- // keep-alive connection the remote has silently dropped, surfacing as
- // "upgrade expected but not completed".
- let mut http_connector = hyper_util::client::legacy::connect::HttpConnector::new();
- http_connector.enforce_http(false);
- http_connector.set_connect_timeout(Some(Duration::from_secs(10)));
- http_connector.set_keepalive(Some(Duration::from_secs(60)));
-
- let https_connector = hyper_rustls::HttpsConnectorBuilder::new()
- .with_native_roots()
- .expect("failed to load native root certificates")
- .https_or_http()
- .enable_http1()
- .wrap_connector(http_connector);
+ let clients = UpstreamClients::new(client).expect("failed to load native root certificates");
let config = match read_configuration(&configuration_save_lock).await {
Ok(config) => config,
Err(err) => {
@@ -785,17 +760,6 @@ async fn privaxy_backend(
allow_private_network_requests: private_network_access,
};
- // The hyper client is only used to perform upgrades. We don't need to
- // handle compression.
- // Hyper's client don't follow redirects, which is what we want, nothing to
- // disable here.
- // An upgraded connection is consumed by the tunnel anyway, so idle pooling
- // buys nothing and only risks reusing a stale connection under a long-lived
- // WebSocket — disable it.
- let hyper_client: UpgradeClient = Client::builder(TokioExecutor::new())
- .pool_max_idle_per_host(0)
- .build(https_connector);
-
let ip = env_or_config_ip(network_config).await;
let proxy_server_addr = SocketAddr::from((ip, network_config.proxy_port));
@@ -835,6 +799,9 @@ async fn privaxy_backend(
}
};
+ // Flush small TLS records and streamed response chunks promptly;
+ // Nagle plus delayed ACKs otherwise introduces avoidable stalls.
+ let _ = stream.set_nodelay(true);
// Reproduce the old `Server::tcp_keepalive(600s)`: enable
// OS-level keepalive on each accepted socket.
let _ = socket2::SockRef::from(&stream).set_tcp_keepalive(
@@ -842,8 +809,7 @@ async fn privaxy_backend(
);
let client_ip_address = peer_addr.ip();
- let client = client.clone();
- let hyper_client = hyper_client.clone();
+ let clients = clients.clone();
let cert_cache = cert_cache.clone();
let blocker_requester = blocker_requester.clone();
let broadcast_tx = broadcast_tx.clone();
@@ -861,8 +827,7 @@ async fn privaxy_backend(
let service = service_fn(move |req| {
proxy::serve_mitm_session(
blocker_requester.clone(),
- hyper_client.clone(),
- client.clone(),
+ clients.clone(),
req,
cert_cache.clone(),
broadcast_tx.clone(),
diff --git a/privaxy/src/server/proxy/client_hello.rs b/privaxy/src/server/proxy/client_hello.rs
new file mode 100644
index 0000000..d8fc582
--- /dev/null
+++ b/privaxy/src/server/proxy/client_hello.rs
@@ -0,0 +1,47 @@
+//! Read the ClientHello before choosing an interception policy or certificate.
+use std::io;
+use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, BufReader};
+use tokio_rustls::StartHandshake;
+
+// Bound both the TLS parser's input and the copy retained for a possible tunnel.
+const MAX_CLIENT_HELLO_BYTES: usize = 64 * 1024;
+pub(super) const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
+
+pub(super) async fn read_client_hello(
+ mut stream: BufReader,
+) -> io::Result<(StartHandshake>, Vec)>
+where
+ IO: AsyncRead + AsyncWrite + Unpin,
+{
+ let mut acceptor = rustls::server::Acceptor::default();
+ let mut prefix = Vec::new();
+ loop {
+ let remaining = MAX_CLIENT_HELLO_BYTES - prefix.len();
+ if remaining == 0 {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ "ClientHello too large",
+ ));
+ }
+ let buffered = stream.fill_buf().await?;
+ let available = &buffered[..buffered.len().min(remaining)];
+ let consumed = acceptor.read_tls(&mut &available[..])?;
+ if consumed == 0 {
+ return Err(io::ErrorKind::UnexpectedEof.into());
+ }
+ prefix.extend_from_slice(&available[..consumed]);
+ stream.consume(consumed);
+ match acceptor.accept() {
+ Ok(Some(accepted)) => {
+ // rustls retains the parsed handshake for interception. For a
+ // bypass, replay exactly the consumed TLS bytes, then drain
+ // BufReader (including hyper's CONNECT read-ahead) normally.
+ return Ok((StartHandshake::from_parts(accepted, stream), prefix));
+ }
+ Ok(None) => {} // TCP and TLS-record fragmentation are both valid.
+ Err((error, _alert)) => {
+ return Err(io::Error::new(io::ErrorKind::InvalidData, error));
+ }
+ }
+ }
+}
diff --git a/privaxy/src/server/proxy/connect_tests.rs b/privaxy/src/server/proxy/connect_tests.rs
new file mode 100644
index 0000000..cda8fdf
--- /dev/null
+++ b/privaxy/src/server/proxy/connect_tests.rs
@@ -0,0 +1,1164 @@
+//! Local CONNECT/TLS regressions. All keys and trusted roots are ephemeral;
+//! these fixtures never load configuration, production CAs or public services.
+use super::*;
+use crate::blocker::BlockingDisabledStore;
+use crate::proxy::{gm::storage::GmStorageStore, userscripts::UserScriptStore};
+use http_body_util::BodyExt;
+use openssl::{
+ asn1::Asn1Time,
+ bn::{BigNum, MsbOption},
+ hash::MessageDigest,
+ pkey::{PKey, Private},
+ rsa::Rsa,
+ x509::{
+ extension::{BasicConstraints, ExtendedKeyUsage, SubjectAlternativeName},
+ X509NameBuilder, X509,
+ },
+};
+use rustls::{
+ pki_types::{CertificateDer, PrivateKeyDer, ServerName},
+ ClientConfig, ServerConfig,
+};
+use std::{
+ collections::BTreeMap,
+ io,
+ sync::{OnceLock, RwLock},
+ time::Duration,
+};
+use tokio::{
+ net::TcpListener,
+ sync::mpsc,
+ task::{JoinHandle, JoinSet},
+ time::timeout,
+};
+use tokio_rustls::{client::TlsStream, LazyConfigAcceptor, TlsConnector};
+
+const TEST_TIMEOUT: Duration = Duration::from_secs(15);
+
+struct Pki {
+ proxy: (X509, PKey),
+ upstream: (X509, PKey),
+ untrusted: (X509, PKey),
+ leaf_key: PKey,
+}
+
+fn pki() -> &'static Pki {
+ static PKI: OnceLock = OnceLock::new();
+ PKI.get_or_init(|| {
+ let _ = rustls::crypto::ring::default_provider().install_default();
+ Pki {
+ proxy: crate::ca::make_ca_certificate(),
+ upstream: crate::ca::make_ca_certificate(),
+ untrusted: crate::ca::make_ca_certificate(),
+ leaf_key: PKey::from_rsa(Rsa::generate(2048).unwrap()).unwrap(),
+ }
+ })
+}
+
+fn der(cert: &X509) -> CertificateDer<'static> {
+ cert.to_der().unwrap().into()
+}
+
+// Independent origin certificates: don't use Privaxy's leaf generator for
+// both sides of a hostname-verification regression.
+fn origin_config(host: &str, trusted: bool, expired: bool) -> Arc {
+ let pki = pki();
+ let (ca, key) = if trusted {
+ &pki.upstream
+ } else {
+ &pki.untrusted
+ };
+ let mut cert = X509::builder().unwrap();
+ cert.set_version(2).unwrap();
+ let mut serial = BigNum::new().unwrap();
+ serial.rand(128, MsbOption::MAYBE_ZERO, false).unwrap();
+ cert.set_serial_number(&serial.to_asn1_integer().unwrap())
+ .unwrap();
+ let mut subject = X509NameBuilder::new().unwrap();
+ subject.append_entry_by_text("CN", host).unwrap();
+ cert.set_subject_name(&subject.build()).unwrap();
+ cert.set_issuer_name(ca.subject_name()).unwrap();
+ cert.set_pubkey(&pki.leaf_key).unwrap();
+ let (start, end) = if expired {
+ (
+ Asn1Time::from_unix(1).unwrap(),
+ Asn1Time::from_unix(2).unwrap(),
+ )
+ } else {
+ (
+ Asn1Time::days_from_now(0).unwrap(),
+ Asn1Time::days_from_now(1).unwrap(),
+ )
+ };
+ cert.set_not_before(&start).unwrap();
+ cert.set_not_after(&end).unwrap();
+ cert.append_extension(BasicConstraints::new().build().unwrap())
+ .unwrap();
+ cert.append_extension(ExtendedKeyUsage::new().server_auth().build().unwrap())
+ .unwrap();
+ let mut san = SubjectAlternativeName::new();
+ if host.parse::().is_ok() {
+ san.ip(host);
+ } else {
+ san.dns(host);
+ }
+ cert.append_extension(san.build(&cert.x509v3_context(Some(ca), None)).unwrap())
+ .unwrap();
+ cert.sign(key, MessageDigest::sha256()).unwrap();
+ let config = ServerConfig::builder()
+ .with_no_client_auth()
+ .with_single_cert(
+ vec![der(&cert.build()), der(ca)],
+ PrivateKeyDer::try_from(pki.leaf_key.private_key_to_der().unwrap()).unwrap(),
+ )
+ .unwrap();
+ Arc::new(config)
+}
+
+fn client_config(proxy: bool, sni: bool) -> Arc {
+ let mut roots = rustls::RootCertStore::empty();
+ roots
+ .add(der(if proxy {
+ &pki().proxy.0
+ } else {
+ &pki().upstream.0
+ }))
+ .unwrap();
+ let mut config = ClientConfig::builder()
+ .with_root_certificates(roots)
+ .with_no_client_auth();
+ // Disabling SNI does not disable certificate hostname/IP verification.
+ config.enable_sni = sni;
+ Arc::new(config)
+}
+
+struct Task(JoinHandle<()>);
+impl Drop for Task {
+ fn drop(&mut self) {
+ self.0.abort();
+ }
+}
+
+struct Proxy {
+ addr: SocketAddr,
+ _task: Task,
+}
+
+impl Proxy {
+ async fn start(policy: LocalExclusionStore) -> Self {
+ Self::with_doh(policy, DohConfig::default()).await
+ }
+
+ async fn with_doh(policy: LocalExclusionStore, doh_config: DohConfig) -> Self {
+ let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let addr = listener.local_addr().unwrap();
+ let clients = UpstreamClients::with_test_roots(&[der(&pki().upstream.0)]);
+ let cache = CertCache::new(pki().proxy.0.clone(), pki().proxy.1.clone());
+ let blocker = AdblockRequester::new(BlockingDisabledStore(Arc::new(RwLock::new(false))));
+ let (events, _) = broadcast::channel(4);
+ let task = tokio::spawn(async move {
+ let mut sessions = JoinSet::new();
+ while let Ok((stream, peer)) = listener.accept().await {
+ let clients = clients.clone();
+ let cache = cache.clone();
+ let blocker = blocker.clone();
+ let events = events.clone();
+ let policy = policy.clone();
+ let doh_config = doh_config.clone();
+ sessions.spawn(async move {
+ let service = service_fn(move |request| {
+ serve_mitm_session(
+ blocker.clone(),
+ clients.clone(),
+ request,
+ cache.clone(),
+ events.clone(),
+ Statistics::new(),
+ peer.ip(),
+ policy.clone(),
+ doh_config.clone(),
+ false,
+ TlsFailureStore::new(Default::default()),
+ None,
+ UserScriptContext {
+ store: UserScriptStore::default(),
+ gm_storage: GmStorageStore::in_memory(),
+ endpoint_signing_key: "fixture-only".into(),
+ allow_private_network_requests: Default::default(),
+ },
+ )
+ });
+ let _ = auto::Builder::new(TokioExecutor::new())
+ .serve_connection_with_upgrades(TokioIo::new(stream), service)
+ .await;
+ });
+ }
+ });
+ Self {
+ addr,
+ _task: Task(task),
+ }
+ }
+
+ async fn connect(
+ &self,
+ destination: &str,
+ config: Arc,
+ name: &str,
+ ) -> TlsStream {
+ let mut tcp = TcpStream::connect(self.addr).await.unwrap();
+ tcp.write_all(
+ format!("CONNECT {destination} HTTP/1.1\r\nHost: {destination}\r\n\r\n").as_bytes(),
+ )
+ .await
+ .unwrap();
+ assert!(read_headers(&mut tcp)
+ .await
+ .unwrap()
+ .starts_with(b"HTTP/1.1 200"));
+ timeout(
+ TEST_TIMEOUT,
+ TlsConnector::from(config).connect(ServerName::try_from(name.to_owned()).unwrap(), tcp),
+ )
+ .await
+ .unwrap()
+ .unwrap()
+ }
+}
+
+// Read exactly through the header terminator, retaining TLS/DNS bytes that
+// might arrive in the same TCP packet.
+async fn read_headers(stream: &mut (impl AsyncRead + Unpin)) -> io::Result> {
+ let mut bytes = Vec::new();
+ while !bytes.ends_with(b"\r\n\r\n") {
+ bytes.push(stream.read_u8().await?);
+ assert!(bytes.len() < 32 * 1024);
+ }
+ Ok(bytes)
+}
+
+#[derive(Clone, Copy)]
+enum Protocol {
+ Http,
+ HttpKeepAlive,
+ HttpWaitForPeer,
+ Http2RefuseOnce,
+ Http2ResponseHeaders(usize),
+ Dns,
+ Upgrade,
+}
+
+struct Origin {
+ addr: SocketAddr,
+ requests: mpsc::UnboundedReceiver<(Option, Vec)>,
+ _task: Task,
+}
+
+impl Origin {
+ async fn start(configs: Vec<(&str, Arc)>, protocol: Protocol) -> Self {
+ Self::bind("127.0.0.1:0", configs, protocol).await
+ }
+
+ async fn bind(
+ address: &str,
+ configs: Vec<(&str, Arc)>,
+ protocol: Protocol,
+ ) -> Self {
+ let listener = TcpListener::bind(address).await.unwrap();
+ let addr = listener.local_addr().unwrap();
+ let fallback = configs[0].1.clone();
+ let configs: BTreeMap<_, _> = configs
+ .into_iter()
+ .map(|(name, config)| (name.to_owned(), config))
+ .collect();
+ let (tx, requests) = mpsc::unbounded_channel();
+ let response_gate = Arc::new(tokio::sync::Notify::new());
+ let task = tokio::spawn(async move {
+ let mut sessions = JoinSet::new();
+ while let Ok((tcp, _)) = listener.accept().await {
+ let configs = configs.clone();
+ let fallback = fallback.clone();
+ let tx = tx.clone();
+ let response_gate = response_gate.clone();
+ sessions.spawn(async move {
+ let Ok(hello) = LazyConfigAcceptor::new(Default::default(), tcp).await else { return; };
+ let name = hello.client_hello().server_name().map(str::to_owned);
+ let config = name.as_ref().and_then(|name| configs.get(name)).unwrap_or(&fallback).clone();
+ let Ok(mut tls) = hello.into_stream(config).await else { return; };
+ match protocol {
+ Protocol::Http | Protocol::HttpKeepAlive | Protocol::HttpWaitForPeer => {
+ let keep_alive = matches!(protocol, Protocol::HttpKeepAlive);
+ loop {
+ let Ok(request) = read_headers(&mut tls).await else { return; };
+ let slow = request.starts_with(b"GET /slow ");
+ let fast = request.starts_with(b"GET /fast ");
+ tx.send((name.clone(), request)).unwrap();
+ if matches!(protocol, Protocol::HttpWaitForPeer) {
+ if slow {
+ response_gate.notified().await;
+ } else if fast {
+ response_gate.notify_one();
+ }
+ }
+ let body = format!("upstream:{}", name.as_deref().unwrap_or("no-sni"));
+ let connection = if keep_alive { "keep-alive" } else { "close" };
+ tls.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: {connection}\r\n\r\n{body}", body.len()).as_bytes()).await.unwrap();
+ if !keep_alive { break; }
+ }
+ }
+ Protocol::Dns => {
+ // Two actual DNS-over-TCP frames, including their
+ // length prefixes and binary DNS response headers.
+ for _ in 0..2 {
+ let length = tls.read_u16().await.unwrap();
+ let mut query = vec![0; length as usize];
+ tls.read_exact(&mut query).await.unwrap();
+ tx.send((name.clone(), query.clone())).unwrap();
+ query[2..4].copy_from_slice(&[0x81, 0x83]); // NXDOMAIN
+ tls.write_u16(length).await.unwrap();
+ tls.write_all(&query).await.unwrap();
+ }
+ }
+ Protocol::Http2RefuseOnce => {
+ let mut connection = h2::server::handshake(tls).await.unwrap();
+ let mut refused = false;
+ while let Some(request) = connection.accept().await {
+ let (request, mut respond) = request.unwrap();
+ tx.send((name.clone(), request.method().as_str().as_bytes().to_vec())).unwrap();
+ if !refused {
+ // The origin explicitly guarantees it did
+ // not process this request. A bodyless GET
+ // should be replayed by reqwest's policy.
+ respond.send_reset(h2::Reason::REFUSED_STREAM);
+ refused = true;
+ } else {
+ let response = http::Response::builder()
+ .header("content-type", "text/plain")
+ .body(())
+ .unwrap();
+ respond.send_response(response, false).unwrap()
+ .send_data(bytes::Bytes::from_static(b"retried"), true).unwrap();
+ }
+ }
+ return;
+ }
+ Protocol::Http2ResponseHeaders(size) => {
+ let mut connection = h2::server::handshake(tls).await.unwrap();
+ while let Some(request) = connection.accept().await {
+ let (request, mut respond) = request.unwrap();
+ tx.send((name.clone(), request.method().as_str().as_bytes().to_vec())).unwrap();
+ let response = http::Response::builder()
+ .header("content-type", "text/plain")
+ .header("content-security-policy", format!("script-src https://{}.test", "a".repeat(size)))
+ .body(())
+ .unwrap();
+ respond.send_response(response, false).unwrap()
+ .send_data(bytes::Bytes::from_static(b"large headers"), true).unwrap();
+ }
+ return;
+ }
+ Protocol::Upgrade => {
+ let request = read_headers(&mut tls).await.unwrap();
+ tx.send((name, request)).unwrap();
+ tls.write_all(b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n").await.unwrap();
+ let mut payload = [0; 4];
+ tls.read_exact(&mut payload).await.unwrap();
+ tls.write_all(&payload).await.unwrap();
+ }
+ }
+ let _ = tls.shutdown().await;
+ });
+ }
+ });
+ Self {
+ addr,
+ requests,
+ _task: Task(task),
+ }
+ }
+
+ async fn request(&mut self) -> (Option, Vec) {
+ timeout(TEST_TIMEOUT, self.requests.recv())
+ .await
+ .unwrap()
+ .unwrap()
+ }
+}
+
+async fn http_get(
+ stream: impl AsyncRead + AsyncWrite + Unpin + Send + 'static,
+) -> (http::StatusCode, String) {
+ http_request(stream, "https://unrelated.invalid:1234/test", "text/plain").await
+}
+
+async fn http_request(
+ stream: impl AsyncRead + AsyncWrite + Unpin + Send + 'static,
+ uri: &str,
+ accept: &str,
+) -> (http::StatusCode, String) {
+ // Neither an inner Host nor an absolute URL may change the selected peer.
+ let request = Request::builder()
+ .uri(uri)
+ .header("Host", "unrelated.invalid:1234")
+ .header("Accept", accept)
+ .body(empty_body())
+ .unwrap();
+ send_http_request(stream, request).await
+}
+
+async fn send_http_request(
+ stream: impl AsyncRead + AsyncWrite + Unpin + Send + 'static,
+ request: Request,
+) -> (http::StatusCode, String) {
+ let (mut sender, connection) = hyper::client::conn::http1::handshake(TokioIo::new(stream))
+ .await
+ .unwrap();
+ let _task = Task(tokio::spawn(async {
+ let _ = connection.await;
+ }));
+ let response = timeout(TEST_TIMEOUT, sender.send_request(request))
+ .await
+ .unwrap()
+ .unwrap();
+ let status = response.status();
+ let bytes = timeout(TEST_TIMEOUT, response.into_body().collect())
+ .await
+ .unwrap()
+ .unwrap()
+ .to_bytes();
+ (status, String::from_utf8(bytes.to_vec()).unwrap())
+}
+
+fn peer_certificate(tls: &TlsStream) -> X509 {
+ X509::from_der(tls.get_ref().1.peer_certificates().unwrap()[0].as_ref()).unwrap()
+}
+
+fn assert_dns_identity(cert: &X509, host: &str) {
+ let names = cert.subject_alt_names().unwrap();
+ assert_eq!(names.len(), 1);
+ assert_eq!(names[0].dnsname(), Some(host));
+ assert!(cert.verify(&pki().proxy.0.public_key().unwrap()).unwrap());
+}
+
+#[tokio::test]
+async fn ip_connect_uses_sni_for_certificates_upstream_tls_and_cache() {
+ let mut origin = Origin::start(
+ vec![
+ ("one.test", origin_config("one.test", true, false)),
+ ("two.test", origin_config("two.test", true, false)),
+ ],
+ Protocol::Http,
+ )
+ .await;
+ let proxy = Proxy::start(LocalExclusionStore::new(vec![])).await;
+ let mut first_cert = None;
+ for host in ["one.test", "two.test", "one.test"] {
+ let tls = proxy
+ .connect(&origin.addr.to_string(), client_config(true, true), host)
+ .await;
+ let cert = peer_certificate(&tls);
+ assert_dns_identity(&cert, host);
+ if host == "one.test" {
+ if let Some(first) = &first_cert {
+ assert_eq!(&cert.to_der().unwrap(), first);
+ } else {
+ first_cert = Some(cert.to_der().unwrap());
+ }
+ }
+ let (status, body) = http_get(tls).await;
+ assert_eq!(status, http::StatusCode::OK, "{body}");
+ assert_eq!(body, format!("upstream:{host}"));
+ let (sni, request) = origin.request().await;
+ assert_eq!(sni.as_deref(), Some(host));
+ let request = String::from_utf8(request).unwrap().to_ascii_lowercase();
+ assert!(request.starts_with("get /test http/1.1"));
+ assert!(request.contains(&format!("host: {host}:{}\r\n", origin.addr.port())));
+ }
+}
+
+#[tokio::test]
+async fn same_sni_on_different_connect_destinations_does_not_reuse_the_wrong_pool() {
+ let mut first = Origin::start(
+ vec![("shared.test", origin_config("shared.test", true, false))],
+ Protocol::HttpKeepAlive,
+ )
+ .await;
+ let address = format!("127.0.0.2:{}", first.addr.port());
+ let mut second = Origin::bind(
+ &address,
+ vec![("shared.test", origin_config("shared.test", true, false))],
+ Protocol::HttpKeepAlive,
+ )
+ .await;
+ let proxy = Proxy::start(LocalExclusionStore::new(vec![])).await;
+ for index in 0..3 {
+ let origin = if index == 1 { &mut second } else { &mut first };
+ let tls = proxy
+ .connect(
+ &origin.addr.to_string(),
+ client_config(true, true),
+ "shared.test",
+ )
+ .await;
+ assert_eq!(http_get(tls).await.0, http::StatusCode::OK);
+ assert_eq!(origin.request().await.0.as_deref(), Some("shared.test"));
+ }
+}
+
+#[tokio::test]
+async fn hostname_and_literal_ip_connect_keep_verification_and_missing_sni_behavior() {
+ for (connect_host, identity, send_sni) in [
+ ("localhost", "localhost", true),
+ ("localhost", "localhost", false),
+ ("127.0.0.1", "127.0.0.1", false),
+ ("[::1]", "::1", false),
+ ("[::1]", "one.test", true),
+ ] {
+ let bind = if connect_host == "[::1]" {
+ "[::1]:0"
+ } else {
+ "127.0.0.1:0"
+ };
+ let mut origin = Origin::bind(
+ bind,
+ vec![(identity, origin_config(identity, true, false))],
+ Protocol::Http,
+ )
+ .await;
+ let proxy = Proxy::start(LocalExclusionStore::new(vec![])).await;
+ let authority = format!("{connect_host}:{}", origin.addr.port());
+ let tls = proxy
+ .connect(&authority, client_config(true, send_sni), identity)
+ .await;
+ let cert = peer_certificate(&tls);
+ if identity.parse::().is_err() {
+ assert_dns_identity(&cert, identity);
+ } else {
+ assert!(cert.subject_alt_names().unwrap()[0].ipaddress().is_some());
+ }
+ let (status, body) = http_get(tls).await;
+ assert_eq!(status, http::StatusCode::OK, "{body}");
+ let upstream_sni = origin.request().await.0;
+ assert_eq!(
+ upstream_sni.as_deref(),
+ if identity.parse::().is_err() {
+ Some(identity)
+ } else {
+ None
+ }
+ );
+ }
+}
+
+#[tokio::test]
+async fn ip_connect_without_sni_does_not_guess_a_dns_identity() {
+ let origin = Origin::start(
+ vec![("one.test", origin_config("one.test", true, false))],
+ Protocol::Http,
+ )
+ .await;
+ let proxy = Proxy::start(LocalExclusionStore::new(vec![])).await;
+ let mut tcp = TcpStream::connect(proxy.addr).await.unwrap();
+ tcp.write_all(format!("CONNECT {} HTTP/1.1\r\n\r\n", origin.addr).as_bytes())
+ .await
+ .unwrap();
+ read_headers(&mut tcp).await.unwrap();
+ let result = timeout(
+ TEST_TIMEOUT,
+ TlsConnector::from(client_config(true, false))
+ .connect(ServerName::try_from("one.test").unwrap(), tcp),
+ )
+ .await
+ .unwrap();
+ assert!(
+ result.is_err(),
+ "a DNS verifier must not accept an IP-only certificate"
+ );
+}
+
+#[tokio::test]
+async fn sni_exclusions_and_ip_exclusions_bypass_interception() {
+ for (host, exclusions) in [
+ ("excluded.test", vec!["*.test".into()]),
+ ("www.apple.com", vec![]), // built-in exclusion from the reported failure
+ ("one.test", vec!["127.0.0.1".into()]),
+ ] {
+ let mut origin = Origin::start(
+ vec![(host, origin_config(host, true, false))],
+ Protocol::Http,
+ )
+ .await;
+ let proxy = Proxy::start(LocalExclusionStore::new(exclusions)).await;
+ // Trust only the origin CA: a minted interception cert must fail here.
+ let tls = proxy
+ .connect(&origin.addr.to_string(), client_config(false, true), host)
+ .await;
+ assert!(peer_certificate(&tls)
+ .verify(&pki().upstream.0.public_key().unwrap())
+ .unwrap());
+ assert_eq!(http_get(tls).await.0, http::StatusCode::OK);
+ assert_eq!(origin.request().await.0.as_deref(), Some(host));
+ }
+}
+
+#[tokio::test]
+async fn include_only_rules_are_evaluated_after_ip_sni_and_exclusions_win() {
+ let origin = Origin::start(
+ vec![("included.test", origin_config("included.test", true, false))],
+ Protocol::Http,
+ )
+ .await;
+ for (exclusions, intercept) in [
+ (vec![], true),
+ (vec!["included.test".into()], false),
+ (vec!["127.0.0.1".into()], false),
+ ] {
+ let policy =
+ LocalExclusionStore::with_rules(true, vec!["included.test".into()], exclusions);
+ let proxy = Proxy::start(policy).await;
+ let tls = proxy
+ .connect(
+ &origin.addr.to_string(),
+ client_config(intercept, true),
+ "included.test",
+ )
+ .await;
+ if intercept {
+ assert_dns_identity(&peer_certificate(&tls), "included.test");
+ }
+ assert_eq!(http_get(tls).await.0, http::StatusCode::OK);
+ }
+}
+
+#[tokio::test]
+async fn browser_alpn_enables_parallel_http2_requests_and_keeps_http1() {
+ let host = "multiplex.test";
+ let mut origin = Origin::start(
+ vec![(host, origin_config(host, true, false))],
+ Protocol::HttpWaitForPeer,
+ )
+ .await;
+ let proxy = Proxy::start(LocalExclusionStore::new(vec![])).await;
+ let mut config = (*client_config(true, true)).clone();
+ config.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()];
+ let tls = proxy
+ .connect(&origin.addr.to_string(), Arc::new(config), host)
+ .await;
+ assert_eq!(tls.get_ref().1.alpn_protocol(), Some(b"h2".as_slice()));
+ let (mut sender, connection) =
+ hyper::client::conn::http2::handshake(TokioExecutor::new(), TokioIo::new(tls))
+ .await
+ .unwrap();
+ let _task = Task(tokio::spawn(async {
+ let _ = connection.await;
+ }));
+
+ // The first origin response is held until the second request arrives.
+ // Both requests must share this one browser-side TLS connection.
+ let slow = tokio::spawn(
+ sender.send_request(
+ Request::builder()
+ .uri("https://unrelated.invalid/slow")
+ .body(empty_body())
+ .unwrap(),
+ ),
+ );
+ let (sni, request) = origin.request().await;
+ assert_eq!(sni.as_deref(), Some(host));
+ assert!(request.starts_with(b"GET /slow "));
+ let response = timeout(
+ TEST_TIMEOUT,
+ sender.send_request(
+ Request::builder()
+ .uri("https://unrelated.invalid/fast")
+ // Cookie-heavy browsers must not hit hyper's 16 KiB default
+ // when they start negotiating HTTP/2 with the proxy.
+ .header("cookie", format!("padding={}", "a".repeat(24 * 1024)))
+ .body(empty_body())
+ .unwrap(),
+ ),
+ )
+ .await
+ .unwrap()
+ .unwrap();
+ let slow_response = timeout(TEST_TIMEOUT, slow).await.unwrap().unwrap().unwrap();
+ for response in [response, slow_response] {
+ assert_eq!(response.status(), http::StatusCode::OK);
+ let body = timeout(TEST_TIMEOUT, response.into_body().collect())
+ .await
+ .unwrap()
+ .unwrap()
+ .to_bytes();
+ assert_eq!(body, format!("upstream:{host}"));
+ }
+ let (sni, request) = origin.request().await;
+ assert_eq!(sni.as_deref(), Some(host));
+ assert!(request.starts_with(b"GET /fast "));
+ assert!(String::from_utf8(request)
+ .unwrap()
+ .contains(&format!("host: {host}:{}", origin.addr.port())));
+
+ let mut config = (*client_config(true, true)).clone();
+ config.alpn_protocols = vec![b"http/1.1".to_vec()];
+ let tls = proxy
+ .connect(&origin.addr.to_string(), Arc::new(config), host)
+ .await;
+ assert_eq!(
+ tls.get_ref().1.alpn_protocol(),
+ Some(b"http/1.1".as_slice())
+ );
+ assert_eq!(http_get(tls).await.0, http::StatusCode::OK);
+}
+
+#[tokio::test]
+async fn empty_get_retries_a_refused_http2_stream() {
+ let mut config = (*origin_config("retry.test", true, false)).clone();
+ config.alpn_protocols = vec![b"h2".to_vec()];
+ let mut origin = Origin::start(
+ vec![("retry.test", Arc::new(config))],
+ Protocol::Http2RefuseOnce,
+ )
+ .await;
+ let proxy = Proxy::start(LocalExclusionStore::new(vec![])).await;
+ let tls = proxy
+ .connect(
+ &origin.addr.to_string(),
+ client_config(true, true),
+ "retry.test",
+ )
+ .await;
+
+ let (status, body) = http_get(tls).await;
+ assert_eq!(status, http::StatusCode::OK, "{body}");
+ assert_eq!(body, "retried");
+ for _ in 0..2 {
+ let (sni, method) = origin.request().await;
+ assert_eq!(sni.as_deref(), Some("retry.test"));
+ assert_eq!(method, b"GET");
+ }
+ assert!(origin.requests.try_recv().is_err());
+}
+
+#[tokio::test]
+async fn nonempty_requests_are_not_replayed_after_http2_refusal() {
+ for method in [Method::GET, Method::POST] {
+ let mut config = (*origin_config("retry.test", true, false)).clone();
+ config.alpn_protocols = vec![b"h2".to_vec()];
+ let mut origin = Origin::start(
+ vec![("retry.test", Arc::new(config))],
+ Protocol::Http2RefuseOnce,
+ )
+ .await;
+ let proxy = Proxy::start(LocalExclusionStore::new(vec![])).await;
+ let tls = proxy
+ .connect(
+ &origin.addr.to_string(),
+ client_config(true, true),
+ "retry.test",
+ )
+ .await;
+ let request = Request::builder()
+ .method(method.clone())
+ .uri("/upload")
+ .body(crate::proxy::full_body("must not be replayed"))
+ .unwrap();
+ let (status, _) = send_http_request(tls, request).await;
+ assert_eq!(status, http::StatusCode::BAD_GATEWAY);
+ assert_eq!(origin.request().await.1, method.as_str().as_bytes());
+ assert!(origin.requests.try_recv().is_err());
+ }
+}
+
+#[tokio::test]
+async fn large_http2_response_headers_are_accepted_with_a_bounded_limit() {
+ // Exercise the shared client (literal IP) and an SNI-pinned client. The
+ // decoded size, including HPACK's per-field overhead, matters here, not
+ // the compressed bytes on the wire.
+ for host in ["127.0.0.1", "headers.test"] {
+ for (size, expected) in [
+ (24 * 1024, http::StatusCode::OK),
+ (128 * 1024, http::StatusCode::BAD_GATEWAY),
+ ] {
+ let mut config = (*origin_config(host, true, false)).clone();
+ config.alpn_protocols = vec![b"h2".to_vec()];
+ let origin = Origin::start(
+ vec![(host, Arc::new(config))],
+ Protocol::Http2ResponseHeaders(size),
+ )
+ .await;
+ let proxy = Proxy::start(LocalExclusionStore::new(vec![])).await;
+ let tls = proxy
+ .connect(&origin.addr.to_string(), client_config(true, true), host)
+ .await;
+ let (status, body) = http_get(tls).await;
+ let reason = body
+ .lines()
+ .find(|line| line.contains("error sending request"))
+ .unwrap_or(&body);
+ assert_eq!(status, expected, "host={host}, size={size}: {reason}");
+ if expected == http::StatusCode::OK {
+ assert_eq!(body, "large headers");
+ }
+ }
+ }
+}
+
+#[tokio::test]
+async fn invalid_upstream_certificates_are_rejected() {
+ for (cert_host, trusted, expired) in [
+ ("one.test", false, false),
+ ("wrong.test", true, false),
+ ("one.test", true, true),
+ ] {
+ let mut origin = Origin::start(
+ vec![("one.test", origin_config(cert_host, trusted, expired))],
+ Protocol::Http,
+ )
+ .await;
+ let proxy = Proxy::start(LocalExclusionStore::new(vec![])).await;
+ let tls = proxy
+ .connect(
+ &origin.addr.to_string(),
+ client_config(true, true),
+ "one.test",
+ )
+ .await;
+ let (status, body) = http_get(tls).await;
+ assert_eq!(
+ status,
+ http::StatusCode::BAD_GATEWAY,
+ "cert={cert_host}, trusted={trusted}, expired={expired}"
+ );
+ assert!(body.contains("invalid peer certificate"), "{body}");
+ assert!(
+ origin.requests.try_recv().is_err(),
+ "HTTP must never reach an unverified peer"
+ );
+ // The separate hyper client used for upgrades must reject it too.
+ let mut tls = proxy
+ .connect(
+ &origin.addr.to_string(),
+ client_config(true, true),
+ "one.test",
+ )
+ .await;
+ tls.write_all(b"GET /socket HTTP/1.1\r\nHost: one.test\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n").await.unwrap();
+ let response = timeout(TEST_TIMEOUT, read_headers(&mut tls))
+ .await
+ .unwrap()
+ .unwrap();
+ assert!(response.starts_with(b"HTTP/1.1 502"));
+ assert!(origin.requests.try_recv().is_err());
+ }
+}
+
+#[tokio::test]
+async fn ip_connect_protocol_upgrades_keep_the_destination_and_verify_sni() {
+ let mut origin = Origin::start(
+ vec![("upgrade.test", origin_config("upgrade.test", true, false))],
+ Protocol::Upgrade,
+ )
+ .await;
+ let proxy = Proxy::start(LocalExclusionStore::new(vec![])).await;
+ let mut tls = proxy
+ .connect(
+ &origin.addr.to_string(),
+ client_config(true, true),
+ "upgrade.test",
+ )
+ .await;
+ tls.write_all(b"GET /socket HTTP/1.1\r\nHost: unrelated.invalid\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n").await.unwrap();
+ let response = timeout(TEST_TIMEOUT, read_headers(&mut tls))
+ .await
+ .unwrap()
+ .unwrap();
+ assert!(response.starts_with(b"HTTP/1.1 101"));
+ tls.write_all(b"ping").await.unwrap();
+ let mut echo = [0; 4];
+ timeout(TEST_TIMEOUT, tls.read_exact(&mut echo))
+ .await
+ .unwrap()
+ .unwrap();
+ assert_eq!(&echo, b"ping");
+ let (sni, request) = origin.request().await;
+ assert_eq!(sni.as_deref(), Some("upgrade.test"));
+ assert!(String::from_utf8(request)
+ .unwrap()
+ .contains(&format!("host: upgrade.test:{}", origin.addr.port())));
+}
+
+#[tokio::test]
+async fn dns_over_tls_passes_binary_queries_and_responses_unchanged() {
+ let mut origin = Origin::start(
+ vec![("dns.quad9.net", origin_config("dns.quad9.net", true, false))],
+ Protocol::Dns,
+ )
+ .await;
+ // An ephemeral unprivileged port keeps this test runnable without root.
+ // Standard port 853's automatic bypass is covered separately below.
+ let proxy = Proxy::start(LocalExclusionStore::new(vec!["dns.quad9.net".into()])).await;
+ let mut tls = proxy
+ .connect(
+ &origin.addr.to_string(),
+ client_config(false, true),
+ "dns.quad9.net",
+ )
+ .await;
+ for id in [0x12, 0x34] {
+ let query = [
+ id, 0x56, 0x01, 0x00, 0, 1, 0, 0, 0, 0, 0, 0, 7, b'e', b'x', b'a', b'm', b'p', b'l',
+ b'e', 3, b'c', b'o', b'm', 0, 0, 1, 0, 1,
+ ];
+ tls.write_u16(query.len() as u16).await.unwrap();
+ tls.write_all(&query).await.unwrap();
+ let length = timeout(TEST_TIMEOUT, tls.read_u16())
+ .await
+ .unwrap()
+ .unwrap();
+ let mut response = vec![0; length as usize];
+ timeout(TEST_TIMEOUT, tls.read_exact(&mut response))
+ .await
+ .unwrap()
+ .unwrap();
+ let mut expected = query;
+ expected[2..4].copy_from_slice(&[0x81, 0x83]);
+ assert_eq!(response, expected);
+ let (sni, received) = origin.request().await;
+ assert_eq!(sni.as_deref(), Some("dns.quad9.net"));
+ assert_eq!(received, query);
+ }
+}
+
+#[test]
+fn standard_dot_port_bypasses_without_consuming_tls_or_entering_http() {
+ let policy = LocalExclusionStore::new(vec![]);
+ for destination in ["9.9.9.9:853", "[2620:fe::fe]:853", "dns.quad9.net:853"] {
+ let authority: Authority = destination.parse().unwrap();
+ assert!(bypass_before_client_hello(&policy, &authority, &authority));
+ }
+ let https: Authority = "9.9.9.9:443".parse().unwrap();
+ assert!(!bypass_before_client_hello(&policy, &https, &https));
+}
+
+fn initial_client_hello(
+ config: Arc,
+ host: &str,
+) -> (rustls::ClientConnection, Vec) {
+ let mut client =
+ rustls::ClientConnection::new(config, ServerName::try_from(host.to_owned()).unwrap())
+ .unwrap();
+ let mut bytes = Vec::new();
+ client.write_tls(&mut bytes).unwrap();
+ (client, bytes)
+}
+
+/// Split the handshake itself across TLS records as well as TCP writes. Just
+/// fragmenting TCP would miss implementations that only parse the first record.
+fn fragment_records(wire: &[u8]) -> Vec {
+ let mut fragmented = Vec::new();
+ let mut offset = 0;
+ while offset < wire.len() {
+ let header = &wire[offset..offset + 5];
+ let length = u16::from_be_bytes([header[3], header[4]]) as usize;
+ for chunk in wire[offset + 5..offset + 5 + length].chunks(17) {
+ fragmented.extend_from_slice(&header[..3]);
+ fragmented.extend_from_slice(&(chunk.len() as u16).to_be_bytes());
+ fragmented.extend_from_slice(chunk);
+ }
+ offset += 5 + length;
+ }
+ fragmented
+}
+
+async fn flush_tls(client: &mut rustls::ClientConnection, tcp: &mut TcpStream) {
+ while client.wants_write() {
+ let mut bytes = Vec::new();
+ client.write_tls(&mut bytes).unwrap();
+ tcp.write_all(&bytes).await.unwrap();
+ }
+}
+
+async fn receive_tls(client: &mut rustls::ClientConnection, tcp: &mut TcpStream) {
+ let mut bytes = [0; 16 * 1024];
+ let length = tcp.read(&mut bytes).await.unwrap();
+ assert_ne!(length, 0, "TLS peer closed unexpectedly");
+ let mut reader = &bytes[..length];
+ while !reader.is_empty() {
+ assert_ne!(client.read_tls(&mut reader).unwrap(), 0);
+ client.process_new_packets().unwrap();
+ }
+}
+
+#[tokio::test]
+async fn fragmented_client_hello_and_connect_read_ahead_work_for_mitm_and_bypass() {
+ for intercept in [true, false] {
+ let mut origin = Origin::start(
+ vec![("fragment.test", origin_config("fragment.test", true, false))],
+ Protocol::Http,
+ )
+ .await;
+ let policy = LocalExclusionStore::new(if intercept {
+ vec![]
+ } else {
+ vec!["fragment.test".into()]
+ });
+ let proxy = Proxy::start(policy).await;
+ timeout(TEST_TIMEOUT, async {
+ let (mut client, hello) =
+ initial_client_hello(client_config(intercept, true), "fragment.test");
+ let hello = fragment_records(&hello);
+ let mut tcp = TcpStream::connect(proxy.addr).await.unwrap();
+ tcp.set_nodelay(true).unwrap();
+ let mut request = format!(
+ "CONNECT {} HTTP/1.1\r\nHost: {}\r\n\r\n",
+ origin.addr, origin.addr
+ )
+ .into_bytes();
+ // Exercise hyper's upgrade read-ahead with a partial TLS header.
+ request.extend_from_slice(&hello[..3]);
+ tcp.write_all(&request).await.unwrap();
+ assert!(read_headers(&mut tcp)
+ .await
+ .unwrap()
+ .starts_with(b"HTTP/1.1 200"));
+ for chunk in hello[3..].chunks(3) {
+ tcp.write_all(chunk).await.unwrap();
+ tokio::task::yield_now().await;
+ }
+ while client.is_handshaking() {
+ flush_tls(&mut client, &mut tcp).await;
+ receive_tls(&mut client, &mut tcp).await;
+ }
+ if intercept {
+ let cert = X509::from_der(client.peer_certificates().unwrap()[0].as_ref()).unwrap();
+ assert_dns_identity(&cert, "fragment.test");
+ }
+ std::io::Write::write_all(
+ &mut client.writer(),
+ b"GET /fragment HTTP/1.1\r\nHost: fragment.test\r\nConnection: close\r\n\r\n",
+ )
+ .unwrap();
+ flush_tls(&mut client, &mut tcp).await;
+ let mut response = Vec::new();
+ loop {
+ receive_tls(&mut client, &mut tcp).await;
+ match std::io::Read::read_to_end(&mut client.reader(), &mut response) {
+ Ok(_) => {}
+ Err(error) if error.kind() == io::ErrorKind::WouldBlock => {}
+ Err(error) => panic!("TLS read: {error}"),
+ }
+ if response.ends_with(b"upstream:fragment.test") {
+ break;
+ }
+ }
+ assert!(response.starts_with(b"HTTP/1.1 200"));
+ assert_eq!(origin.request().await.0.as_deref(), Some("fragment.test"));
+ })
+ .await
+ .unwrap();
+ }
+}
+
+#[tokio::test]
+async fn client_hello_parser_retains_every_byte_at_all_record_and_buffer_boundaries() {
+ let (_, hello) = initial_client_hello(client_config(true, true), "fragment.test");
+ let mut wire = fragment_records(&hello);
+ // Bytes after the ClientHello may already be buffered, including TLS 1.3
+ // compatibility CCS or early data. A tunnel must preserve them verbatim.
+ wire.extend_from_slice(b"\x14\x03\x03\x00\x01\x01buffered-after-hello");
+ for capacity in [1, 2, 5, 19, 8192] {
+ let (mut writer, reader) = tokio::io::duplex(wire.len());
+ writer.write_all(&wire).await.unwrap();
+ writer.shutdown().await.unwrap();
+ let (mut hello, mut replay) = read_client_hello(BufReader::with_capacity(capacity, reader))
+ .await
+ .unwrap();
+ assert_eq!(hello.client_hello().server_name(), Some("fragment.test"));
+ hello.io.read_to_end(&mut replay).await.unwrap();
+ assert_eq!(replay, wire, "buffer capacity {capacity}");
+ }
+}
+
+#[tokio::test]
+async fn malformed_truncated_and_oversized_client_hellos_fail_closed() {
+ let mut oversized = vec![0x16, 3, 1, 0, 4, 1, 1, 0, 1];
+ for _ in 0..5 {
+ oversized.extend_from_slice(&[0x16, 3, 1, 0x40, 0]);
+ oversized.extend_from_slice(&[0; 16 * 1024]);
+ }
+ for bytes in [
+ b"\x16\x03\x01\x00\x20\x01".to_vec(), // truncated handshake
+ b"\x16\x03\x01\x00\x05\x02\x00\x00\x01\x00".to_vec(), // not ClientHello
+ oversized,
+ ] {
+ let (mut writer, reader) = tokio::io::duplex(bytes.len());
+ writer.write_all(&bytes).await.unwrap();
+ writer.shutdown().await.unwrap();
+ assert!(read_client_hello(BufReader::new(reader)).await.is_err());
+ }
+}
+
+#[test]
+fn tls_identity_only_uses_dns_sni_for_ip_connect_and_keeps_the_port() {
+ for (connect, sni, expected) in [
+ (
+ "192.0.2.1:4443",
+ Some("WWW.Example.test"),
+ "www.example.test:4443",
+ ),
+ ("[2001:db8::1]:443", Some("one.test"), "one.test:443"),
+ ("192.0.2.1:443", None, "192.0.2.1:443"),
+ ("[2001:db8::1]:443", Some("192.0.2.2"), "[2001:db8::1]:443"),
+ ("192.0.2.1:443", Some("invalid/host"), "192.0.2.1:443"),
+ (
+ "original.test:443",
+ Some("different.test"),
+ "original.test:443",
+ ),
+ ] {
+ assert_eq!(
+ tls_identity(&connect.parse().unwrap(), sni).as_str(),
+ expected
+ );
+ }
+}
+
+#[tokio::test]
+async fn connect_ip_pin_does_not_override_configured_doh_redirect_routing() {
+ let mut resolver = Origin::start(
+ vec![("localhost", origin_config("localhost", true, false))],
+ Protocol::Http,
+ )
+ .await;
+ let mut destination = Origin::bind(
+ &format!("127.0.0.2:{}", resolver.addr.port()),
+ vec![("localhost", origin_config("localhost", true, false))],
+ Protocol::Http,
+ )
+ .await;
+ let doh = DohConfig {
+ mode: crate::configuration::DohMode::Redirect,
+ upstream: Some(format!(
+ "https://localhost:{}/dns-query",
+ resolver.addr.port()
+ )),
+ extra_hosts: vec![],
+ };
+ let proxy = Proxy::with_doh(LocalExclusionStore::new(vec![]), doh).await;
+ let tls = proxy
+ .connect(
+ &destination.addr.to_string(),
+ client_config(true, true),
+ "localhost",
+ )
+ .await;
+ let (status, body) = http_request(tls, "/dns-query?dns=AA", "application/dns-message").await;
+ assert_eq!(status, http::StatusCode::OK, "{body}");
+ let (sni, request) = resolver.request().await;
+ assert_eq!(sni.as_deref(), Some("localhost"));
+ assert!(request.starts_with(b"GET /dns-query?dns=AA HTTP/1.1"));
+ assert!(
+ destination.requests.try_recv().is_err(),
+ "redirect must resolve its own destination"
+ );
+}
diff --git a/privaxy/src/server/proxy/exclusions.rs b/privaxy/src/server/proxy/exclusions.rs
index c6aeb10..3d2056f 100644
--- a/privaxy/src/server/proxy/exclusions.rs
+++ b/privaxy/src/server/proxy/exclusions.rs
@@ -298,12 +298,30 @@ impl From<&crate::configuration::Configuration> for InterceptionRules {
pub struct LocalExclusionStore(Arc>);
impl LocalExclusionStore {
+ /// Unlike an include-only miss, an explicit exclusion can be honored
+ /// before looking for SNI on an IP CONNECT (including server-first flows).
+ pub fn is_excluded(&self, host: &str, raw_host: &str) -> bool {
+ let rules = self.0.read().unwrap();
+ [host, raw_host]
+ .iter()
+ .any(|host| DEFAULT_EXCLUSIONS.is_match(host) || rules.exclusions.is_match(host))
+ }
+
#[cfg(test)]
pub fn new(exclusions: Vec) -> Self {
+ Self::with_rules(false, Vec::new(), exclusions)
+ }
+
+ #[cfg(test)]
+ pub(super) fn with_rules(
+ include_only: bool,
+ inclusions: Vec,
+ exclusions: Vec,
+ ) -> Self {
Self(Arc::new(RwLock::new(InterceptionRules {
exclusions: WildMatchCollection::new(exclusions),
- inclusions: WildMatchCollection::new(Vec::new()),
- include_only: false,
+ inclusions: WildMatchCollection::new(inclusions),
+ include_only,
})))
}
diff --git a/privaxy/src/server/proxy/html_rewriter.rs b/privaxy/src/server/proxy/html_rewriter.rs
index d3cfd65..7d3ae4c 100644
--- a/privaxy/src/server/proxy/html_rewriter.rs
+++ b/privaxy/src/server/proxy/html_rewriter.rs
@@ -427,6 +427,10 @@ impl Rewriter {
endpoint_tokens,
} = self;
+ // Head scriptlets and the trailing cosmetic stylesheet belong to one
+ // response. Their separate tasks share a single counting guard.
+ let statistics = statistics.for_response();
+
let CosmeticBlockerResult {
hidden_selectors,
style_selectors,
@@ -676,6 +680,56 @@ impl Rewriter {
#[cfg(test)]
mod tests {
use super::*;
+
+ #[tokio::test]
+ async fn head_and_body_injections_count_each_response_only_once() {
+ use crate::blocker::BlockingDisabledStore;
+ use http_body_util::BodyExt;
+ use std::sync::RwLock;
+
+ let statistics = Statistics::new();
+ for expected in 1..=2 {
+ let requester =
+ AdblockRequester::new(BlockingDisabledStore(Arc::new(RwLock::new(false))));
+ let mut cosmetics = requester
+ .get_cosmetic_response("https://example.test/".into())
+ .await;
+ cosmetics.hidden_selectors.push(".ad-test".into());
+ let (input, receiver) = mpsc::channel(2);
+ let (sender, mut body) = body_channel();
+ let rewriter = Rewriter::new(
+ requester,
+ receiver,
+ sender,
+ statistics.clone(),
+ "fixture".into(),
+ cosmetics,
+ false,
+ Vec::new(),
+ GmStorageStore::in_memory(),
+ HashMap::new(),
+ );
+ input
+ .send(Ok(Bytes::from_static(
+ b"ad
",
+ )))
+ .await
+ .unwrap();
+ drop(input);
+ let task = tokio::task::spawn_blocking(move || rewriter.rewrite());
+ let mut output = Vec::new();
+ while let Some(frame) = body.frame().await {
+ if let Ok(bytes) = frame.unwrap().into_data() {
+ output.extend_from_slice(&bytes);
+ }
+ }
+ task.await.unwrap();
+ let html = String::from_utf8(output).unwrap();
+ assert!(html.contains("