具備混合後量子密碼學 TLS 支援的可投入生產的 HTTP/3/QUIC/WebTransport 反向代理。以 Rust 建置,提供最高效能與安全性。
AI 翻譯
Production-ready HTTP/3/QUIC/WebTransport reverse proxy with hybrid Post-Quantum Cryptography TLS support. Built in Rust for maximum performance and security.
開發工具Rust · ShellGitHub @PQCrypta上線中營運者未確認
試用過了嗎? 點一下就好,不需要登入。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證Rust Layer-7 reverse proxy for HTTP/1.1, HTTP/2, HTTP/3/QUIC, and WebTransport. Hybrid PQC TLS (X25519MLKEM768), three TLS modes, JA3/JA4 fingerprinting, GeoIP blocking, circuit breaking, ML-adaptive rate limiting, 6 load-balancing algorithms, Brotli/zstd compression, ACME, OCSP stapling, Prometheus metrics, and hot-reload.
翻譯準備中
取自儲存庫 README
正在載入完整 README。 以下為已儲存的摘錄。
Production-ready HTTP/3/QUIC/WebTransport reverse proxy with hybrid Post-Quantum Cryptography (PQC) TLS support.檢視完整 README ↗
Production-ready HTTP/3/QUIC/WebTransport reverse proxy with hybrid Post-Quantum Cryptography (PQC) TLS support.
--env).git, .env, .aws, wp-login.php, terraform state, SSH keys, CI/CD files, etc.) are blocked at path level before pattern scanning, preventing automated scanner traffic from reaching backends and polluting error-rate metricstrusted_internal_cidrs deprecation| Feature | Status | Description |
|---|---|---|
| Load Balancing | ✅ | 6 algorithms with session affinity and health-aware routing |
| Circuit Breaker | ✅ | Backend health monitoring with auto-recovery |
| Advanced Rate Limiting | ✅ | Multi-dimensional: IP, JA3/JA4, JWT, headers, composite keys on all paths (TCP + QUIC/HTTP3); optional Redis backend for distributed cross-instance coordination |
| Connection Rate Limiting | ✅ | Per-IP cap on new connections per second (connection_rate_limit, connections_per_second), separate from max_connections_per_ip which caps concurrency — a client that opens and closes as fast as it can never trips a concurrency limit |
| Per-Route Security Policy | ✅ | [routes.security] overrides applied before the global checks: WAF on/off and mode, rate limits, JA3 allowlist, mTLS, HMAC request signing, plus skip_bot_blocking for routes that serve automated clients |
| DoS Protection | ✅ | Connection limits, request validation |
| GeoIP Blocking | ✅ | Country-based blocking (MaxMind DB) |
| JA3/JA4 Fingerprinting | ✅ | TLS client fingerprint detection and classification on every transport — HTTP/1.1, HTTP/2 and HTTP/3. Over QUIC the ClientHello arrives inside encrypted Initial CRYPTO frames, so the vendored rustls/noq forks surface it through HandshakeData and it is parsed by the same extractor the TCP paths use. The JA4 transport marker is q over QUIC and t over TCP |
| Priority Hints | ✅ | RFC 9218 response prioritization |
| Request Coalescing | ✅ | Deduplicates identical in-flight requests |
| Early Hints (103) | ✅ | Link headers for preload/preconnect |
| Compression | ✅ | Brotli/gzip/deflate/zstd |
| Security Headers | ✅ | HSTS, CSP, CORS, Alt-Svc; CORS headers injected on 429 rate-limit responses so browsers surface the status code instead of a misleading CORS error |
| Alt-Svc by transport |
api.example.com → port 3003, example.com → port 8080additional_ports) all supporting QUIC/HTTP3/WebTransport/x/../dev/, /%2e/dev/ and //dev/ route as /dev/ on HTTP/1.1, HTTP/2, HTTP/3 and WebTransport alike, so a route guarding the canonical path cannot be stepped around by spelling it differently. resolve_dot_segments and merge_slashes in [server] (both on by default) are independent of normalize_paths (lowercasing); the WAF and HMAC proof-of-possession see the raw requestX-Forwarded-For headers scanned without SSRF patterns (localhost/RFC1918 IPs in XFF are legitimate proxy hops, not SSRF — prevents false positives for clients behind local reverse proxies)block_malicious = true in fingerprint config automatically blocks connections whose JA3/JA4 hash matches a known-malicious entry in the classification database; fingerprint cache with configurable TTL and background cleanuppqc_session_tickets = false the proxy issues no TLS 1.3 tickets at all and resumption relies on session storage. When enabled, each ticket encapsulates to the server's ML-KEM-1024 (FIPS 204) key for a fresh per-ticket secret, derives an AES-256-GCM key from it with HKDF-SHA384, and seals the resumption state with the KEM ciphertext as associated data. The keypair rolls every session_ticket_lifetime_secs (default 12 h) retaining one previous generation, since the trait requires lifetime to be enforced by key rolling rather than a timestamp inside the ticket. Note the server holds both halves of the keypair: what this changes is the algorithm protecting ticket key material and that each ticket gets an independent secret — it is not a defence against reading server memorycertificate_compression = "auto" in [tls]; "off" disables it on both stacks). The two stacks enable it in completely different ways — OpenSSL sends an uncompressed chain unless the application calls both SSL_CTX_set1_cert_comp_preference and SSL_CTX_compress_certs, neither of which the or crates bind, while rustls needs no call at all and populates from the and crate features. Only zlib is offered over TCP, because these OpenSSL builds report and nothing else and advertising an algorithm the library cannot produce is worse than offering one fewer. Measured on this deployment: the Certificate message is 3,435 bytes and the replacing it is 2,384, a saving of exactly 1,051 bytes per full handshake — most of what the X25519MLKEM768 key share adds. The setting is read once at startup because both stacks bake it into the TLS context, so changing it needs a restart rather than a config reloadleast_connections (default): Routes to server with fewest active connectionsround_robin: Simple rotation through serversweighted_round_robin: nginx-style smooth weighted distributionrandom: Random server selectionip_hash: Consistent hashing by client IP for sticky sessionsleast_response_time: Routes to fastest responding server (EMA tracking)PQCPROXY_CANARY cookie so they stay on the same server for the duration of the experiment; auto-rollback suspends the canary if its sliding-window error rate exceeds a configured threshold; live admin endpoints (GET /canary, POST /canary/suspend/:id, POST /canary/resume/:id, POST /canary/weight/:id) allow runtime inspection and control without restarting the proxy[routes.shadow] block sends a fire-and-forget async copy of each request to a secondary backend; client only ever sees the primary response and the shadow response is discarded; configurable percentage (0–100), independent timeout, custom marker header name+value, and per-route response logging — zero overhead on routes without shadow configuredh3 crate with proper header forwarding; hop-by-hop headers (Transfer-Encoding, Connection, etc.) stripped from all backend responses before forwarding or caching — prevents ERR_QUIC_PROTOCOL_ERROR per RFC 9114 §4.2draft-29..34, which it cannot actually handshake; the listener overrides EndpointConfig::supported_versions to [0x00000001, 0x6b3343cf] so VN names only what the server really speaks, plus a reserved GREASE version (RFC 9000 §15). v2 is an anti-ossification revision with the same feature set as v1 — new codepoints, salts and TLS labels — so it adds no capability, only resistance to middleboxes hard-coding "QUIC = v1". Verified externally rather than asserted: a client offering only 0x6b3343cf completes a handshake against this endpoint, and its VN packet lists GREASE, v1 and v2.noq-fork).noq-fork).server.max_concurrent_multipath_paths (default 4; 1 keeps every connection single-path, 0 stops the extension being negotiated at all) and path creation/validation/teardown is automatic once negotiated. Peers that do not advertise multipath are unaffected whatever the value. HTTP/3 and WebTransport both run over noq (via the in-tree h3-noq binding and the noq-ported wtransport). Verify: scan pqcrypta.com at https://pqcrypta.com/http3_quic/ — the Multipath field validates a genuine second data-carrying path.webtransport_allowed_origins allowlist rejects browser sessions from unlisted origins with 403; non-browser clients (no Origin header) always acceptedspeedtest operation) providing datagram RTT probing, stream download/upload throughput measurement, packet-loss counting, MTR-based hop traceroute with GeoIP city/ASN/country annotation, and client IP info lookup/telemetry): Native WebTransport handler that pushes 6 independent QUIC uni-streams at 20 Hz each (~5 Mbps virtual throughput per channel), demonstrating true QUIC stream isolation. A stats channel pushes RTT, CWND, CPU, and memory at 10 Hz. A control bidi-stream accepts impairment commands (delay, loss, bandwidth cap, jitter, disconnect) scoped to individual channels, leaving others unaffected — the opposite of TCP head-of-line blocking.--env <name> CLI flag (or PQCRYPTA_ENV) loads config.<name>.toml and merges it on top of the base config — shared base with environment-specific overridesSIGHUP to the proxy reopens all log file handles without dropping connections or restarting — compatible with standard logrotate postrotate hooks (systemctl kill -s HUP pqcrypta-proxy)GET /blocklist), manual block (POST /blocklist/block/:ip?duration_secs=N) and immediate unblock (POST /blocklist/unblock/:ip); GET /security/status, /security/geoip/:ip, /security/fingerprints and /security/threats; rate-limit statistics (GET /ratelimit) and per-client reset (POST /ratelimit/reset/:key) against the one advanced rate limiter every listener, port and protocol shares; loopback enforcement prevents plain-HTTP token exposure on non-loopback interfaces; ephemeral session tokens use OsRng for cryptographic security; per-IP and global lockout of failing callers with exponential back-off; the token is checked first, so a correct token is never refused by it (a client left on a stale token cannot lock out the rest)POST /ct/v1/add-chain for public auditability, to the configured logs or, by default, to one log per operator from the live CT log list whose temporal shard holds the certificate's expiryThe WAF, blocklist, GeoIP check, rate limiters, header-size limit and per-route
policy are implemented once, in SecurityState::evaluate, and both transports
call it. It takes a transport-agnostic view of a request and returns a verdict;
only the rendering differs — axum builds a Response, the HTTP/3 handler builds
an http::Response<()>.
This matters because the HTTP/3 handler previously carried its own copy of the
checks, which omitted the WAF entirely: every rule was bypassable by connecting
over QUIC. Two implementations of one policy drift. When changing a rule, change
evaluate — a new decision variant will not compile until both renderers handle
it. Verify parity after any change:
# both must return 403
curl "https://<host>/?id=1%20UNION%20SELECT%20pw%20FROM%20users"
curl --http3-only "https://<host>/?id=1%20UNION%20SELECT%20pw%20FROM%20users"
Fingerprints are computed on every transport. On TCP the listener peeks the socket
and parses the ClientHello itself; over QUIC the hello arrives inside encrypted Initial
CRYPTO frames, so the vendored rustls fork captures its wire bytes and the vendored noq
fork surfaces them through HandshakeData. Both feed the same extractor — two
parsers would mean one client classified differently depending on which port it reached,
and a blocklist entry gathered on one silently not applying on the other.
Because a QUIC client legitimately offers a different extension set (QUIC transport
parameters in, others out), the JA4 transport marker distinguishes them: q over QUIC,
t over TCP.
Blocking on the QUIC path happens after the handshake rather than before it. A QUIC server cannot decline earlier — the hello is encrypted under keys derived during the handshake it would be trying to avoid — but the block still lands before any HTTP/3 request is served.
Classification consults the operator database at fingerprint.fingerprint_db_path
first, then the built-in table. Entries are matched by JA3, then JA4.
Prefer JA4 when adding a scanner. JA3 hashes the raw cipher/extension order, so a
tool that varies its cipher list between probes produces a different JA3 every
run — two runs of nmap --script ssl-enum-ciphers against this proxy produced two
disjoint sets of 19 JA3 hashes while their JA4 values were identical. An entry
whose hash field holds a JA4 string is matched the same way as a JA3 one.
Collect fingerprints rather than copying them from public lists — a wrong entry
silently blocks real visitors. The proxy logs one line per new fingerprint under
the fingerprint_observed target, so running a single named tool and reading that
window gives verified ground truth:
MARK=$(date -Is)
# run exactly one tool against the proxy, then:
journalctl -u pqcrypta-proxy --since "$MARK" | grep "New TLS fingerprint"
Addresses in security.pentest_bypass_ips are classified and logged but never
banned by fingerprint, so adding a scanner entry cannot ban the host your
authorised scans come from.
The following directories must exist outside the web root before starting the proxy:
| Directory | Mode | Purpose |
|---|---|---|
/var/lib/pqcrypta-proxy/blocklists/ | 0700, owned by pqcrypta | Database-synced IP/fingerprint/country blocklists |
/var/lib/pqcrypta-proxy/fingerprints/ | 0700, owned by pqcrypta | JA3/JA4 fingerprint database (ja3.json) |
# Create directories with correct ownership and permissions
install -d -m 0700 -o pqcrypta -g pqcrypta /var/lib/pqcrypta-proxy/blocklists
install -d -m 0700 -o pqcrypta -g pqcrypta /var/lib/pqcrypta-proxy/fingerprints
Backend addresses are validated against dangerous IP ranges at config load time (F-01):
169.254.0.0/16, fe80::/10) — always rejected. These ranges host cloud
metadata services (AWS IMDSv1/v2, GCP metadata server, Azure IMDS). Routing proxy traffic here
would expose IAM credentials to attackers. This check cannot be disabled.[security]
# Explicitly acknowledge that RFC1918 backends are intentional and the SSRF
# risk has been assessed. Link-local (169.254.0.0/16) is still rejected.
allow_internal_backends = true
The MaxMind GeoLite2 databases are not included in the repository (weekly updates would make committed copies stale within days). Download them with the provided script:
# Register free at https://www.maxmind.com/en/geolite2/signup then:
export MAXMIND_ACCOUNT_ID=<your account ID>
export MAXMIND_LICENSE_KEY=<your license key>
scripts/download_geoip.sh
This writes GeoLite2-City.mmdb (the geoip_db_path database, which carries the country too)
and GeoLite2-ASN.mmdb to data/geoip/. After that, scripts/refresh_fingerprint_db.sh keeps
them current: run weekly from cron, it calls geoipupdate with /etc/GeoIP.conf
(EditionIDs GeoLite2-City GeoLite2-ASN), validates each downloaded file, replaces the live one
by rename only when it changed, and pushes it to the other proxy nodes. It is the only scheduler
for geoipupdate; disable the Ubuntu package's own geoipupdate.timer.
A changed database reaches the running proxy by a reload (systemctl reload pqcrypta-proxy,
SIGHUP), not a restart: a reload compares each data file (both GeoLite2 databases, the JA3/JA4
database) with the one it loaded, by inode, size and modification time, and loads what changed,
and the speed test's readers do the same on their next lookup. The unit needs
ExecReload=/bin/kill -HUP $MAINPID for systemctl reload. The script restarted the proxy, on
every node, every week; each restart refused connections while the old process drained.
Only loopback (127.0.0.0/8 / ::1) is trusted by default (SEC-A05). RFC1918 private ranges
are treated as untrusted external traffic — this prevents XFF/Real-IP spoofing attacks from
RFC1918 sources (e.g., on multi-tenant or shared networks). If you operate a genuinely isolated
private network where RFC1918 sources are legitimate, add them explicitly:
[security]
# Explicit opt-in for CIDRs that should bypass security checks.
# Default: empty (only loopback 127.x/::1 is trusted; RFC1918 is NOT trusted by default).
trusted_internal_cidrs = ["10.200.0.0/16"]
[security]
geoip_db_path = "/etc/pqcrypta/geoip/GeoLite2-City.mmdb"
geoip_asn_db_path = "/etc/pqcrypta/geoip/GeoLite2-ASN.mmdb"
blocked_countries = ["KP"] # ISO 3166-1 alpha-2
allowed_countries = [] # non-empty: only these; an unlocated address passes
blocked_regions = [] # ISO 3166-2, e.g. "US-CA"
blocked_asns = [] # autonomous system numbers
block_tor_exit_nodes = false # fetch tor_exit_list_url every tor_exit_refresh_secs
geoip_block_duration_secs = 86400 # 0 = permanent
All of these are one check (SecurityState::geo_block_reason) that the
per-request evaluator and the HTTP/3 per-connection gate both call, so no rule
applies on one path and not the other. The Tor list is fetched in the
background and swapped in whole; a failed fetch keeps the previous list. The
speed test reads the same two database paths.
--watch-config (file edits), POST /reload, and SIGHUP (which also reopens
log files) all trigger the same reload, and every one of them loads the file
the way startup does: the --env overlay and the --udp-port,
--admin-port and --no-pqc overrides are applied again, so a reload never
quietly drops them.
What a reload applies:
[security], [rate_limiting], [advanced_rate_limiting] and
[circuit_breaker]; the per-route policy the security layer reads before
routing; security.blocked_ips (addresses and CIDR ranges, added and lifted
as listed, leaving operator and database blocks alone); the WAF, recompiled
from [waf] when it changed — exclusions, threshold, mode, categories,
custom patterns, enabled — with its counters carried over; the scanner-UA
exemptions; the GeoIP and JA3 databases when their paths change; the crawler
verifier; and the Tor exit refresher, which reads its settings each round.auth_token, hmac_secret, allowed_ips — on
the next request, so a rotated token takes effect without a restart. A reload
that removes auth_token keeps the previous one rather than switching
authentication off.[backend_pools]) are added, rebuilt or dropped; a
server whose settings did not change keeps its health, connections and canary
state through a rebuild.[tls], [pqc], server.http11_only_hosts):
the OpenSSL (TCP) listener rebuilds its acceptor and every per-SNI context
from them and swaps them in, so the next handshake offers the new groups
(an added experimental_groups entry, a changed preferred_kem), versions
and client authentication; a handshake already under way finishes on the
context it started with, and a rebuild that fails keeps the previous one and
logs why. The rustls listeners take them through the TLS provider.
Certificates, and Early Hints.Settings bound when the process starts — listening addresses and ports, QUIC
transport parameters, the listener kinds (pqc.enabled,
fingerprint.enabled), the admin listener, and the process-wide [logging],
[otel], [ocsp], [acme], [cache], [conformance] and [load_balancer]
services — are listed in RESTART_BOUND_SETTINGS. A reload that changes one
applies everything else and logs a warning naming each such setting, instead of
reporting success while it waits for a restart.
[server]
proxy_protocol_trusted = ["10.0.0.0/8"] # load balancers that send PROXY headers
proxy_protocol_timeout_ms = 3000
A connection from a trusted peer must open with a PROXY header, v1 or v2, and
the address it names becomes the client address for logging, rate limits, the
WAF and GeoIP; without one it is refused. Other peers are never parsed for a
header. v2 TLVs are validated and a CRC32C TLV verified. Separately,
proxy_protocol = true on a [[passthrough_routes]] entry sends a v2 header to
that backend.
[logging]
access_log = true
access_log_file = "/var/log/pqcrypta/access.log"
access_log_format = "combined" # or "json", or a template:
# access_log_format = '$remote_addr [$time_local] "$request" $status $body_bytes_sent $request_time_ms $ja4 $backend'
Template variables: remote_addr, time_local, time_iso8601, request,
method, path, protocol, status, body_bytes_sent, http_referer,
http_user_agent, host, request_time_ms, trace_id, ja3, ja4,
backend. Client-supplied fields are sanitised against log injection; JSON
escapes them. A streamed response is logged when the stream ends, with the
bytes actually sent.
[security.validation]
enabled = true # default on
max_uri_length = 16384 # path + query, bytes -> 414
max_headers_count = 200 # header fields -> 431
max_header_name_length = 256 # bytes -> 431
max_header_value_length = 16384 # bytes, per value -> 431
allowed_methods = [] # empty accepts any method; otherwise -> 405
reject_null_bytes = true # raw or %00 in path or query -> 400
Checked in SecurityState::evaluate, the one evaluator HTTP/1.1, HTTP/2 and
HTTP/3 all call, after the blocklist and header-size checks and before the WAF.
A request with no host is refused by routing, which matches on it.
[security]
# Origins that may read the proxy's own 403/429 refusals cross-origin. Without
# CORS headers a refusal reaches the page as an opaque CORS error, so it cannot
# see the status and back off. One list for every transport and both limiters.
refusal_cors_origins = ["https://example.com"] # default: none
# Where a geo-blocked request is redirected (302). Empty refuses with a plain 403.
geo_block_redirect_url = "https://example.com/error_pages/403.html" # default: ""
# Exempt from IP, country and rate blocking, so a blocked visitor can load the
# page explaining the block. Must contain geo_block_redirect_url's path.
error_pages_path_prefix = "/error_pages/" # default: "" (nothing exempt)
None of these name a site by default; each deployment names its own.
To enable fingerprint-based detection:
Download an open-source JA3 database (e.g., from salesforce/ja3) or create your own JSON file with the format:
[
{"hash": "<md5>", "classification": "browser", "description": "Chrome 120"},
{"hash": "<md5>", "classification": "malicious", "description": "Mirai scanner"}
]
Valid classifications: browser, bot, legitimate_bot, malicious, scanner, api_client
Place the file at /var/lib/pqcrypta-proxy/fingerprints/ja3.json
(or configure a custom path via fingerprint.fingerprint_db_path)
If the file is missing or malformed the proxy starts normally with an empty database and logs a warning.
Enforcement is controlled by two flags in [fingerprint]:
block_malicious = true (default) — automatically blocks and IP-bans connections whose
JA3/JA4 hash is classified as malicious in the database. Set to false for advisory-only
logging while you build confidence in the database.block_scanners = false (default) — set to true to also block scanner fingerprints.To prevent open-redirect abuse via a spoofed Host header, configure the allowed-domains list:
[http_redirect]
enabled = true
port = 80
# Only redirect requests whose Host header matches one of these domains.
# Requests with an unknown Host receive 400 Bad Request.
allowed_domains = ["example.com", "api.example.com", "www.example.com"]
Leave allowed_domains = [] (empty, the default) to disable the check and allow any Host.
The admin API should always have an authentication token set:
[admin]
bind_address = "127.0.0.1"
port = 8082
allowed_ips = ["127.0.0.1", "::1"]
# Generate with: openssl rand -base64 32
auth_token = "your-random-token-here"
# require_loopback = true ← default: aborts startup when bind_address is non-loopback
Without an auth_token any process on the host can call destructive endpoints
(/shutdown, /reload) without credentials.
Loopback enforcement (require_loopback): By default the proxy refuses to start when the
admin bind_address resolves to a non-loopback interface, because all admin traffic — including
the Bearer token — is plain HTTP. To intentionally expose the admin API on a non-loopback address
(e.g. behind a TLS-terminating SSH tunnel), set:
[admin]
bind_address = "0.0.0.0"
require_loopback = false # explicitly acknowledge the risk
Never commit your production config/proxy-config.toml to version control — it contains
secrets (auth token, ACME email), real infrastructure topology, and backend addresses.
Use config/example-config.toml as the template:
cp config/example-config.toml config/proxy-config.toml
# Fill in real values; proxy-config.toml is in .gitignore
# Clone repository
git clone https://github.com/PQCrypta/pqcrypta-proxy.git
cd pqcrypta-proxy
# Build release binary
cargo build --release
# Copy example config and fill in real values (proxy-config.toml is gitignored)
cp config/example-config.toml /etc/pqcrypta/proxy-config.toml
# Validate configuration
./target/release/pqcrypta-proxy --config /etc/pqcrypta/proxy-config.toml --validate
# Run
./target/release/pqcrypta-proxy --config /etc/pqcrypta/proxy-config.toml
# Build Docker image
docker build -t pqcrypta-proxy .
# Run container
docker run -p 80:80 -p 443:443/tcp -p 443:443/udp \
-v /etc/letsencrypt:/etc/letsencrypt:ro \
-v ./config:/etc/pqcrypta:ro \
pqcrypta-proxy
# /etc/pqcrypta/proxy-config.toml
[server]
bind_address = "0.0.0.0" # "[::]" for dual-stack; enable_ipv6 = false downgrades it to IPv4
udp_port = 443
additional_ports = [4433, 4434]
worker_threads = 0 # 0 = one per CPU core; read before the Tokio runtime is built
max_concurrent_multipath_paths = 4 # draft-ietf-quic-multipath; 1 = single-path, 0 = never negotiated
[tls]
cert_path = "/etc/letsencrypt/live/example.com/fullchain.pem"
key_path = "/etc/letsencrypt/live/example.com/privkey.pem"
ocsp_stapling = true # must be true AND [ocsp].enabled for stapling to run
pqc_session_tickets = true # without this, no TLS 1.3 tickets are issued at all
session_ticket_lifetime_secs = 43200
certificate_compression = "auto" # RFC 8879; "auto" (default) or "off"; restart to apply
[http_redirect]
enabled = true
port = 80
# Backend: Apache on port 8080
[backends.apache]
name = "apache"
type = "http1"
address = "127.0.0.1:8080"
tls_mode = "terminate"
# Backend: Rust API on port 3003
[backends.api]
name = "api"
type = "http1"
address = "127.0.0.1:3003"
tls_mode = "terminate"
# Route: api.example.com → API backend
[[routes]]
name = "api-route"
host = "api.example.com"
path_prefix = "/"
backend = "api"
forward_client_identity = true
priority = 100
# Route: example.com → Apache backend
[[routes]]
name = "main-site"
host = "example.com"
path_prefix = "/"
backend = "apache"
forward_client_identity = true
priority = 100
[security]
dos_protection = true
blocked_ips = []
geoip_db_path = "/var/www/html/pqcrypta-proxy/data/geoip/GeoLite2-City.mmdb"
blocked_countries = ["CN", "RU", "KP"]
# Rate limits live in [rate_limiting], not under [security]; the auto-block
# thresholds that react to them are [security] keys.
[rate_limiting]
requests_per_second = 100
burst_size = 200
[circuit_breaker] # top-level, not under [security]
failure_threshold = 5
success_threshold = 2
half_open_delay_secs = 30 # open -> half-open after this long
The advanced rate limiter provides multi-dimensional rate limiting inspired by Cloudflare, Envoy, HAProxy, and ML research. It solves the corporate NAT problem where many users share one gateway IP.
# Basic layer: request rate and, separately, how fast an IP may OPEN connections.
# max_connections_per_ip caps concurrency; a client that opens and closes as fast
# as it can never trips a concurrency cap, which is what connection_rate_limit is for.
# A connection counts once (by its source port) however many requests it carries,
# so a page load multiplexed over one HTTP/2 or HTTP/3 connection is one connection.
[rate_limiting]
enabled = true
requests_per_second = 100
burst_size = 50
connection_rate_limit = true
connections_per_second = 50
[advanced_rate_limiting]
enabled = true
ipv6_subnet_bits = 64 # group /64 subnets so a single host cannot rotate addresses
trusted_proxies = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]
# Key resolution: a table, not a string. The first entry that can be derived
# from the request wins; `fallback` applies when none can.
[advanced_rate_limiting.key_strategy]
order = ["api_key", "jwt_subject", "ja3_fingerprint", "real_ip", "source_ip"]
fallback = "source_ip"
use_composite = false
[advanced_rate_limiting.global_limits]
requests_per_second = 10000
burst_size = 2000
[advanced_rate_limiting.global_limits.per_ip]
requests_per_second = 100
burst_size = 200
requests_per_minute = 1000
requests_per_hour = 10000
[advanced_rate_limiting.global_limits.per_fingerprint]
requests_per_second = 500
burst_size = 100
[advanced_rate_limiting.global_limits.per_api_key]
requests_per_second = 50
burst_size = 100
[advanced_rate_limiting.global_limits.per_composite]
requests_per_second = 200
burst_size = 50
# Adaptive anomaly detection
[advanced_rate_limiting.adaptive]
enabled = true
baseline_window_secs = 3600 # window used to profile normal traffic
sensitivity = 0.7 # 0.0 = laxest, 1.0 = strictest; 0.7 == raw multiplier
auto_adjust = true # tighten each key's limits toward its own baseline
min_samples = 1000 # samples required before adaptive limiting acts
std_dev_multiplier = 3.0 # requests > mean + N*stddev counts as an anomaly
# Per-route limits are keyed by route name and carry their own [.limits] table.
[advanced_rate_limiting.route_limits.login]
pattern = "/api/method/login"
[advanced_rate_limiting.route_limits.login.limits]
requests_per_second = 2
burst_size = 5
requests_per_minute = 20
requests_per_hour = 100
Key Features:
trusted_proxies lists the hops whose XFF header is honoured when deriving the client IPipv6_subnet_bits groups /64 subnets to prevent per-host evasionbaseline_window_secs and flags requests beyond std_dev_multiplier standard deviationssensitivity and auto_adjustA request is anomalous once its rate exceeds mean + k * stddev for its key.
std_dev_multiplier is the raw k; sensitivity is the same control
expressed the way an operator thinks about it, and the two compose rather than
compete. The scale is anchored at the default 0.7, which yields exactly
std_dev_multiplier — so a deployment that leaves sensitivity alone behaves
identically to one that had never heard of it:
sensitivity | effective k at the 3.0 default | effect |
|---|---|---|
| 0.0 | 10.0 | only gross outliers |
| 0.7 | 3.0 | the default, unchanged |
| 1.0 | 0.25 | floored — maximum sensitivity still needs a real deviation |
auto_adjust tightens each key's requests_per_minute and
requests_per_hour toward that key's own learned threshold. The configured
limits are necessarily sized for the busiest plausible caller, so without it
every quiet key carries the loudest key's headroom: an account that normally
makes 5 requests a minute may make 3,000 before anything objects, which is all
the room a credential-stuffing run needs.
Adjustment is one-directional and bounded. The configured limit is a ceiling
that is never exceeded and burst_size is the floor, so a nearly-idle key
cannot be tightened into limiting its own first legitimate burst. Nothing
happens at all until the key's baseline reaches min_samples — a new key is not
throttled for being new. The per-second token bucket is deliberately untouched:
its quota is fixed when the bucket is built, and per-second bursts are what
burst_size and the anomaly detector already answer.
By default all rate limit state lives in per-process memory (DashMap). Each proxy instance counts independently, so in a multi-instance deployment one client can consume their full quota on every node.
Enabling the Redis backend makes all per-key counters shared across every instance. The global token bucket stays in-process (one per node, for local DDoS protection); per-IP, per-fingerprint, per-API-key, and per-composite sliding windows move to Redis using atomic Lua scripts.
[advanced_rate_limiting.redis]
url = "redis://127.0.0.1:6379"
key_prefix = "pqcp" # Namespace prefix for all Redis keys
connect_timeout_ms = 2000 # Abort connection attempt after 2 s
command_timeout_ms = 50 # Per-command timeout; on timeout → silent local fallback
distribute_per_second = true # Include per-second window in Redis (recommended)
Per-key limit tunables (all keys in [advanced_rate_limiting.global_limits.*]):
[advanced_rate_limiting.global_limits.per_api_key]
requests_per_second = 500
burst_size = 250
requests_per_minute = 15000
requests_per_hour = 250000
[advanced_rate_limiting.global_limits.per_composite]
requests_per_second = 200
burst_size = 100
requests_per_minute = 6000
requests_per_hour = 100000
How it works (HTTP/1.1, HTTP/2 via TCP and HTTP/3 via QUIC):
Request arrives on any path
│
├─ [QUIC/HTTP3 only] Simple per-IP SecurityState governor + auto-block
│ (fast in-process check; increments suspicious_patterns counter and
│ auto-blocks repeat offenders — runs before the advanced limiter)
│
├─ Advanced rate limiter (ALL paths: TCP HTTP/1.1 + HTTP/2 + QUIC HTTP/3)
│ │
│ ├─ Global token-bucket (IN-MEMORY — per-node DDoS protection)
│ │
│ ├─ Resolve key (IP / API key / JA3 / JWT / composite)
│ │
│ ├─ Redis available?
│ │ YES → Lua token-bucket (per-second, distributed, atomic EVAL)
│ │ Lua fixed-window (per-minute, distributed, INCR+EXPIRE)
│ │ Lua fixed-window (per-hour, distributed, INCR+EXPIRE)
│ │ ── any command timeout → local DashMap fallback ──
│ │ NO → local DashMap bucket (original behaviour)
│ │
│ └─ Adaptive anomaly detection (always local, per-node)
Graceful fallback: if Redis is unreachable or a command exceeds command_timeout_ms, that single request silently falls back to the local in-memory bucket. No error is returned to the client and no exception is thrown. The proxy remains fully functional without Redis.
Admin API reports redis_connected: true/false in the rate limiter stats snapshot so you can verify the connection is live.
No Redis = zero behaviour change. The [advanced_rate_limiting.redis] section is optional. Omitting it leaves the proxy running exactly as before with in-memory limiting.
[waf]
enabled = true
mode = "block" # "detect" logs only; "block" returns 403
anomaly_threshold = 5 # score at which a request is blocked
# Detection categories
sqli = true # SQL injection patterns
xss = true # Cross-site scripting patterns
path_traversal = true # Traversal and path confusion (../, ..;/, %2e%2e, overlong UTF-8)
nosqli = true # NoSQL injection ($where, $gt, $regex, etc.)
ssrf = true # SSRF (metadata IPs Critical; loopback spellings Info — no FP on prose)
cmd_injection = true # OS command injection, ${IFS}, reverse shells
xxe = true # XML external entity injection
deserialization = true # Java/PHP/Python object injection, incl. byte signatures
jndi = true # JNDI/Log4Shell, OGNL, SpEL expression injection
ssti = true # Server-side template injection (Jinja, Twig, ERB)
file_inclusion = true # php://, expect://, phar://, data:// stream wrappers
crlf_injection = true # Response splitting (URL and headers only)
proto_pollution = true # __proto__, constructor.prototype
graphql = false # Schema introspection — off, legitimate for public schemas
request_anomaly = true # Request smuggling, header injection, diagnostic methods
scanner_probe = true # Reconnaissance probe paths (.git, .env, wp-login, …)
block_scanner_uas = true
# Inspection surface
scan_json_body = true # Scan bodies, decoding JSON string escapes
max_body_scan_bytes = 65536 # Max bytes of body to scan (default 64 KB)
scan_all_headers = true # Every header except negotiation/cache/hints/credentials
max_header_scan_bytes = 8192 # Max bytes of any single header value
max_header_count = 80 # Header count above which a request is anomalous
max_decode_passes = 3 # Percent-decode passes before matching
custom_patterns = [
"(?i)\\bmy-banned-keyword\\b",
]
Anomaly scoring. A rule match contributes its severity's score — Low 3,
Medium 5, High 8, Critical 10 — and the request is blocked once the total
reaches anomaly_threshold. At the default of 5 any single Medium-or-higher
rule blocks on its own, while two Low-severity signals must agree. Low is where
genuinely ambiguous patterns live: localhost in a URL parameter, a bare
exec(, a backtick pair. Raise the threshold to demand more corroboration,
lower it to act on single weak signals. A match below the threshold is still
counted in the per-rule metrics, so a rule can be evaluated before it is trusted
to block.
Rule identifiers. Every rule has a stable identifier of the form
PQW-<CATEGORY>-<NNN>, and verdicts are reported as category:identifier —
bad-bot-ua:PQW-BOT-001, sqli:PQW-SQLI-011. Identifiers are stable across
pattern edits and across category toggles: switching a category off keeps its
rules' metric series in place rather than renumbering everything after them.
Decoding. Inputs are matched raw and in every decoded form: percent-decoding
is applied repeatedly to a fixpoint (max_decode_passes), plus form decoding
(+ → space), HTML character references (<), JSON string escapes
(\u003c), IIS-style %uXXXX escapes, and Unicode fullwidth folding
(\uff1cscript\uff1e → <script>). A rule that matches several decoded forms of the same input counts
once. Bodies are decoded lossily and are always inspected, whether or not they
are valid UTF-8; binary bodies are additionally matched against byte signatures
(Java serialisation header, Python pickle opcodes) that no textual decoding
could preserve.
Transport parity. The WAF runs identically over HTTP/1.1, HTTP/2, HTTP/3
and WebTransport. The TCP and QUIC/h3 handlers both call the one
SecurityState::evaluate (and inspect_body once the body is buffered), so
path, query, header and body inspection are the same on every transport; a
regression corpus is run over both --transport auto (h1/h2) and
--transport h3 to prove that parity, not assume it. WebTransport stream and
datagram payloads are proxied to the same backends as HTTP requests, so they run
through the same inspect_body before reaching a backend — a WebTransport
session is not an uninspected path to /encrypt and friends.
Compressed bodies. A Content-Encoding: gzip|deflate|br|zstd request body
is decompressed (bounded by max_decompressed_body_bytes, so a decompression
bomb cannot exhaust memory) and the decompressed form is scanned — otherwise a
compressed payload is entropy to the pattern engine and passes. The raw
compressed bytes are never text-scanned (they trip binary-ish rules), only
matched against the binary deserialization signatures.
Request anomalies. request_anomaly covers what no regex can express:
conflicting Content-Length headers and Content-Length beside
Transfer-Encoding (request smuggling), transfer codings other than chunked,
control characters in header values, TRACE/TRACK/DEBUG, excessive header
counts, and a Host containing a path, space or userinfo marker.
Per-path exclusions. Tune a false positive without switching a category off site-wide:
[[waf.exclusions]]
path = "^/regex/" # regex matched against the request path
categories = ["xss"] # whole categories to suppress
[[waf.exclusions]]
path = "^/api/graphql"
rules = ["PQW-NOSQL-001"] # or individual rule identifiers
Metrics. The Prometheus endpoint exports pqcrypta_waf_requests_total,
pqcrypta_waf_allowed_total, pqcrypta_waf_blocked_total,
pqcrypta_waf_detected_total, and pqcrypta_waf_rule_hits_total labelled by
rule, category and severity. Only rules that have actually matched are
emitted, so the series count tracks the traffic this node sees rather than the
full rule table.
block_scanner_uas matches against a built-in regex set covering common attack tools. It operates independently from path/payload pattern matching — a request can be blocked purely by its User-Agent even if the body is clean. Disable per-route via waf_mode = "detect" if you need to allow scanner tools from specific paths (e.g., an internal security tooling endpoint).
The scanner-UA check also matches the default curl/N and Wget/N User-Agents, which are the expected clients for artefacts a site publishes for programmatic access (install scripts, datasets, certificate chains). scanner_ua_exempt_paths in [waf] lists paths exempt from it altogether as regular expressions over the lowercased path; injection and path-traversal scanning still runs on them. The default covers only files that exist for tools by convention (/robots.txt, /sitemap.xml, /llms.txt, /.well-known/); a site adds its own, and setting the key replaces the default, so list those too. An invalid pattern is refused at load.
[waf]
scanner_ua_exempt_paths = ['^/robots\.txt$', '^/sitemap\.xml$', '^/\.well-known/']
programmatic_client_paths in [waf] is the narrower list for downloads and APIs: on those paths curl, Wget, python-requests, python-urllib, Go, Java and a missing User-Agent pass, and attack tools, scrapers and headless browsers are still refused. scanner_ua_exempt_paths lifts every user-agent rule, which files read by every kind of crawler need (robots.txt, /.well-known/) and a download does not.
[waf]
programmatic_client_paths = ['^/downloads/', '^/api/dataset\.json$']
A route can declare the same exemption instead: skip_bot_blocking = true on a [[routes]] entry turns off the scanner/bot User-Agent check for that route only, on both the TCP and HTTP/3 paths. Prefer it over adding hardcoded prefixes.
Two narrower flags admit scripts without admitting scanners. Both lift only the generic-client rules (curl, Wget, python-requests, python-urllib, Go, Java, and a missing or blank User-Agent); attack tools such as sqlmap and nikto, scrapers and headless browsers stay blocked whatever the request carries, where skip_bot_blocking lets them through too:
allow_credentialed_clients = true admits those clients when the request carries X-API-Key or Authorization: Bearer. The backend must refuse an invalid credential even on endpoints that need none, or a made-up key becomes the way past the rules.allow_programmatic_clients = true admits them with or without a credential, for public endpoints documented for scripts: a published dataset, a scanner's timeline.[[routes]]
name = "public-dataset"
host = "api.example.com"
path_prefix = "/dataset"
backend = "api"
allow_programmatic_clients = true
Route-level WAF override:
[[routes]]
name = "public-api"
host = "api.example.com"
path_prefix = "/"
backend = "api"
[routes.security]
waf_enabled = true
waf_mode = "block" # override global mode for this route
Per-route policy is resolved before the security checks run, so all of it
applies on both transports. The WAF and rate limiter sit in front of routing, so
the security layer resolves the route itself through the same find_route the
proxy uses downstream — which is what makes [routes.security] and
skip_bot_blocking take effect at all.
[logging]
audit_log_enabled = true
audit_log_path = "/var/log/pqcrypta-proxy/audit.json" # omit to write to stderr
Each audit event is a JSON object with timestamp, level, category, and event-specific fields. Event categories: admin_action, auth_failure, ip_blocked, rate_limit, pqc_downgrade, waf_block, waf_detect, config_reload, tls_reload, ja3_replay, ja3_drift.
[backends.api]
name = "api"
type = "http1"
address = "127.0.0.1:3003"
retries = 3 # default 3
retry_backoff_ms = 50 # initial backoff; doubles each attempt
retry_on = ["connect-failure", "5xx", "timeout"] # default: connect-failure + 5xx
[backends.critical-api]
name = "critical-api"
type = "http1"
address = "127.0.0.1:4000"
[backends.critical-api.circuit_breaker]
failure_threshold = 3 # trip after 3 failures (global default: 5)
half_open_delay_secs = 10 # half-open probe delay (global default: 30)
success_threshold = 1 # close after 1 success (global default: 2)
Controls the HTTP/1.1 connection pool used for requests to backends over TCP. All fields are optional — defaults are production-ready for moderate traffic.
[connection_pool]
idle_timeout_secs = 90 # Close idle connections after 90 s
max_idle_per_host = 10 # Keep up to 10 idle connections per backend
max_connections_per_host = 100 # Limit for backends that do not set their own max_connections.
# NOT a ceiling over an explicit per-backend value — several
# backends are deliberately configured above this figure.
acquire_timeout_ms = 5000 # Fail the request if a connection can't be acquired in 5 s.
# Unbounded waiting turns a backend stall into a proxy-wide stall.
Reducing max_idle_per_host lowers file-descriptor usage on backends with many idle periods. Increasing max_connections_per_host improves burst throughput at the cost of backend fd pressure.
# Load base config, then merge /etc/pqcrypta/proxy-config.prod.toml on top
pqcrypta-proxy --config /etc/pqcrypta/proxy-config.toml --env prod
# Or via environment variable
PQCRYPTA_ENV=prod pqcrypta-proxy --config /etc/pqcrypta/proxy-config.toml
Overlay values win for all matching keys; unmatched keys from the base are kept. The overlay is re-applied on every reload (file watch, POST /reload, SIGHUP), as are the command-line overrides.
[acme]
enabled = true
certificate_transparency = true
# Empty (the default): one log per operator from the CT log list, the one
# whose temporal shard holds the certificate's expiry. A log takes only
# certificates expiring inside its shard (half a year), so a fixed list goes
# stale: the Xenon 2025H1 shard this example used to name now answers 404.
ct_logs = []
ct_log_list = "https://www.gstatic.com/ct/log_list/v3/log_list.json"
Off by default: it serves malformed protocol output on purpose. Each test owns
one UDP port in port_range, and startup refuses a range that overlaps
server.udp_port or server.additional_ports, or one too narrow for the
catalogue, rather than serving the tests that fit.
[conformance]
enabled = false # master switch
host = "conformance.pqcrypta.com" # the vhost for the catalogue, reports and badge
port_range = [4460, 4600] # inclusive; one UDP port per test
session_ttl_secs = 3600 # how long one client's verdicts are kept
max_sessions = 512 # cap on concurrent sessions
liveness_timeout_ms = 5000 # wait for the probe that follows each anomaly
# The ML-DSA-87 chain t-cert-compression-pq serves, and its key. Verdicts on
# that port quote the chain's size as loaded from here, so replacing the file
# changes what they say.
pq_chain_cert = "/etc/pqcrypta/pqc-certs/conformance/fullchain.pem"
pq_chain_key = "/etc/pqcrypta/pqc-certs/conformance/server.key"
A session has its own host name, <session>.<host> (the sni in POST /session's
answer). A connection whose server name is a session's is filed under that session
from any address; one without (a test that ends before the server name is read)
under the address that started the session. The name needs a DNS name covering it
(*.<host>) and a wildcard certificate (*.<host> in [acme] domains, validated
by DNS challenge). POST /session/<id>/address files the caller's address under an
existing session too: a dual-stack host's tests that end before a server name is sent
then find the session over either family. h3-conformance adds its host's address in
each family the suite resolves to, and gives its client the session's name when the
suite serves it (checked by fetching the catalogue under that name), the suite's own
name otherwise, where only one session at a time can run from one address.
[server]
webtransport_max_sessions_per_origin = 100 # max concurrent sessions per Origin header
webtransport_max_streams_per_session = 1000 # max concurrent streams per session
webtransport_max_datagrams_per_sec = 500 # datagram rate limit per session
[server]
enable_quic_migration = true # default true; set false to disable client IP migration
[[routes]]
name = "secure-api"
host = "api.example.com"
path_prefix = "/"
backend = "api"
[routes.security]
mtls_required = true # require client certificate on this route
allowed_ja3 = ["abc123...", "def456..."] # allowlist of known-good JA3 fingerprints
waf_enabled = true
waf_mode = "block"
enable_0rtt = false # deny 0-RTT early data on this route
# Global load balancer settings
[load_balancer]
enabled = true
default_algorithm = "least_connections" # Options: least_connections, round_robin, weighted_round_robin, random, ip_hash, least_response_time
# Session affinity (sticky sessions) settings
[load_balancer.session_affinity]
cookie_name = "PQCPROXY_BACKEND"
cookie_ttl_secs = 3600
cookie_secure = true
cookie_httponly = true
cookie_samesite = "lax" # Options: strict, lax, none
# Request queue when all backends busy
[load_balancer.queue]
enabled = true
max_size = 1000
timeout_ms = 5000
# Slow start for recovering servers
[load_balancer.slow_start]
enabled = true
duration_secs = 30
initial_weight_percent = 10
# Connection draining for graceful removal
[load_balancer.connection_draining]
enabled = true
timeout_secs = 30
# Backend pool with multiple servers
[backend_pools.api]
name = "api"
algorithm = "least_connections"
health_aware = true
affinity = "cookie" # Options: none, cookie, ip_hash, header
health_check_path = "/health"
health_check_interval_secs = 10
# Primary server
[[backend_pools.api.servers]]
address = "127.0.0.1:3003"
weight = 100
priority = 1
max_connections = 100
timeout_ms = 30000 # Bounds the forwarded request; on expiry the client gets 504 Gateway Timeout
tls_mode = "terminate"
# Secondary server
[[backend_pools.api.servers]]
address = "127.0.0.1:3004"
weight = 100
priority = 1
max_connections = 100
# Failover server (only used when primary/secondary unavailable)
[[backend_pools.api.servers]]
address = "10.0.0.5:3003"
weight = 50
priority = 2 # Lower priority = failover only
max_connections = 50
Route to Pool: Routes can reference either single backends or backend pools:
# Route using a backend pool
[[routes]]
name = "api-route"
host = "api.example.com"
path_prefix = "/"
backend = "api" # References backend_pools.api
priority = 100
Canary routing lets you ship a new server version to a small percentage of traffic while stable servers handle the rest. The configuration lives in a [backend_pools.NAME.canary] subsection placed before the first [[NAME.servers]] entry. Canary routing is active on all transport protocols: HTTP/1.1, HTTP/2, and HTTP/3/QUIC.
[backend_pools.api-pool]
name = "api-pool"
algorithm = "weighted_round_robin"
# Pool-level canary settings — place before [[servers]] entries
[backend_pools.api-pool.canary]
enabled = true # activate canary routing
sticky = true # keep each client on the same canary
sticky_cookie_name = "PQCPROXY_CANARY" # cookie set on first canary assignment
sticky_cookie_ttl_secs = 3600 # sticky assignment lifetime (seconds)
sticky_header = "X-Canary-Group" # optional: pre-assign group via request header
auto_rollback = true # suspend canary on high error rate
rollback_error_rate = 0.05 # error rate threshold (5 %)
rollback_window_secs = 60 # sliding window length (seconds)
rollback_min_requests = 10 # minimum requests before rollback can trigger
# Canary server — mark with canary = true, set canary_weight_percent
[[backend_pools.api-pool.servers]]
address = "127.0.0.1:3005"
canary = true # designate as canary
canary_weight_percent = 5 # route 5 % of new traffic here
weight = 100
priority = 1
max_connections = 100
timeout_ms = 30000
tls_mode = "terminate"
# Stable server — receives remaining 95 % of traffic
[[backend_pools.api-pool.servers]]
address = "127.0.0.1:3003"
weight = 100
priority = 1
max_connections = 100
timeout_ms = 30000
tls_mode = "terminate"
How it works:
PQCPROXY_CANARY cookie matching a live canary server, it is routed there (sticky assignment).canary_weight_percent. If the roll is lower, the request goes to the canary and a Set-Cookie header is added to the response for future stickiness.auto_rollback = true and the canary's error count within the sliding window exceeds rollback_error_rate × requests, the canary is suspended and all traffic falls back to stable servers.Live admin control (all endpoints require Bearer token auth):
GET /canary — current status of every canary server across all pools
POST /canary/suspend/:server_id — suspend a canary immediately
POST /canary/resume/:server_id — re-enable a suspended canary (resets error window)
POST /canary/weight/:server_id — adjust weight at runtime
body: {"percent": 10}
Example:
curl -s http://127.0.0.1:8082/canary \
-H "Authorization: Bearer <token>"
# {"pools":[{"pool":"api-pool","servers":[{"id":"127.0.0.1:3005","is_canary":true,
# "canary_weight_percent":5,"suspended":false,"error_rate":0.0,...}]}]}
Add a [routes.shadow] subsection to any route to mirror traffic to a secondary backend. The client only receives the primary response; the shadow response is logged and discarded. All values are configurable — nothing is hardcoded. Shadow mirroring is active on all transport protocols: HTTP/1.1, HTTP/2, and HTTP/3/QUIC.
[[routes]]
name = "api-route"
host = "api.example.com"
backend = "api-stable"
# Mirror 10 % of traffic to a canary instance for dark testing
[routes.shadow]
backend = "api-canary" # Must be a key in [backends.*]
percent = 10 # 0–100 % of requests to mirror (default 100)
timeout_ms = 5000 # Abandon shadow after this many ms (default 5000)
shadow_header = "X-Shadow-Request" # Header injected on shadow requests (default)
shadow_header_value = "1" # Value for that header (default "1")
log_responses = true # Log shadow status + latency at INFO (default true)
How it works:
tokio::task::spawn fires the shadow copy asynchronously — the primary forward proceeds in parallel.Activating on the running config:
[backends.api-canary] entry pointing at the canary instance (e.g. 127.0.0.1:3004).[routes.shadow] block to the desired route in /etc/pqcrypta/proxy-config.toml.curl -s -X POST http://127.0.0.1:8082/reload -H "Authorization: Bearer $TOKEN".The proxy includes an RFC 9111-compliant HTTP response cache. It is disabled by default — add a [cache] section to opt in. The cache stores raw backend response bodies (pre-compression); all outer middleware layers (security headers, Alt-Svc, Brotli/gzip) run on every served response, including cache hits.
What is cached: GET and HEAD responses with a cacheable status code (200, 203, 204, 206, 300, 301, 410) that carry Cache-Control: public or no Cache-Control directive. Responses with Cache-Control: no-store, private, no-cache, or Vary: * are never stored. Responses that set cookies are skipped by default.
Conditional request support: ETag / If-None-Match (strong and weak) and Last-Modified / If-Modified-Since — the cache returns 304 Not Modified and avoids body transfer when content is unchanged.
[cache]
enabled = true # default false — must opt in
max_size_mb = 128 # total cache size cap in MiB
default_ttl_secs = 60 # TTL when backend sends no Cache-Control max-age
max_body_size_bytes = 2097152 # responses larger than ~2 MiB are forwarded but not stored
no_cache_set_cookie = true # skip caching responses that set cookies
# Path prefixes that bypass the cache entirely
excluded_paths = ["/api/", "/ws", "/stream", "/auth", "/admin"]
# Exact hostnames (or subdomain suffixes) whose responses are never cached.
# Use this for API subdomains whose backends omit Cache-Control: no-store.
excluded_hosts = ["api.example.com"]
The cache layer is placed as the innermost Axum layer so security headers and compression always apply regardless of whether the response came from the cache or the backend.
Decrypt TLS at proxy, plain HTTP to backend.
[backends.apache]
name = "apache"
type = "http1"
address = "127.0.0.1:8080"
tls_mode = "terminate" # Default - can be omitted
Decrypt at proxy, re-encrypt to backend via HTTPS.
[backends.internal-api]
name = "internal-api"
type = "http1"
address = "internal.example.com:443"
tls_mode = "reencrypt"
tls_cert = "/path/to/ca.pem" # Optional: custom CA
tls_client_cert = "/path/to/client.pem" # Optional: mTLS client cert
tls_client_key = "/path/to/client.key" # Optional: mTLS client key
tls_skip_verify = false # DANGEROUS if true
tls_sni = "internal.example.com" # Optional: custom SNI
Route based on SNI without decryption.
[[passthrough_routes]]
name = "external-service"
sni = "external.example.com" # Supports wildcards: *.example.com
backend = "10.0.0.5:443"
proxy_protocol = false # Optional: PROXY protocol v2
timeout_ms = 30000
[headers]
hsts = "max-age=63072000; includeSubDomains; preload"
x_frame_options = "DENY"
x_content_type_options = "nosniff"
referrer_policy = "strict-origin-when-cross-origin"
permissions_policy = "camera=(), microphone=(), geolocation=()"
cross_origin_opener_policy = "same-origin"
cross_origin_embedder_policy = "require-corp"
cross_origin_resource_policy = "same-origin"
# Custom branding headers
x_quantum_resistant = "ML-KEM-1024, ML-DSA-87, X25519MLKEM768"
x_security_level = "Post-Quantum Ready"
This set is applied by the same rules on HTTP/1.1, HTTP/2 and HTTP/3, and on
cache hits as well as origin responses: one ResponseHeaderSet, which the TCP
listener applies in an Axum layer and the HTTP/3 listener in
apply_response_policy_headers. Most headers replace whatever the backend
sent. The origin's own value wins for three cross-origin policies
(Cross-Origin-Opener-Policy, -Embedder-Policy, -Resource-Policy) and for
content_security_policy. The configured value is used only when the backend
sent none, so a page can make itself cross-origin isolated, and a CSP carrying
per-request script nonces passes untouched.
A route's own decisions stand over all of it, on every transport. Its
headers_override values replace the backend's value and the global one,
whatever the header (an x-frame-options override included). The headers in
its remove_headers stay removed, and stripe_compatibility sends no COOP or
COEP at all. A header the route removes and also overrides goes out with the
override.
HTTP/3 forwards the backend's response headers as HTTP/1.1 and HTTP/2 do, every
one but the hop-by-hop headers. It used to forward only an allowlist. That
dropped any header not on it, such as a page's cross-origin policies, Link,
Retry-After and WWW-Authenticate. It also added the security headers on top
of the backend's and the route's instead of replacing them, so a route with its
own policies (Grafana) answered over HTTP/3 with two contradictory values of
each, which a browser treats as none.
Every value is optional: an empty string omits that header on every
transport. accept_ch, nel, report_to and priority are empty unless set,
and the Outlook add-in exception (addin_hosts, addin_path_prefix,
addin_csp) is off unless all three are configured. server_timing_enabled
adds a Server-Timing metric whose description is server_timing_desc. The set
is validated once at startup; a value that is not a legal header value is
logged and omitted.
headers_override on a route takes literal header names
(cross-origin-embedder-policy), not the snake_case keys of this section. The
proxy refuses to load a route that spells a managed header the [headers] way,
because that sends a header of the literal name, which no browser reads, while the
real policy stays in force.
The TCP middleware chain is assembled once at startup, and a layer whose
feature is off is left out, not skipped per request: response cache,
security headers (when every value is empty and Server-Timing is off), Alt-Svc
(when nothing is advertised and tcp_only_hosts is empty), compression, the
HTTP/3-features layer (coalescing and priority hints both off), the advanced
rate limiter, and trace-context extraction (when OTel is off and INFO spans are
filtered out). The startup log names every layer left out. The security
evaluator always runs; it carries the request-size, header-size and blocklist
enforcement that has no switch of its own. The chain is not rebuilt on a hot
reload, so enabling one of these needs a restart.
[compression]
enabled = true # default
min_size = 1024 # bytes; smaller responses are sent as-is
brotli_quality = 4
zstd_level = 3
gzip_level = 6
compress_types = ["text/html", "text/css", "application/json"] # exact MIME type; charset etc. ignored
Applies to HTTP/1.1 and HTTP/2 responses. Before this section existed the compression settings were fixed in code and could not be changed or turned off.
[[routes]]
name = "api-cors"
host = "api.example.com"
path_prefix = "/"
backend = "api"
[routes.cors]
allow_origin = "https://example.com" # must be a specific origin when allow_credentials = true
allow_methods = ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
allow_headers = ["Content-Type", "Authorization", "X-API-Key"]
allow_credentials = true
max_age = 86400
[[routes]]
name = "app"
path_prefix = "/app"
backend = "app"
enforce_cookie_security = true # add HttpOnly and Secure where the backend omitted them
set_cookie_domain = "example.com" # add Domain where the backend omitted it
Both apply to every backend Set-Cookie on HTTP/1.1, HTTP/2 and HTTP/3,
streamed (SSE) or buffered, and attributes are matched by name, so a cookie
named securetoken is not mistaken for one carrying Secure.
Configuration validation rejects
allow_origin = "*"combined withallow_credentials = trueat startup (RFC 6454 / CORS spec). All modern browsers refuse this combination; the proxy enforces it at load time rather than producing confusing runtime failures. Use a specific origin string when credentials are required.
See config/example-config.toml for full documentation.
pqcrypta-proxy [OPTIONS]
Options:
-c, --config <PATH> Configuration file [default: /etc/pqcrypta/config.toml]
--udp-port <PORT> Override UDP port for QUIC
--admin-port <PORT> Override admin API port
--log-level <LEVEL> Log level [default: info]
--json-logs Enable JSON log format
--no-pqc Disable PQC hybrid key exchange
--watch-config Watch config file for changes [default: true]
--validate Validate configuration only
-h, --help Print help
-V, --version Print version
Environment variables: PQCRYPTA_CONFIG, PQCRYPTA_UDP_PORT, PQCRYPTA_ADMIN_PORT, PQCRYPTA_LOG_LEVEL, PQCRYPTA_JSON_LOGS, PQCRYPTA_ENV
Set PQCRYPTA_ENV=production to explicitly declare a production deployment. Set PQCRYPTA_ENV=development to permit development-only options (such as tls_skip_verify) when ACME is not enabled. When ACME is active the environment is always treated as production regardless of this variable.
┌──────────────────────────────────────────────────────────┐
│ PQCProxy v0.2.2 │
│ │
Client ──────────►│ Port 80 ─► HTTP Redirect Server ─► HTTPS (301/308) │
(Browser/App) │ │
│ Port 443 ─► TLS Termination ─► Reverse Proxy │
│ │ │ │
│ │ ├─► HTTP/1.1, HTTP/2 (TCP) │
│ │ ├─► HTTP/3 (QUIC/UDP) │
│ │ └─► WebTransport Sessions │
│ │ │
│ └─► TLS Passthrough ─► SNI Routing (no decrypt) │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Security Middleware Stack │ │
│ │ JA3/JA4 → Rate Limit → GeoIP → Circuit Breaker │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ HTTP/3 Features Middleware │ │
│ │ Early Hints → Priority → Coalescing → Compression │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Route Engine │ │
│ │ - Domain matching (api.example.com vs example.com) │ │
│ │ - Path matching (prefix, exact, regex) │ │
│ │ - CORS handling │ │
│ │ - Redirect rules │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Load Balancer │ │
│ │ Algorithms: least_conn | round_robin | weighted │ │
│ │ random | ip_hash | least_response_time │ │
│ │ Features: Session affinity, Health-aware routing │ │
│ │ Slow start, Connection draining │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Backend Pools │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ TLS │ │ TLS │ │ TLS │ │ │
│ │ │ Terminate │ │ Re-encrypt │ │ Passthrough │ │ │
│ │ │ (HTTP) │ │ (HTTPS) │ │ (SNI) │ │ │
│ │ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │ │
│ └─────────┼────────────────┼────────────────┼─────────┘ │
│ │ │ │ │
│ ▼ ▼ ▼ │
│ Pool: Apache Pool: API Pool: External │
│ ┌────┬────┬────┐ ┌────┬────┬────┐ ┌────┬────┐ │
│ │ S1 │ S2 │ S3 │ │ S1 │ S2 │ S3 │ │ S1 │ S2 │ │
│ └────┴────┴────┘ └────┴────┴────┘ └────┴────┘ │
│ │
│ Admin API (HTTP 8082) │
│ /health, /metrics, /reload, /shutdown │
└──────────────────────────────────────────────────────────┘
src/
├── main.rs # Entry point; --env overlay; startup validation
├── lib.rs # Library exports
├── config.rs # Configuration parsing, schema versioning, conflict validation, env overlay
├── load_balancer.rs # Load balancing algorithms, pools, session affinity, per-backend CB overrides
├── proxy.rs # Backend pool, request routing, per-backend retry with exponential backoff
├── http_listener/ # HTTP/1.1 + HTTP/2 listener with PQC TLS; layers (security
│ # headers, Alt-Svc, rate limit), TLS config, PROXY v2, passthrough
├── quic_listener/ # QUIC/HTTP/3 listener; configurable connection migration;
│ # request.rs holds the h3 request pipeline and response policy headers
├── security.rs # Rate limiting, DoS, GeoIP, circuit breaker, WAF hook, body size limit
├── fingerprint.rs # JA3/JA4 TLS fingerprint extraction, replay cache, drift detector
├── tls_acceptor.rs # Custom TLS acceptor with fingerprint capture; 0-RTT nonce store
├── compression.rs # Brotli/Zstd/Gzip compression
├── http3_features.rs # Early Hints, Priority, Request Coalescing
├── admin.rs # Admin API endpoints; audit logging; /health/quic; /health/webtransport
├── tls.rs # TLS configuration; PQC session tickets
├── pqc_tls.rs # Post-Quantum TLS provider; downgrade detection
├── pqc_extended.rs # Extended PQC configuration and capabilities
├── acme.rs # ACME certificate automation (DNS and IP identifiers, ARI renewal); Certificate Transparency log submission; domain path validation
├── acme_dns.rs # ACME dns-01 / dns-account-01: TXT records by TSIG-signed DNS UPDATE, waited for at every name server
├── dns_wire.rs # DNS message reading and writing shared by the DoH gateway and acme_dns
├── ocsp.rs # OCSP stapling automation
├── metrics.rs # Prometheus metrics registry
├── rate_limiter.rs # Advanced multi-dimensional rate limiting; JWT HMAC verification
├── proxy_protocol.rs # PROXY protocol v2 support
├── access_logger.rs # Structured access log with log-injection sanitization
├── waf.rs # WAF engine — 256-rule RegexSet, PQW-* IDs, anomaly scoring, multi-pass decoding, per-rule metrics; enforced on TCP, HTTP/3 and WebTransport (A01/A03/A06/A08/A10)
├── audit_logger.rs # Async structured JSON audit logger for security events
└── webtransport_server.rs # WebTransport session handling; per-origin session rate limiting
PQCrypta Proxy supports hybrid PQC key exchange using rustls-post-quantum (X25519MLKEM768).
| Algorithm | Security Level | Description |
|---|---|---|
X25519MLKEM768 | NIST Level 3 | Hybrid X25519 + ML-KEM-768 — recommended default (FIPS 203) |
SecP256r1MLKEM768 | NIST Level 3 | Hybrid P-256 + ML-KEM-768 (FIPS 203) |
SecP384r1MLKEM1024 | NIST Level 5 | Hybrid P-384 + ML-KEM-1024 (FIPS 203) |
curveSM2MLKEM768 | NIST Level 3 | Hybrid curveSM2 + ML-KEM-768 (draft-yang-tls-hybrid-sm2-mlkem, 0x11EE): rustls through the vendored fork's sm2 feature, OpenSSL from 4.0. Interoperates with OpenSSL 4.0.3 in both directions (examples/kx-interop.rs) |
X448MLKEM1024 | NIST Level 5 | Hybrid X448 + ML-KEM-1024 (FIPS 203) |
mlkem512 | NIST Level 1 | Pure ML-KEM-512 (FIPS 203) |
mlkem768 | NIST Level 3 | Pure ML-KEM-768 (FIPS 203) |
mlkem1024 | NIST Level 5 | Pure ML-KEM-1024 (FIPS 203) |
kyber768 ⚠️ | NIST Level 3 | Deprecated — pre-NIST round-3 draft, not FIPS 203. Requires --features legacy-pqc at build time. Not interoperable with ML-KEM peers. Do not use for new deployments. |
x25519_kyber768 ⚠️ | NIST Level 3 | Deprecated — pre-NIST round-3 draft hybrid, not FIPS 203. Requires --features legacy-pqc at build time. |
Only FIPS 203-compliant algorithms are built by default.
kyber768andx25519_kyber768(pre-standardisation Kyber drafts) are excluded from all default builds. To enable them only for backward-compatible migration periods, compile withcargo build --release --features legacy-pqc. A deprecation warning is logged at startup whenever a legacy algorithm is selected.
With the default pqc-signatures feature, the SNI certificate resolver serves ML-DSA-87-signed X.509 certificates for domains whose key file contains an ML-DSA-87 PKCS#8 private key (OID 2.16.840.1.101.3.4.3.19). Drop {domain}.crt (full PQ chain) and {domain}.key into the certs directory alongside the classical pairs — detection is automatic per domain, on the same TCP :443 and QUIC listeners.
CertificateVerify is signed with ML-DSA-87 (TLS signature scheme mldsa87, codepoint 0x0906) via aws-lc-rsseed [0], expandedKey, and the seed+expanded both form OpenSSL 3.5 writesmldsa87 in signature_algorithms are refused (no silent classical fallback on that SNI)-groups X25519MLKEM768 this yields a fully post-quantum handshake: PQ key exchange + PQ certificate signature# Connect with OpenSSL 3.5+
openssl s_client -connect pqc.pqcrypta.com:443 -sigalgs mldsa87 \
-groups X25519MLKEM768 -CAfile root-ca.crt
A delegated credential is a short-lived key that a certificate's own key has signed for: the handshake is signed with it, so the certificate's key can stay offline and a compromised credential expires within a day. The SNI certificate resolver mints them for any certificate that allows delegation, one with the DelegationUsage extension (1.3.6.1.4.1.44363.44) and the digitalSignature key usage, which clients require before they accept one.
pqc.enable_signatures is off, then ECDSA P-256. A client gets the first whose scheme it lists in its delegated_credential extension and whose signature it can verify (the certificate signed it with a scheme in the client's signature_algorithms); every other client gets the certificate's own signatureCertificate, and the delegated key signs CertificateVerify under the scheme the credential names. TLS 1.3 only, as RFC 9345 requires; served wherever rustls terminates TLS, which is QUIC (HTTP/3) here. The OpenSSL TCP listener does not implement themDelegatedCredential::delegate signs a credential (RFC 9345 §4), the server sends one only to a client that offered it, and its client aborts with unexpected_message on one it never asked for (§4.1.1)No public CA issues the DelegationUsage extension, so scripts/delegated-credential-cert.sh issues the certificate from a private CA for a test host (delegated-credential.conformance.pqcrypta.com) and renews it weekly through delegated-credential-cert.timer; the proxy picks it up on its TLS reload and mints the credentials itself.
cp scripts/delegated-credential-cert.sh /usr/local/bin/pqcrypta-delegated-credential-cert
cp scripts/delegated-credential-cert.{service,timer} /etc/systemd/system/
systemctl enable --now delegated-credential-cert.timer
[pqc]
enabled = true
provider = "openssl3.5"
openssl_path = "/usr/local/openssl-4.0.3/bin/openssl"
openssl_lib_path = "/usr/local/openssl-4.0.3/lib64"
preferred_kem = "X25519MLKEM768"
fallback_to_classical = true # offer P-384 so non-ML-KEM clients can connect
require_pqc_provider = true # refuse to start if our own PQC provider is broken
experimental_groups = ["frodo1344shake", "frodo976shake"] # OQS provider groups, TCP only
These two were one flag until they were split, and the split matters because they answer different questions.
These settings shape the groups on every listener, TCP and QUIC alike. With PQC enabled the offer is every hybrid the TLS stack implements — X25519MLKEM768, SecP256r1MLKEM768 and SecP384r1MLKEM1024 on both OpenSSL and rustls (the last added to the vendored rustls), and curveSM2MLKEM768 on rustls and on OpenSSL 4.0 or later — with preferred_kem first. The OpenSSL list is checked name by name against the linked library, and a KEM with no TLS group codepoint (X448MLKEM1024 in OpenSSL 3.5) is left out rather than failing the list. A pure ML-KEM group is offered only when preferred_kem names it, and never under require_hybrid. The classical fallback is one tuple, P-384, P-256 and X25519, on every listener: a classical client's own key share is used, so a client with no ML-KEM (Safari 18 and earlier) completes in one round trip, and one that sent no share the server accepts is asked for P-384. RFC 8446 §9.1 has every TLS 1.3 implementation support P-256 (MUST) and X25519 (SHOULD); QUIC stacks such as picotls offer nothing classical but P-256. With pqc.enabled = false only the classical groups are offered.
experimental_groups names key-exchange groups that a loaded OpenSSL provider adds and IANA has not assigned — the OQS provider's FrodoKEM, HQC and the like, by their OpenSSL names. The OpenSSL (TCP) listener offers them in a tuple of their own, after the standard ML-KEM tuples and before the classical fallback: a client that offers ML-KEM still gets ML-KEM, and one that offers only these and a classical group gets post-quantum. No browser offers them, so nothing changes for visitors; an OQS client, or a scanner that asks group by group, sees them. Each name is checked against the linked library like the rest; under require_hybrid only the hybrids (named for their classical half, p384_frodo976shake) are offered, and min_security_level does not apply. Standard and classical names are refused at startup, since they have their own settings and their own place in the order. rustls (QUIC) implements none of them. pqcrypta.com offers FrodoKEM-1344 and FrodoKEM-976 (SHAKE), the conservative LWE KEMs its own engines implement, and HQC-256 and HQC-192 (hqc5, hqc3), the code-based KEM NIST selected in 2025, which its hqc-128/192/256 engines implement: each level's FrodoKEM, then its HQC. HQC comes from liboqs 0.16, whose implementation is the official one of the 2025-08-22 specification, not the PQClean code CVE-2025-48946 concerned.
Until 2026-09-24 the OpenSSL listener logged the group list these settings produced and applied a fixed X25519MLKEM768:P-384, and the QUIC listener ignored them — so preferred_kem, require_hybrid and fallback_to_classical did nothing, and pqc.enabled = false still negotiated the hybrid on HTTP/3.
fallback_to_classical is client compatibility: whether the classical fallback is offered alongside the hybrid PQC groups. With it off, a client with no ML-KEM support cannot complete a handshake at all — on this deployment that measurably includes curl built against quictls, third-party uptime monitoring and several crawlers, all of which reach the site today over P-384. It is a live lockout, not a posture setting. require_hybrid = true states the same lockout deliberately.
require_pqc_provider is our own posture: what happens when PQC is enabled and its provider cannot be used. It defaults to refusing to start. Previously the only way to fail closed here was to turn off fallback_to_classical, which also cut off every non-PQC client — so the safe choice carried a price nobody wanted to pay, and the unsafe one was the default.
Whichever setting removes the classical group, the proxy now logs a warning saying so. It used to warn for require_hybrid and stay silent for fallback_to_classical = false, so the more obscure route to a client lockout was also the quieter one.
Important:
openssl_pathmust point to an OpenSSL 3.5+ binary built with ML-KEM support. The proxy checks this path at startup to decide whether the PQC TCP listener is available. Withrequire_pqc_provider = true(the default) a wrong or missing path stops startup; with it false the proxy falls back to a standard rustls listener that accepts TLS 1.2 and does not negotiate X25519MLKEM768. Verify the path before deploying either way.
OpenSSL 4.0. The deployment's TCP listener links OpenSSL 4.0.3, with
liboqs 0.16.0 and oqs-provider 0.12.0 for the FrodoKEM and HQC groups, from its
own prefix, /usr/local/openssl-4.0.3. 4.0 is what adds curveSM2MLKEM768 to
TCP (QUIC has it through the vendored rustls). 4.0's library soname is
libssl.so.4, so the binary carries the prefix as DT_RPATH
(RUSTFLAGS="-C link-arg=-Wl,--disable-new-dtags,-rpath,/usr/local/openssl-4.0.3/lib64"
with OPENSSL_DIR pointing at the prefix) and loads it whatever
LD_LIBRARY_PATH says. That leaves the system's OpenSSL 3, and everything
else linked against it, as it was. The openssl crate has to be 0.10.81 or
later: 4.0 made many X509 functions take and return const pointers, and older
releases do not compile against it.
Automatic Let's Encrypt certificate provisioning and renewal. Issues one individual certificate per domain — each domain gets its own {domain}.crt / {domain}.key pair written atomically (write-to-.tmp-then-rename) to prevent corruption on concurrent renewal. With use_ecdsa, keys are ECDSA P-384 by default (ecdsa_curve = "P-256" for smaller certificates).
SNI-based cert selection is handled by MultiDomainCertResolver (rustls/QUIC) and create_pqc_acceptor_with_sni (OpenSSL/TCP), both loading all cert pairs from the certs directory at startup and on every ACME renewal. A server name gets its own certificate, or the wildcard one that covers it (*.example.com.crt for a.example.com): the wildcard is the whole left-most label and stands for exactly one label (RFC 9525 §6.3).
A domain's certificate can also name IP addresses (ip_addresses, RFC 8738 identifiers): the order carries them beside the domain, under the CA's profile for addresses (ip_profile; Let's Encrypt issues them only under shortlived, 160-hour certificates), and the HTTP-01 challenge is answered on port 80 of each address. A client that connects by address sends no server name (RFC 6066 has no form for one) and verifies the certificate against the address it dialled (RFC 9525 §6.4); both TLS stacks give such a ClientHello the certificate that names the host's addresses, and fall back to tls.cert_path where none does. A ClientHello whose server name is an address, bare or bracketed, is answered the same way: RFC 6066 allows none there, but GnuTLS clients such as kdig send one, and rustls (the QUIC listener) ignores it. A name server's DNS over HTTPS reached by its IP — as dnspython's dns.query.https() reaches it, verifying the address — is the case this exists for: with a certificate naming only ns1.example.com, that client aborts the handshake.
renewal_days before expiry or a third of the lifetime before, whichever is less (~day 60 of a 90-day cert, ~hour 107 of a 160-hour one). A certificate whose names or addresses differ from the configuration is reissued at oncereplaces (RFC 9773 §5), which Let's Encrypt exempts from its rate limits. The next check comes at check_interval_hours, or sooner when a renewal falls due first[acme]
enabled = true
domains = ["example.com", "api.example.com"]
email = "[email protected]"
directory_url = "https://acme-v02.api.letsencrypt.org/directory" # Production
# directory_url = "https://acme-staging-v02.api.letsencrypt.org/directory" # Staging
challenge_type = "http-01"
certs_path = "/etc/pqcrypta/certs"
account_path = "/etc/pqcrypta/acme/account.json"
renewal_days = 30 # Without ARI: 30 days before expiry, or a third of the lifetime if less
check_interval_hours = 24 # Once daily, sooner when a renewal falls due first
use_ecdsa = true # ECDSA, P-384 unless ecdsa_curve = "P-256"
accept_tos = true
# A domain's certificate can also name its host's addresses (RFC 8738): a
# client connecting by address authenticates it, and gets it when it sends
# no server name. The domain must be in `domains`.
ip_addresses = { "api.example.com" = ["192.0.2.10", "2001:db8::10"] } # its public addresses
ip_profile = "shortlived" # Let's Encrypt issues addresses only under this profile
# DNS challenges instead of HTTP-01 (challenge_type = "dns-01"), and for any
# wildcard in `domains` ("*.example.com") whatever challenge_type says
# [acme.dns_update]
# server = "127.0.0.1:53" # takes the UPDATE (primary, or a forwarding secondary)
# key_name = "acme-node1" # TSIG key, as that server knows it
# algorithm = "hmac-sha256" # or "hmac-sha512"
# secret_file = "/etc/pqcrypta/acme/dns-update.key" # its secret, base64, 0600
# propagation_timeout_secs = 120 # every name server must serve the record by then
# External Account Binding (required by ZeroSSL, optional for Let's Encrypt)
# eab_kid = "your-kid"
# eab_hmac_key = "your-hmac-key"
| Type | Description | Requirements |
|---|---|---|
http-01 | HTTP validation on port 80 | Port 80 accessible, served by redirect server |
dns-01 | A TXT record, published by DNS UPDATE (RFC 2136) signed with TSIG (RFC 8945, HMAC-SHA256 or -SHA512), waited for at every name server of its zone, removed after validation; dns-account-01 (draft-ietf-acme-dns-account-label, one record name per ACME account, so several nodes validate one name at once) where the CA offers it | [acme.dns_update]: a server that takes the update (the zone's primary, or a secondary that forwards it, as Knot DNS does) and a TSIG key |
An IP address identifier is always validated by HTTP-01, whatever challenge_type says: RFC 8738 §7 allows no DNS challenge for one. A wildcard (*.example.com in domains) is always validated by a DNS challenge, the only kind a CA offers for one, so a configuration with a wildcard and no [acme.dns_update] is refused at load.
| CA | Directory URL |
|---|---|
| Let's Encrypt | https://acme-v02.api.letsencrypt.org/directory |
| Let's Encrypt Staging | https://acme-staging-v02.api.letsencrypt.org/directory |
| ZeroSSL | https://acme.zerossl.com/v2/DV90 (requires EAB) |
| Buypass | https://api.buypass.com/acme/directory |
| Google Trust Services | https://dv.acme-v02.api.pki.goog/directory |
Automated OCSP response fetching with background refresh.
[ocsp]
enabled = true
cache_duration_secs = 3600 # 1 hour cache
refresh_before_expiry_secs = 300 # Refresh 5 min before expiry
timeout_secs = 10
max_retries = 3
# Check OCSP status
curl http://127.0.0.1:8082/ocsp
# Force refresh
curl -X POST http://127.0.0.1:8082/ocsp/refresh
Public (no authentication required):
| Endpoint | Method | Description |
|---|---|---|
/health | GET | Minimal health check — safe for load-balancer probes (F-03) |
Protected (Bearer token required):
| Endpoint | Method | Description |
|---|---|---|
/metrics | GET | Prometheus metrics (comprehensive) |
/metrics/json | GET | JSON metrics snapshot |
/metrics/errors | GET | Per-endpoint error counts and recent failure log. Filter with ?type=client (4xx) or ?type=server (5xx) |
/reload | POST | Reload configuration — audit logged |
/shutdown | POST | Graceful shutdown — audit logged |
/config | GET | Read-only config view |
/backends | GET | Backend health status |
/tls | GET | TLS certificate info |
/ocsp | GET | OCSP stapling status |
/ocsp/refresh | POST | Force OCSP response refresh (5-min cooldown) |
/acme | GET | ACME certificate status |
/acme/renew | POST | Force certificate renewal (1-hour cooldown) |
/ratelimit | GET | Rate limiter status and statistics |
/health/quic | GET | QUIC listener health — port, migration status, active connections |
/health/webtransport | GET | WebTransport health — active sessions, allowed origins, limits |
# Health check
curl http://127.0.0.1:8082/health
# Prometheus metrics
curl http://127.0.0.1:8082/metrics
# Reload configuration
curl -X POST http://127.0.0.1:8082/reload
# Reload TLS certificates only
curl -X POST http://127.0.0.1:8082/reload -d '{"tls_only":true}'
The Telemetry Wall is a native WebTransport handler at /telemetry (default wss://api.pqcrypta.com:4433/telemetry) that streams real-time transport-layer data without any backend involvement.
QUIC's key advantage over TCP is that stream-level impairment on one stream does not affect other streams. The Telemetry Wall makes this concrete: impair channel ch3 and watch channels ch1, ch2, ch4, ch5, ch6 continue uninterrupted. The equivalent on HTTP/2 or WebSocket over TCP would stall all streams.
| Stream | Type | Rate | Content |
|---|---|---|---|
ch1–ch6 | Server uni-streams | 20 Hz | Throughput frames (~32 KB virtual per frame ≈ 5 Mbps per channel) |
| Stats | Server uni-stream | 10 Hz | RTT, CWND, CPU %, memory %, uptime |
| Control | Bidi-stream | Client-driven | Impairment and heal commands |
| Datagrams | — | Echo | RTT and packet-loss measurement |
Session idle timeout: 5 minutes. Hard cap: 60 minutes.
All stream frames use a 4-byte big-endian length prefix followed by JSON:
[u32 BE length][JSON bytes]
Channel header (first frame on each uni-stream):
{"stream_type":"channel_header","channel":"ch1","rate_hz":20}
Throughput frame:
{"t":1234567.890,"seq":42,"channel":"ch1","bytes_total":1048576,"impaired":false,"impairment":null}
Stats frame:
{"t":1234567.890,"rtt_ms":23.5,"cwnd_bytes":1250000,"cpu_pct":8.3,"mem_pct":42.1,"uptime_s":86400}
{"cmd":"impair","channel":"ch3","type":"delay_ms","intensity":200.0,
"pattern":"burst","burst_freq_s":5.0,"burst_dur_ms":500.0,"duration_s":30.0}
{"cmd":"heal","channel":"ch3"}
{"cmd":"heal_all"}
Impairment types: delay_ms, loss_pct, bandwidth_kbps, jitter_ms, disconnect
Patterns: constant, burst (periodic spike), random
Server acknowledges each command:
{"type":"ack","cmd":"impair","channel":"ch3","ok":true}
The speed test handler at /speedtest (wss://api.pqcrypta.com:4433/speedtest) provides a side-by-side QUIC vs TCP throughput comparison from the same server.
The proxy has two config options that together enable independent TCP and QUIC measurements in the browser:
tcp_only_hosts in [server]: listed hostnames get Alt-Svc: clear, evicting any cached QUIC upgrade so the browser stays on TCP/TLS. A request for one that still arrives over HTTP/3 — coalesced onto a connection made for another name on the same address — is answered 421 Misdirected Request (RFC 9110 §15.5.20), which browsers retry on a new connection; it was a 404, which failed a TCP speed test's request outright.http11_only_hosts in [server]: listed hostnames suppress the h2 ALPN token, forcing HTTP/1.1. The browser then opens one TCP connection per fetch() stream instead of coalescing onto an HTTP/2 pipe — giving each parallel speed test stream its own TCP flow.The QUIC path uses WebTransport streams natively; the TCP path uses HTTP/1.1 connections to the same backend. Both paths hit the same server, measuring the protocol itself rather than network distance.
| Operation | Client sends | Server sends |
|---|---|---|
download | {"op":"download","bytes":N} | 1-byte status + N raw bytes |
upload | {"op":"upload","bytes":N} + N raw bytes | {"bytes_received":M,"duration_ms":D,"throughput_mbps":T} |
info | {"op":"info"} | Server capabilities JSON |
geoip | {"op":"geoip"} | Client IP, city, country, ASN |
traceroute | {"op":"traceroute"} | Stream of hop frames with per-hop GeoIP, then {"type":"done"} |
Datagram echo: any datagram ≥ 8 bytes is echoed immediately. Client embeds a send timestamp in the payload to compute RTT and count loss.
| Parameter | Value |
|---|---|
| Max download | 1 GB (client time-limits to 5–10 s in practice) |
| Max upload | 500 MB |
| Download chunk | 256 KB (keeps QUIC send buffer full) |
| Session idle timeout | 5 minutes |
| Session hard cap | 30 minutes |
masque-client — a MASQUE (RFC 9298) reference clientcargo run --example masque-client -- <relay:port> <target:port> <name>
cargo run --example masque-client -- pqcrypta.com:443 1.1.1.1:53 example.com
Opens an HTTP/3 connection, sends an Extended CONNECT with :protocol = connect-udp,
and relays one DNS query to the target resolver as an HTTP Datagram. Written to be read:
it is the shortest complete demonstration of the CONNECT-UDP flow, including the
quarter-stream-ID framing that RFC 9297 uses to bind a connection-global datagram back to
its request stream.
Three things it gets right that are easy to get wrong:
SETTINGS_H3_DATAGRAM and SETTINGS_ENABLE_CONNECT_PROTOCOL are negotiated.
Separate settings; without the second, :protocol is not legal on the request.A standalone copy that builds against upstream quinn/h3 rather than the vendored noq
forks is published at https://pqcrypta.com/masque/, for people who want the client
without the proxy.
The pentests/ directory contains 32 automated black-box attack scripts across 12 phases, used to continuously validate pqcrypta-proxy's security posture. No source access is required — all scripts target the public HTTP surface.
cd pqcrypta-proxy/pentests
cp config.demo.sh config.sh
vi config.sh # set TARGET and API_TARGET at minimum
./preflight.sh # check required tools
./run_all.sh # all 32 scripts
./run_all.sh --skip-slow # skip TLS deep-scan and resilience/chaos
./run_all.sh --local # tighter timing thresholds (LAN / same datacenter)
./run_all.sh --phase 5 # single phase
| Phase | Scripts | Attack category |
|---|---|---|
| 1 | 14 | Reconnaissance & information disclosure |
| 2 | 01, 12 | WAF bypass & advanced evasion |
| 3 | 02, 03 | Bot detection bypass, header injection |
| 4 | 07, 06, 13 | HTTP smuggling (1.1/2/3/WebTransport), TLS/SSL, WebSocket |
| 5 | 08, 09, 05, 10, 11, 04 | SSRF, cache poisoning, API auth, crypto fuzzing, timing oracles, rate limiting |
| 6 | 15, 19, 20 | Auth hardening, session security, MFA bypass, access control |
| 7 | 16, 17, 23 | Business logic, race conditions, CSP, client-side JS |
| 8 | 21, 22, 18 | Supply chain, cloud/infra, resilience & chaos |
| 9 | 24 | Data privacy & PII exposure |
| 10 | 25, 26, 27 | Container/K8s runtime, CI/CD pipeline, database & storage |
| 11 | 28 | AI / LLM attack surface (prompt injection, context bleed) |
| 12 | 29, 30, 31, 32 | XXE, SSTI, file upload, gRPC/Protobuf |
| Protocol | Coverage |
|---|---|
| HTTP/1.1 | CL.TE and TE.CL smuggling via raw nc, all WAF / auth / API tests |
| HTTP/2 | TE rejection (RFC 9113 §8.2.2), Rapid Reset (CVE-2023-44487), all API tests |
| HTTP/3 / QUIC | curl --http3 with Alt-Svc fallback detection; WAF and rate-limit tests |
| WebTransport | TCP probe on QUIC_PORT, HTTP/3 QUIC framing |
| WebSocket | RFC 6455 upgrade, auth bypass, token-in-query-string, RFC 8441 H2 WS |
Add your test runner IP to pentest_bypass_ips in [security] to skip rate-limiting and auto-block without disabling the WAF:
[security]
# Remove after the engagement ends.
pentest_bypass_ips = ["YOUR.RUNNER.IP.HERE"]
Effect: rate limiting and auto-block are disabled for listed IPs (preventing mid-run lockout), but the WAF still executes — attack payloads still return 403, confirming WAF detection is working. Script 04 (rate limiting) needs a non-bypassed IP to test the limiter itself.
Each run writes to results/run_YYYYMMDD_HHMMSS/:
results/run_20260522_153000/
├── MASTER_REPORT.txt # full concatenated output
├── SUMMARY.tsv # machine-readable: phase<TAB>script<TAB>name<TAB>status<TAB>findings<TAB>warns<TAB>duration_s
├── 01_waf_bypass.txt
├── 07_advanced_smuggling.txt
└── ...
| Exit code | Meaning |
|---|---|
0 | All scripts passed — no findings, no warnings |
1 | At least one [WARN] or [VULN] finding |
2 | One or more scripts failed with an execution error |
3 | Preflight check failed or scope_guard.sh refused the target |
50 samples per test group, python3 statistics.mean + pstdev per group, alert threshold 3σ above baseline mean. Minimum floor: 15 ms remote, 3 ms with --local. Tests: API key oracle, admin login timing, decrypt padding oracle, WAF timing side-channel.
bash 5.0+, curl, python3, openssl, nc, dig, jq. Optional: hey (race conditions), websocat (WebSocket), grpcurl (gRPC), testssl.sh (TLS deep-scan), nikto (web scan).
Latency percentiles are computed from a double-buffered 5-minute sliding window rather than a cumulative histogram. The active buffer accumulates request durations; every 2.5 minutes the buffers rotate, so reported percentiles always reflect the last 2.5–5 minutes of live traffic. Historical outliers from startup or past load spikes do not pollute current readings.
Percentiles are interpolated using Prometheus-style linear interpolation within each bucket. The histogram uses 18 fine-grained buckets with boundaries chosen to match SLO thresholds: 5, 10, 25, 50, 75, 100, 150, 200, 300, 500, 750, 1000, 1500, 2000, 3000, 5000, 10000 ms, and +Inf. This eliminates the step-function snapping seen with coarse bucket boundaries (e.g., a p99 of 1001 ms being reported as 2500 ms).
Requests that carry the x-health-check-bypass: 1 request header are excluded from all metrics counters and the latency histogram:
total_requests, successful_requests, failed_requestsin_progress connectionsThis prevents the health check cron's synthetic cryptographic workflows (which generate intentional 500s during wrong-key rejection tests) from appearing as real errors or skewing production latency percentiles.
The API server's tower_http::TraceLayer is also configured with .on_failure(()) on all three router layers, suppressing the default ERROR-level log entries that would otherwise be emitted for every health-check-bypass 500. Genuine non-bypass 5xx responses are still logged as ERROR by the metrics middleware, which checks the x-health-check-bypass header before deciding whether to emit the log entry.
Requests rejected by the security IP-blocklist or bot-blocklist receive an x-waf-block: 1 response header. The collector tracks these separately in waf_blocked_requests (distinct from failed_requests) so that bot attack traffic cannot inflate error-rate SLOs or depress domain health scores.
PQCrypta Proxy supports end-to-end distributed tracing via the OpenTelemetry SDK. When enabled, every HTTP request creates a span that is stitched into the incoming trace (if one is present) and propagated to upstream backends.
| Format | Headers | Notes |
|---|---|---|
| W3C TraceContext (RFC 9543) | traceparent, tracestate | Extracted first; highest priority |
| B3 Multi-header | x-b3-traceid, x-b3-spanid, x-b3-sampled | Fallback extract; always injected |
| B3 Single-header | b3 | {traceId}-{spanId}-{flag} compact form; also injected |
Both W3C and B3 formats are injected into every upstream request so Jaeger, Zipkin, Tempo, and W3C-compatible backends can all correlate traces from a single proxy deployment.
Trace context extraction and injection is active on all four transports:
trace_context_middleware extracts from HeaderMap before the first handler runsinject_current_context_into_map() stamps both formats into every proxied request[otel]
# Enable distributed tracing (disabled by default)
enabled = true
# Service name reported in spans and to the collector
service_name = "pqcrypta-proxy"
# OTLP HTTP/JSON endpoint — works with Jaeger, Grafana Tempo,
# OpenTelemetry Collector, Honeycomb, Lightstep, etc.
otlp_endpoint = "http://localhost:4318"
# Sampling ratio: 1.0 = always, 0.0 = never, 0.1 = 10% of root spans
# Uses ParentBased(TraceIdRatioBased) — child spans inherit parent decision
sample_ratio = 1.0
# Optional resource attributes added to every exported span
# [otel.resource_attributes]
# deployment.environment = "production"
# host.name = "proxy-1"
When tracing is active, every access-log line includes a trace_id=<hex> field so log entries can be looked up directly in Jaeger or Tempo:
203.0.113.42 - - [02/Mar/2026:14:23:01 +0000] "GET /api/v1/users HTTP/3" 200 1542 "-" "curl/8.5.0" host="api.pqcrypta.com" time=18ms trace_id=4bf92f3577b34da6a3ce929d0e0e4736
Spans are batched and exported asynchronously via OTLP HTTP/JSON (no protobuf dependency — uses the existing reqwest client). The global tracer provider is a NOOP until init_otel() is called after config loads, so startup spans are silently dropped; all request-handling spans are fully exported. On graceful shutdown, the batch queue is flushed before the process exits.
scripts/deploy.sh builds the release binary, records its sha256, deploys to
every proxy node, and verifies each node is running that exact hash with the
service active. It always builds first: cargo test and cargo clippy do not
produce the release binary, so copying target/release/pqcrypta-proxy after
only those can ship a stale build — this script closes that gap.
scripts/deploy.sh # build + deploy all nodes + verify hashes
scripts/deploy.sh --local-only # this node only
scripts/deploy.sh --gate # also run the post-deploy WAF regression gate
# Copy service file
sudo cp packaging/systemd/pqcrypta-proxy.service /etc/systemd/system/
# Enable and start
sudo systemctl enable pqcrypta-proxy
sudo systemctl start pqcrypta-proxy
# View logs
journalctl -u pqcrypta-proxy -f
# Copy plist
cp packaging/macos/com.pqcrypta.proxy.plist ~/Library/LaunchAgents/
# Load service
launchctl load ~/Library/LaunchAgents/com.pqcrypta.proxy.plist
# Using NSSM (Non-Sucking Service Manager)
nssm install pqcrypta-proxy "C:\Program Files\pqcrypta-proxy\pqcrypta-proxy.exe"
nssm set pqcrypta-proxy AppParameters "--config C:\ProgramData\pqcrypta\config.toml"
nssm start pqcrypta-proxy
# /etc/sysctl.d/99-pqcrypta.conf
net.core.rmem_max = 26214400
net.core.wmem_max = 26214400
net.core.rmem_default = 1048576
net.core.wmem_default = 1048576
net.ipv4.udp_mem = 65536 131072 262144
# Build with native CPU optimizations
RUSTFLAGS="-C target-cpu=native" cargo build --release
The throughput comparison against HAProxy, the instructions-per-request A/B
used for optimisation work, and the handshake benchmarks live in
benchmarks/ as scripts, not cargo bench targets; benchmarks/README.md
describes the rig and the traps it guards against.
min_version; applies to both TCP/TLS via OpenSSL 3.5+ and QUIC/HTTP3 via rustls)Whether a client is asked for a certificate is decided once from the configuration and applied on every listener — HTTP/1.1, HTTP/2 and HTTP/3, whichever TLS stack serves them:
require_client_cert = true — every client must present one; the
handshake fails without it.internal = true, or
[routes.security] mtls_required = true on any route — with no global
requirement: the listeners ask for a certificate, a client may still finish
the handshake without one, and the route answers it 401. Other routes are
unaffected, and no client is asked when no route needs a certificate, so
browsers are not shown a certificate picker for nothing.Both of the first two need ca_cert_path; the proxy refuses to start without
it rather than accept any publicly issued certificate. The route gate reads
x-client-cert, which only the accept loop sets — any copy a client sends is
stripped on every listener.
[tls]
ca_cert_path = "/etc/pqcrypta/client-ca.pem"
require_client_cert = true # every client, every route
# ...or leave it false and mark the routes that need one:
[[routes]]
name = "internal-api"
internal = true # mTLS by default; opt out with [routes.security] mtls_required = false
The admin API takes the same protection on its own listener. With
require_mtls = true it serves TLS 1.3 only, prefers the hybrid groups, and
requires a client certificate issued by client_ca_path (default:
tls.ca_cert_path); the certificate is then the credential, and auth_token
is still checked when set. Because the listener is encrypted and
authenticated, require_loopback no longer applies to it, and in
zero_trust_mode a required client certificate satisfies the admin
proof-of-possession requirement as hmac_secret does.
[admin]
enabled = true
bind_address = "10.0.0.5" # reachable from the management network
port = 8082
require_mtls = true
client_ca_path = "/etc/pqcrypta/admin-ca.pem"
# tls_cert_path / tls_key_path default to [tls] cert_path / key_path
The admin API requires at least one of the following to be configured, or the proxy refuses to start:
auth_token set in [admin] — Bearer token required on every admin request, orallowed_ips restricted to loopback addresses (127.x.x.x, ::1)[admin]
enabled = true
bind_address = "127.0.0.1"
port = 8082
auth_token = "your-strong-secret-token-at-least-32-chars" # required unless allowed_ips is loopback-only
allowed_ips = ["127.0.0.1", "::1"]
Token requirements:
openssl rand -base64 48.Brute-force protection:
429 Too Many Requests lockout for that IP./acme/renew is limited to once per hour; /ocsp/refresh to once
per 5 minutes to prevent inadvertent CA rate-limit exhaustion.Per-subject JWT rate limiting verifies the token's HMAC-SHA256 signature before trusting the sub claim. Configure a shared signing secret that matches the upstream token issuer:
[advanced_rate_limiting]
jwt_secret = "your-hmac-sha256-secret-at-least-32-bytes"
jwt_algorithms = ["HS256"]
[advanced_rate_limiting.key_strategy]
order = ["jwt_subject", "source_ip"]
Without jwt_secret, the jwt_subject strategy is disabled and falls back to the next configured key strategy.
Algorithm restriction (F-10): By default only HS256 is accepted. To allow additional HMAC variants:
[advanced_rate_limiting]
jwt_secret = "your-hmac-sha256-secret-at-least-32-bytes"
jwt_algorithms = ["HS256"] # Only HS256/HS384/HS512 are valid; non-HMAC strings are rejected
tls_skip_verify = true on a backend completely disables certificate and signature verification for that upstream connection, enabling man-in-the-middle attacks on the proxy↔backend leg. The proxy logs a loud warning for every such backend at startup.
Production deployments reject tls_skip_verify at config load. Production is detected automatically when:
[acme] enabled = true), orPQCRYPTA_ENV=production is set in the environment.To use tls_skip_verify in a development environment where neither condition applies, set PQCRYPTA_ENV=development:
PQCRYPTA_ENV=development pqcrypta-proxy --config config.toml
# Only valid when PQCRYPTA_ENV=development and acme.enabled = false
[backends.dev-backend]
name = "dev-backend"
type = "http1"
tls_mode = "reencrypt"
address = "localhost:8443"
tls_skip_verify = true
Replace self-signed backend certificates with CA-signed ones before enabling ACME or moving to production.
0-RTT (TLS 1.3 early data) is disabled by default. When enabled it works on every listener, TCP and QUIC: session tickets allow early data, and a request that arrives in it is served before the handshake completes, its response leaving as 0.5-RTT data. Waiting for the handshake would answer at the moment a 1-RTT connection does, which is no saving at all.
[tls]
enable_0rtt = true
# Methods safe for 0-RTT forwarding (idempotent, no side effects)
zero_rtt_safe_methods = ["GET", "HEAD"]
# Early data a TCP ticket allows (QUIC tickets always carry 0xffffffff, RFC 9001 §4.6.1)
zero_rtt_max_early_data = 16384
# "strict": refuse a repeated ClientHello; "session": each ticket carries early data once
zero_rtt_replay_protection = "strict"
zero_rtt_nonce_window_secs = 60
What counts as early. A request is early when it is dispatched before the client's Finished has been verified. That is the property replay turns on: an attacker can re-send a recorded ClientHello and its early data, but never the Finished, so everything a replayed connection dispatches is early. On TCP the OpenSSL listeners read early data with SSL_read_early_data and the rustls listeners through a driver of their own (tokio-rustls completes a server handshake before returning); on QUIC each request stream reports whether it arrived in 0-RTT. Replay protection runs on the ClientHello before any handshake work, on both TCP stacks; OpenSSL adds its own single-use tickets when early data is on.
Every route has an allow_0rtt flag that defaults to false. When a request arrives as TLS 1.3 early data on a route where allow_0rtt = false, the proxy responds with 425 Too Early and does not forward the request to the backend. This prevents replay attacks on non-idempotent operations (POST, PUT, DELETE, PATCH, etc.).
Routes that serve purely idempotent, replay-safe content can opt in explicitly:
[[routes]]
name = "static-assets"
host = "cdn.example.com"
path_prefix = "/static/"
backend = "cdn"
allow_0rtt = true # safe: static files, no side effects
[[routes]]
name = "api"
host = "api.example.com"
path_prefix = "/"
backend = "api"
# allow_0rtt = false ← default; early-data requests receive 425 Too Early
A request that is forwarded while early carries Early-Data: 1 to the backend (RFC 8470 §5.1), so an origin that cannot tolerate replay can answer 425 itself.
The x-tls-early-data header used internally to propagate the early-data flag is stripped from all incoming requests before being set by the accept loop, and is removed from every outgoing backend request, so it cannot be forged by clients or leaked to backends.
Licensed under either of:
at your option.
Contributions welcome! Please read CONTRIBUTING.md first.
| ✅ |
RFC 7838: Alt-Svc names alternatives to the connection it arrives on. Over TCP the proxy advertises h3 on every configured UDP port; over QUIC it advertises h2 on the same port plus any other h3 port, never the connection in use |
| PQC TLS | ✅ | X25519MLKEM768 hybrid key exchange (NIST Level 3) + ML-DSA-87 server certificates (NIST Level 5, FIPS 204) |
| Background Cleanup | ✅ | Auto-cleanup of expired entries |
| ACME Automation | ✅ | Let's Encrypt HTTP-01/DNS-01; one individual cert per domain with atomic writes and immediate hot-reload; IP addresses beside a domain (RFC 8738, shortlived profile); renewal in the CA's ARI window (RFC 9773) with replaces |
| OCSP Stapling | ✅ | Automated OCSP response fetching with caching |
| Prometheus Metrics | ✅ | TLS, connection, request, backend, and error metrics |
| PROXY Protocol v2 | ✅ | Client IP preservation for downstream proxies |
| Access Logging | ✅ | Per-request logging in JSON or text format with configurable file output |
| WebTransport Origin Validation | ✅ | SR-02 cross-origin blocking with configurable allowlist |
| WebTransport Per-Origin Rate Limiting | ✅ | Max sessions per origin, streams per session, datagrams/sec enforced |
| IP Blocklists (DB-synced) | ✅ | Live-synced IP/fingerprint/country blocklists from application database |
| Per-Route Timeouts | ✅ | Per-route timeout overrides independent of global defaults |
| Custom Header Injection | ✅ | Inject arbitrary headers per route before forwarding to backends |
| Multiple Listener Ports | ✅ | Primary port plus any number of additional ports via additional_ports |
| WebTransport Operations | ✅ | JSON operation routing over streams (encrypt/decrypt/keygen/health/ping/speedtest); QUIC-native speed test server with datagram RTT probing, stream throughput download/upload, packet-loss measurement, MTR-based hop traceroute with GeoIP annotation, and per-client info lookup |
| WAF | ✅ | Pattern-based injection and traversal inspection — SQLi, XSS, path traversal, NoSQLi, SSRF, CMDi, XXE, deserialization; covers OWASP A01/A03/A08/A10 attack patterns. Scanner probe blocking: path-level block for .git, .env, .aws, wp-login.php, terraform state, SSH keys, CI/CD files, and 40+ other reconnaissance targets |
| Audit Logger | ✅ | Async structured JSON audit log — admin actions, auth failures, WAF events, rate limits, PQC downgrades |
| JA3/JA4 Replay Detection | ✅ | Flags same fingerprint from multiple IPs within configurable window |
| JA3/JA4 Drift Detection | ✅ | Flags cipher/extension composition changes on the same fingerprint hash |
| PQC Downgrade Detection | ✅ | Detects classical-only TLS negotiation when PQC is required; block/log/allow action. The negotiated group is read from the connection via SSL_get0_group_name, not assumed — it was previously hardcoded to X25519MLKEM768, which made the check unable to fire |
| Response Cache (RFC 9111) | ✅ | Optional RFC 9111-compliant HTTP cache with Cache-Control parsing, ETag/If-None-Match, Last-Modified/If-Modified-Since, Vary header support, size-bounded DashMap store, and configurable path/host exclusions. HEAD responses are never stored — their body is empty by definition, and a stored empty body made the hit path report the resource as zero-length |
| HTTP/3 Client Conformance Suite | ✅ | A public service that makes the server misbehave on purpose so a client library can find out how it copes: 59 anomalies, each citing the RFC clause it exercises — reserved frame types, a duplicated SETTINGS identifier, a control stream opening with the wrong frame, an unknown QUIC frame type, a Stateless Reset, a path-MTU black hole, refused 0-RTT. Every test binds its own UDP port, because anomalies like supported_versions are endpoint-wide and one port is the whole test; the vhost serves the catalogue, reports and badge. Verdicts are asymmetric by class — ignoring a reserved HTTP/3 frame is a pass and ignoring a reserved QUIC frame is a failure, since QUIC reserves no ignorable frame types (RFC 9000 §12.4). A failure requires positive evidence that the client read the anomaly: silence on a unidirectional control stream, or a window that closes with nothing observed, is inconclusive — a rejection whose CONNECTION_CLOSE was lost looks exactly like no rejection at all. A run that never puts the client in the situation under test records inconclusive too, never a pass and never a failure. An anomaly is also emitted at the moment that makes the answer unambiguous: the mid-connection GOAWAY goes out once the client's request is in flight and names the stream after it, because sent at connection setup it races that request, and RFC 9114 §5.2 makes a client that reads it first correct to close and reconnect — measured that way the test scored a conformant client as having failed to recover. Machine-readable catalogue, JSON reports and an SVG badge; h3-conformance drives any client from CI. TLS tier added 2026-09-17: six ports whose anomaly is the key exchange itself rather than a malformed byte — hybrid-only (forcing the HelloRetryRequest round trip that post-quantum deployments break on), classical-only against a hybrid offer (discretionary: the report says which policy the client took, because no RFC yet mandates a post-quantum floor), and a ClientHello too large for one Initial packet, judged from a count of the client's Initials so a client that never sent a large one is reported unexercised rather than credited. Two more are now emitted through a key_share_impairment hook in vendor/rustls-fixed, which exists because a correct TLS implementation has no code path that produces either: a ServerHello naming a group the client never offered, payload left exactly as generated so that an abort can only be over RFC 8446 §4.1.3 and not over a share that would not parse; and a hybrid share with one bit flipped early in the ML-KEM ciphertext and the X25519 tail untouched, where a client that still completes has silently downgraded itself to the classical half alone. What is judged on the first is the abort and not the alert value — RFC 9001 §4.8 expressly lets a QUIC endpoint replace any alert with a generic one — and a handshake that ends with no CONNECTION_CLOSE at all is inconclusive rather than a failure. The third, a GREASE named group, was withdrawn before it judged anything: as built it put the reserved value in the ServerHello key_share, which is illegal under §4.1.3 whatever the value is, so it scored every conformant client as a failure for doing the right thing |
| Cache Purge (admin API) | ✅ | GET /cache reports entries and bytes held; POST /cache/purge drops them — everything, or narrowed by ?host=, ?prefix=, ?host=&prefix= for one path prefix on one host, or ?host=&path= for one exact URL across every method. Exists because a deploy that changes generated output otherwise leaves the edge serving the old body until max-age expires, and the alternative was restarting the proxy. path= without host=, or with prefix=, is refused rather than silently widened or ignored. Audit-logged with scope and target |
| Single Shared Response Cache | ✅ | One cache instance per process rather than one per listener. There were five — one per HTTP listener variant and one per QUIC port — so the same URL was cached separately for h2 and h3 and for each port, and a purge would have cleared one while the others served the old body. The key is METHOD|host|path, with no port or protocol in it, so sharing is what the key already assumed |
| Correct HEAD Semantics (RFC 9110 §9.3.2) | ✅ | A HEAD response carries the header fields a GET would have. Backends serving chunked send no Content-Length, and the proxy no longer synthesizes content-length: 0 from the empty buffered body — which had been telling every link checker, uptime monitor, CDN probe and security scanner that every dynamic page was empty. Fixed on all three transports (HTTP/1.1, HTTP/2 and the separate HTTP/3 implementation) |
| Per-Backend Connection Pool | ✅ | Per-host idle timeout, max idle connections, max total connections, and acquire timeout for the HTTP/1.1 backend pool. A reused connection the backend closes just as a request is written (its keep-alive timeout racing the reuse) no longer turns into a 502: an idempotent request (RFC 9110 §9.2.2) with a replayable body is sent again on another connection; a POST is not, since its first copy may have been acted on |
| Per-Backend Retry | ✅ | Configurable retries with exponential backoff per backend; retry on 5xx/connect-failure/timeout |
| Per-Backend Circuit Breaker | ✅ | Per-backend overrides for failure threshold, half-open delay, and success threshold |
| 0-RTT Replay Protection | ✅ | Nonce store (strict/session/none); rejects replayed early-data nonces |
| QUIC Retry | ✅ | Optional RFC 9000 source-address validation (enable_quic_retry); Retry token echoed before handshake, anti-spoofing at +1 RTT |
| Certificate Transparency | ✅ | Submits new certs via POST /ct/v1/add-chain after ACME issuance, to logs chosen from the live CT log list: one per operator whose shard holds the expiry |
| QUIC Connection Migration | ✅ | Configurable enable/disable of QUIC connection migration (enable_quic_migration) |
| Per-Route Security Policy | ✅ | Per-route mTLS requirement, JA3 allowlist, rate limit override, WAF mode, 0-RTT control |
| Body Size Limit | ✅ | Global max_request_body_bytes enforced (default 50 MB); 413 on excess |
| Config Schema Versioning | ✅ | version field in config; warns if absent, errors if version > current |
| Config Conflict Validation | ✅ | Startup validation catches conflicting settings (PQC+passthrough, 0-RTT non-safe, mTLS without CA) |
| Environment Config Overlay | ✅ | --env <name> flag loads config.<name>.toml overlay merged on top of base config |
| CIDR Blocklist Support | ✅ | Blocklist files now match full subnet ranges (e.g. 192.0.2.0/24); previously only host addresses were matched |
| Session Affinity TTL | ✅ | Sticky session maps evict stale entries after configurable TTL; header affinity uses its own dedicated map |
| Proactive Backend Health Checks | ✅ | Background TCP-connect health task marks backends unreachable before traffic hits them; configurable interval |
| Enforced Backend Request Timeout | ✅ | Per-backend/pool-server timeout_ms bounds the forwarded request itself (not just connection setup) — a hung backend returns 504 Gateway Timeout instead of holding the client connection open indefinitely |
| Configurable GeoIP Block Duration | ✅ | geoip_block_duration_secs in [security] (default 24 h); previously blocks were permanent with no expiry |
| Configurable WebTransport Port | ✅ | webtransport_port in [server] replaces the hardcoded port 4433 for the dedicated WebTransport server |
| Dynamic Alt-Svc Header | ✅ | Alt-Svc value built from udp_port + additional_ports instead of a hardcoded constant; alt_svc_ports overrides that list where a bound port does not actually serve HTTP/3 |
| TCP-Only Hosts | ✅ | tcp_only_hosts in [server] — listed hostnames receive Alt-Svc: clear, evicting cached QUIC upgrades so browsers always connect via TCP/TLS; a request for one that arrives over HTTP/3 anyway (coalesced onto another name's connection) gets 421 Misdirected Request, which browsers retry on a new connection — TCP |
| HTTP/1.1-Only Hosts | ✅ | http11_only_hosts in [server] — listed hostnames negotiate HTTP/1.1 only (no h2 ALPN); browsers open an independent TCP connection per fetch() stream instead of coalescing onto one HTTP/2 pipe, eliminating head-of-line blocking on parallel speed test streams |
| Admin Loopback Enforcement | ✅ | require_loopback = true (default) aborts startup when admin API is bound to a non-loopback address |
| Shared Security State | ✅ | Every listener — QUIC and all three TCP paths — shares one SecurityState. Blocked IPs, rate limiter counters, the fingerprint corpus and DB-synced blocklists are visible across all of them. The TCP listeners previously each constructed their own, so an IP blocked on one was not blocked on another and the admin API could not clear it |
| Audit Logger Wired | ✅ | AuditLogger constructed at startup and passed to the admin server; audit events are now actually written |
| Cryptographically Secure Admin Token | ✅ | Ephemeral admin tokens use OsRng instead of thread_rng |
zero_trust_mode | ✅ | Startup validation enforcing mTLS, no plaintext backends, no CIDR trust, and admin HMAC proof-of-possession |
| Per-Route HMAC Signing | ✅ | HMAC-SHA256 proof-of-possession signing full path+query string; optional X-Request-Nonce binds nonce into signature for full replay prevention within 300 s window |
| Internal Route Auto-mTLS | ✅ | internal = true routes default to requiring client certificate |
| Admin HMAC Proof-of-Possession | ✅ | Optional per-request HMAC signature (path+query signed) alongside bearer token; optional X-Admin-Nonce for full replay prevention |
trusted_internal_cidrs Deprecation | ✅ | Startup warning directing operators to cert-based trust |
| OpenSSL Subprocess Env Sanitisation | ✅ | All openssl subprocesses clear the environment before execution (env_clear()) to prevent PATH/LD_PRELOAD injection |
| Hot Reload | ✅ | Configuration and TLS certificates reloaded at runtime without dropping connections or restarting: routes, security, the WAF, admin credentials, pools and every listener's per-request settings; a change to a setting bound at startup (ports, listener kinds, process-wide services) is named in a warning |
| Log Rotation (SIGHUP) | ✅ | SIGHUP reopens all log file handles in-place; compatible with logrotate postrotate — no restart required |
| TLS 1.3 Default | ✅ | TLS 1.3 minimum by default on all listeners (min_version = "1.3"); configurable to allow TLS 1.2 via min_version in [tls] |
| Certificate Compression (RFC 8879) | ✅ | The certificate chain is sent compressed to any client that offers compress_certificate. Measured on pqcrypta.com: 3,435 bytes to 2,376 with zlib (1.45:1), about 1 KB off every full handshake — which roughly offsets what the X25519MLKEM768 key share adds. Both TLS stacks are covered, and they need different treatment: on the OpenSSL TCP listener it takes SSL_CTX_set1_cert_comp_preference and SSL_CTX_compress_certs (the preference alone reports success and changes nothing on the wire), while rustls on QUIC/HTTP-3 needs only its brotli/zlib crate features. zlib on TCP because these OpenSSL builds carry -DZLIB and nothing else; brotli and zlib on the QUIC side |
| Delegated Credentials (RFC 9345) | ✅ | Under a certificate that allows it (the DelegationUsage extension and the digitalSignature key usage), the proxy mints delegated credentials itself: short-lived keys the certificate's key signs for, which then sign the handshake in its place. ML-DSA-87, ML-DSA-65 and ECDSA P-256 ones, each valid for a day and replaced every six hours, with keys that exist only in memory; a client that offers the delegated_credential extension gets the first it accepts, so a post-quantum client gets an ML-DSA CertificateVerify under an ECDSA certificate. Served by rustls, so over QUIC; see Delegated Credentials |
| Request IDs | ✅ | request_id_header in [server] sends one ID per request to the backend and back to the client on every transport; a well-formed client ID (≤128 visible ASCII chars) is kept, anything else replaced with 128 random bits in hex; off by default |
| Server Identity Concealment | ✅ | Backend Server header replaced by server_header in [server] (default pqcrypta, no version); an empty value passes the backend's through |
| JWT Rate Limiting | ✅ | Per-subject rate limiting with HMAC-SHA256 signature verification; unsigned sub claims rejected; non-HMAC algorithms blocked |
| Log Injection Prevention | ✅ | Newlines and control characters stripped from all user-controlled fields before writing to access and audit logs |
| NEL (Network Error Logging) | ✅ | Network Error Logging headers with configurable policy for client-side error reporting |
tls_skip_verify Production Block | ✅ | tls_skip_verify = true rejected at config load unless --allow-insecure-backends CLI flag is passed |
| SSRF Protection | ✅ | Link-local and loopback backend addresses rejected; RFC1918 logged with warning; WAF SSRF pattern set active |
| Admin Brute-Force Lockout | ✅ | Per-IP and global lockout with exponential back-off (5 min base, up to 30 min) on repeated auth failures |
| Connection Draining | ✅ | Graceful backend removal with configurable drain timeout; in-flight requests complete before backend is taken out of rotation |
| Request Queuing | ✅ | Queues requests when all backends are saturated; configurable queue depth and wait timeout |
| Slow Start | ✅ | Gradually ramps traffic to recovered backends to avoid thundering herd after a circuit breaker reopens |
| Connection Pool | ✅ | Per-backend connection pool with configurable max idle, max total, acquire timeout, and idle timeout |
| Session Affinity Modes | ✅ | Sticky sessions via IP hash, custom header, or Set-Cookie with configurable SameSite attribute |
| Path Regex Routing | ✅ | Per-route regex pattern matching with ReDoS prevention (pattern size-limited) |
| Path Canonicalisation | ✅ | Every transport routes and forwards on one canonical path: RFC 3986 dot segments resolved, percent-encoded ones included (resolve_dot_segments), repeated slashes merged (merge_slashes), case folded (normalize_paths). The WAF still inspects the raw path, so a traversal attempt is detected as one |
| Certificate Compression | ✅ | RFC 8879 on both stacks: zlib over TCP via OpenSSL's SSL_CTX_compress_certs, brotli or zlib over QUIC/HTTP-3 where rustls builds the list from crate features. certificate_compression = "auto" | "off" in [tls], default auto. Worth 1,051 bytes off every full handshake on a four-certificate Let's Encrypt chain |
| PQC Session Tickets | ✅ | Each TLS 1.3 ticket carries its own ML-KEM-1024 encapsulation; the resumption state is sealed with an AES-256-GCM key derived from it via HKDF-SHA384 (pqc_session_tickets, session_ticket_lifetime_secs) |
| TLS Key Permission Checks | ✅ | Validates private key file permissions at startup; configurable strict mode aborts on insecure permissions |
| Malicious Fingerprint Blocking | ✅ | Classification consults the operator database at fingerprint_db_path first, then the built-in table, matching JA3 then JA4 (block_malicious, block_scanners). Authorised pentest_bypass_ips are classified but never banned |
| Server-Timing Header | ✅ | Per-request Server-Timing header with proxy latency breakdown for performance visibility |
| Accept-CH Header | ✅ | Accept-CH client hints advertisement for adaptive content delivery |
| Graceful Shutdown Drain | ✅ | Configurable drain timeout polls active connections at 100 ms intervals; exits as soon as connections reach zero |
| Weighted Load Balancing | ✅ | Per-server weight (1–1000) with smooth weighted round-robin for proportional traffic distribution |
| Canary / Traffic Splitting | ✅ | Percentage-based canary routing with sticky cookie assignment, per-pool auto-rollback on error rate threshold, and live admin control — active on HTTP/1.1, HTTP/2, HTTP/3/QUIC |
| Traffic Shadowing / Mirroring | ✅ | Per-route fire-and-forget copy of requests to a secondary backend; client only sees primary response; all parameters configurable (backend, percent, timeout, marker header) — active on HTTP/1.1, HTTP/2, HTTP/3/QUIC |
| RFC 9111 Response Cache | ✅ | Full Cache-Control parsing (max-age, s-maxage, no-cache, no-store, private, public); ETag/If-None-Match → 304; Last-Modified/If-Modified-Since → 304; Vary header support; TTL-based expiry; size-bounded DashMap store — active on HTTP/1.1, HTTP/2, HTTP/3/QUIC |
| Hop-by-Hop Header Stripping | ✅ | HTTP/1.1 connection-specific headers (Transfer-Encoding, Connection, Keep-Alive, Proxy-Connection, Upgrade, TE, Trailer, Proxy-Authenticate, Proxy-Authorization) stripped from backend responses before caching and forwarding — compliant with RFC 9113 §8.2.2 and RFC 9114 §4.2; prevents ERR_QUIC_PROTOCOL_ERROR on HTTP/3/QUIC and stream errors on HTTP/2 |
| OpenTelemetry Distributed Tracing | ✅ | W3C TraceContext (traceparent/tracestate, RFC 9543) + B3 multi-header + B3 single-header extraction and injection; composite propagator on all transports (HTTP/1.1, HTTP/2, HTTP/3/QUIC, WebTransport); OTLP HTTP/JSON export; ParentBased(TraceIdRatioBased) sampler; trace IDs in access-log lines |
| Least Response Time Routing | ✅ | Routes requests to the backend with the lowest moving-average response time |
| IP Hash Load Balancing | ✅ | Deterministic backend selection by client IP hash for implicit session stickiness |
| Per-Server Priority | ✅ | Failover priority levels; lower-priority backends only receive traffic when higher-priority ones are unhealthy |
| Per-Server Keep-Alive | ✅ | Configurable QUIC keep-alive interval per server to prevent idle connection timeouts |
| DNS Prefetch / Preconnect / Prerender Hints | ✅ | Early Hints Link headers for dns-prefetch, preconnect, modulepreload, and speculative prerender |
| Report-To Header | ✅ | Reporting endpoint configuration injected into responses for NEL and CSP violation delivery |
| Fingerprint Cache TTL | ✅ | JA3/JA4 fingerprint classification cache with configurable max-age and background cleanup |
| QUIC ACK piggybacking & send coalescing | ✅ | Owed ACKs ride on outgoing packets (ack_piggyback) and responses that finish together leave in one transmit (quic_send_coalescing); ack_eliciting_threshold (default 1) governs only ACK-only packets |
| QUIC ACK Frequency | ✅ | draft-ietf-quic-ack-frequency extension (enable_ack_frequency, default on) — peers may request fewer, batched ACKs, cutting ACK traffic and CPU on high-throughput connections; negotiated, so inert against clients that lack it |
| MASQUE / CONNECT-UDP (RFC 9298) | ✅ | UDP proxying over HTTP/3 Extended CONNECT (:protocol = connect-udp); UDP payloads relayed as HTTP Datagrams (RFC 9297) bound to the request stream; disabled by default with host:port allowlist, per-session idle timeout, and per-connection session cap ([masque]) |
| DNS over HTTPS (RFC 8484) | ✅ | A gateway for an authoritative name server on the same host: GET, HEAD and POST over HTTP/1.1, HTTP/2 and HTTP/3; DNS over TCP to the server; Cache-Control from the answer TTLs; RFC 8467 padding on request; zone transfers and other opcodes refused with an Extended DNS Error; loopback-only, off by default ([doh]) |
| Encrypted Client Hello (ECH) | ✅ | Server-side ECH, RFC 9849, with post-quantum HPKE: X-Wing (0x647A) and ML-KEM-768/1024 configs open through libcrux (vendor/pqcrypta-hpke-pq) beside the RFC 9180 DH ones. ech-keygen --kem x-wing and --kem x25519 generate each, and the published list carries X-Wing first, so a client uses it where it can and browsers, which implement X25519 only today, still get ECH. HPKE keypairs generated and rotated by ech-keygen / ech-rotate.timer into /etc/pqcrypta/ech-configs, with previous configs retained so clients holding a cached DNS record still complete. A rotated key is taken up by every listener without a restart: QUIC and each TCP listener rebuild their rustls configs on the TLS reload (ech_config::reloads), and ech-rotate.sh asks for that reload through the admin API, then publishes the new list in DNS with pqcrypta-ech-publish (the self-hosted DNS signer) only once the proxy holds the key. Applies on TCP and QUIC — ECH is a TLS 1.3 extension and QUIC is TLS 1.3, so the same handshake resolves it. An offer holds across a HelloRetryRequest: the retry request confirms the acceptance, and the second ClientHello's offer, an empty enc sealed at the next sequence number, is opened with the first's HPKE context, so a client whose first key share the server does not take still gets ECH (RFC 9849 sections 6.1.5, 7.1.1 and 7.2.1); a second offer that is missing, changed or does not open aborts the handshake. The outcome (accepted / rejected / not-offered) is exposed to backends as x-tls-ech |
| Handshake Facts to Backends | ✅ | x-tls-version, x-tls-cipher, x-tls-group, x-tls-alpn, x-tls-ech injected on all three listener paths, alongside the existing x-ja3-hash / x-ja4-hash / x-client-name / x-client-type / x-client-cert / x-tls-early-data. Every one is stripped from the incoming request before being set, so a caller cannot assert a handshake it did not have |
| Observed Fingerprint Corpus | ✅ | Fingerprints seen in live traffic are persisted to /var/lib/pqcrypta-proxy/fingerprints/observed.json every 5 minutes and reloaded at startup, with the pre-hash JA3 string, per-fingerprint connection counts, and the User-Agents seen on requests carrying them. Bounded by security.max_tracked_fingerprints and evicted least-recently-seen, with a warning logged whenever eviction fires. That limit was a hardcoded 50,000 until 2026-09-17, by which point the corpus was sitting at exactly that number: it had been dropping its long tail for an unknown period, and the long tail is what a fingerprint directory is for. Default is now 250,000 (~125 MB). Evidence only — it never feeds classification or blocking |
opensslopenssl-syscert_compressorsbrotlizlib-DZLIBCompressedCertificatemin_version = "1.3" in [tls]), which is what this deployment runs. TLS 1.2 can be permitted via config for compatibility, but that is a departure from the default posture rather than an equivalent choice — OpenSSL 3.5+ for TCP/TLS, rustls for QUIC/HTTP3strict_key_permissions = true aborts if permissions are too permissiveenable_quic_retry in [server] (default off, in which case RFC 9000's implicit 3× anti-amplification validation is used instead — no per-connection latency).sub claim; unsigned tokens and non-HMAC algorithms rejectedgeoip_block_duration_secs (default 24 h)X-Forwarded-For exempt from SSRF patterns since proxy hops legitimately insert loopback/RFC1918 IPs into the forwarded chainAccess-Control-Allow-Origin and Access-Control-Allow-Credentials headers when the request Origin matches an allowed origin — prevents browsers from reporting rate-limit errors as CORS failuresServer header is replaced by server_header in [server] (default pqcrypta, no version); an empty value passes the backend's own header through, as HAProxy and NGINX do by defaulttls_skip_verify Production Block: tls_skip_verify = true rejected at config load; requires explicit --allow-insecure-backends CLI flag to override192.0.2.0/24); updates without restart. The reload reconciles in both directions: entries added to the synced file are blocked, and database-sourced entries that disappear from it are released from memory on the next reload (60 s), so deactivating a row or clicking unblock in the threat dashboard actually lifts a live block. Only DatabaseSync entries are reconciled — blocks the proxy raised itself (rate limit, WAF, Manual config entries) are unaffected. For an immediate release, POST /blocklist/unblock/:ip on the admin API drops the IP (and any CIDR entry covering it) without waiting for the next sync; GET /blocklist shows what the running proxy is actually enforcing, which is the copy that decides whether a request is refusedh3h3-fork[[http3.preload_resources]] and hot-reload at runtime (preconnect origins + preload rules apply on the next request, no restart). Note: an href is only used by the browser when it byte-matches the page URL, so any ?v= cache-buster must be included and kept in sync.u=3,i=?0)udp_port and additional_ports so every listener advertises its actual address, and varying by transport per RFC 7838: over TCP the value lists h3 on every UDP port, over QUIC it lists h2 on the same port plus any other h3 port, never the connection already in use. alt_svc_ports in [server] replaces the derived list outright, for the case where a bound port does not serve HTTP/3 — a provider filtering inbound UDP/443 upstream, or a port handed to the WebTransport server — since advertising one costs every visitor a QUIC attempt that can only time out. An empty list advertises no HTTP/3 at all, which is the truthful value for a node that serves none. tcp_only_hosts in [server] sends Alt-Svc: clear for designated TCP-only origins (and answers 421 Misdirected Request to any request for one that arrives over HTTP/3, so the browser retries it over TCP), as do alt_svc_clear_user_agents (case-insensitive User-Agent substrings: search crawlers and TLS scanners by default) and alt_svc_clear_cidrs (source networks: Cloudflare's published ranges by default, since Cloudflare Radar sends a generic Chrome UA); alt_svc_max_age_secs sets ma on every advertisement (default 86400). With nothing advertised, x-webtransport-port is not sent either; http11_only_hosts in [server] suppresses h2 ALPN entirely so browsers open an independent TCP connection per stream (required for parallel TCP speed tests):authority pseudo-header handling for backend routing/speedtest): Side-by-side throughput comparison over QUIC and TCP. WebTransport path measures download/upload throughput, datagram RTT, and packet loss. The tcp_only_hosts + http11_only_hosts config options force a parallel HTTP/1.1 connection so the browser opens one TCP stream per fetch rather than coalescing onto HTTP/2 — enabling a true protocol-level comparison. TCP-speedtest responses echo the request Origin when it is in webtransport_allowed_origins, so the test works from any allowed front-end regardless of allowlist order.pentests/): 32 automated black-box attack scripts across 12 phases covering WAF bypass, HTTP smuggling (HTTP/1.1 CL.TE, HTTP/2 Rapid Reset, HTTP/3, WebTransport), SSRF, timing oracles, race conditions, and AI/LLM attack surface. Use pentest_bypass_ips in [security] to skip rate-limiting and auto-block for test runner IPs while keeping the WAF active.webtransport_port in [server] controls the dedicated WebTransport server bind port (default 4433)enable_quic_migration)--env <name> merges config.<name>.toml over base)[admin] require_mtls = true serves the admin API over TLS 1.3 with a required client certificate (it used to be a hard startup error, because the listener had no TLS)is_trusted_ip() (RFC1918 not trusted by default — closes XFF bypass)tls_skip_verify blocked in production (rejected at config load; requires --allow-insecure-backends)unsafe_code = deny globally (OpenSSL FFI retains targeted allow)