This commit is contained in:
Mateusz Gruszczyński committed 2026-10-05 19:05:55 +02:00
1 parent ddc5c6593f
commit b9d137eee1
63 files changed
+1227 -440

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
Read README.md, ROADMAP.md and docs/TEST_REPORT.md before making changes.
The project name is RustProxy; the Cargo package/binary remains `rust-proxy` for
compatibility. Current source version: 0.26.0.
compatibility. Current source version: 0.26.1.
## Delivery
+9
View File
@@ -4,6 +4,15 @@
> test-only changes and packaging implementation notes were moved to
> [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md).
## 0.26.1
- Route low-level protocol errors to per-vhost persistent logs when a vhost can be safely resolved from HTTP authority/Host or TLS SNI; unknown, malformed and non-attributable traffic remains in `_unmatched.jsonl`.
- Add `sni`, `authority`, `matched_by`, `reason` and `error` diagnostics to stored protocol-error records while keeping the 0.26.0 compatibility fields.
- Keep protocol-error persistence independent from the Live Logs memory buffer.
- Improve Logs Explorer with selectable saved columns, a sticky table header, highlighted selection and inline JSON record details.
- Collapse the configured-file inventory by default, repair mapped `client_ip` display and extend keyword filtering to protocol diagnostics.
- Keep schema 8 and the existing saved proxy configuration format.
## 0.26.0
- Add persistent protocol-error traffic records for malformed HTTP, TLS ClientHello/handshake failures and timeouts, HTTP/2/HTTP/3 protocol failures, invalid h2c bootstraps and rejected connection/stream limits. These records use the existing bounded external logging queue and do not enter the Live Logs memory ring.
Generated
+1 -1
View File
@@ -1688,7 +1688,7 @@ dependencies = [
[[package]]
name = "rust-proxy"
version = "0.26.0"
version = "0.26.1"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rust-proxy"
version = "0.26.0"
version = "0.26.1"
edition = "2021"
rust-version = "1.88"
description = "RustProxy - independent reverse proxy with local administration"
+13 -13
View File
@@ -1,9 +1,9 @@
# RustProxy 0.26.0
# RustProxy 0.26.1
Independent Rust reverse proxy and static Web Server with a dashboard, SQLite configuration,
streaming HTTP/TLS/WebSocket forwarding, raw TCP/UDP stream hosts, IP-bound vhosts and backend pools.
**0.26.0 release source.**
**0.26.1 release source.**
## Layer-4 streams
@@ -11,17 +11,17 @@ Raw TCP/UDP forwarding has its own **Streams** page in the primary navigation, a
Named HTTP/HTTPS listener bind addresses use the same chip-style editor as vhost hostnames, including multi-address listeners.
## What changed in 0.26.0
## What changed in 0.26.1
- Persist malformed HTTP, TLS ClientHello/handshake failures and timeouts, invalid h2c traffic, HTTP/2 protocol failures and HTTP/3/QUIC failures that can happen before a normal routed request exists.
- Keep those low-level `protocol_error` events out of the Live Logs memory ring and send them only through enabled structured-log sinks, preventing scanner noise from displacing ordinary live requests.
- Add the authenticated **Logs** workspace at `/logs`, reachable from the top bar and Settings -> Logging, for bounded filtering and JSON inspection of configured access/error files.
- Keep file-backed Logs independent from Live Logs: existing configured files can be viewed even when their writers are currently disabled.
- Harden access-log creation and stored-log reads with regular-file checks and Unix `O_NOFOLLOW`; the file browser never accepts an arbitrary filesystem path.
- Extend standard structured records with `event`, `protocol_error`, `error_detail`, `local_addr` and `tls_sni`.
- Keep SQLite schema 8 and the existing saved configuration format; upgrading from 0.25.7 requires no data migration.
- Route persistent `protocol_error` records to the matching per-vhost JSONL file when the vhost can be safely resolved from normal routing, HTTP authority/Host or TLS SNI; unresolved traffic remains in `_unmatched.jsonl`.
- Add `reason`, `error`, `sni`, `authority` and `matched_by` diagnostics while retaining the 0.26.0 protocol fields for compatibility; protocol errors still bypass Live Logs.
- Reuse the normal exact/wildcard hostname resolver for logging attribution instead of maintaining protocol-specific hostname matching.
- Improve the **Logs** workspace with selectable account-scoped columns, a sticky table header, highlighted selected rows and inline record details.
- Collapse the configured-file inventory by default so large per-vhost installations do not dominate the page.
- Fix Stored Logs client-IP display for custom structured-log mappings and make keyword search cover diagnostic values, including `reason`, `error`, `sni` and `authority`, with debounced search while typing.
- Keep SQLite schema 8 and the saved configuration format unchanged; upgrading from 0.26.0 requires no data migration.
The detailed 0.25.5 implementation audit remains available as historical baseline evidence. Current 0.26.0 verification status is maintained in [docs/TEST_REPORT.md](docs/TEST_REPORT.md).
The detailed 0.25.5 implementation audit remains available as historical baseline evidence. Current 0.26.1 verification status is maintained in [docs/TEST_REPORT.md](docs/TEST_REPORT.md).
## Documentation
@@ -99,7 +99,7 @@ CARGO_BUILD_JOBS=1 CARGO_PROFILE_RELEASE_LTO=false \
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=8 cargo build --release --locked
```
The 0.26.0 source archive preserves the resolved `Cargo.lock` supplied with the source tree and
The 0.26.1 source archive preserves the resolved `Cargo.lock` supplied with the source tree and
updates only this package's own version entry. This packaging environment has no Cargo, so the
dependency graph was not re-resolved here. On the compiler-enabled deployment host, run the
locked check/test/release gates below and retain the verified lockfile. `dev.sh` can generate a
@@ -338,7 +338,7 @@ python3 scripts/release_smoke.py
# Runs those checks first, then the Rust suite when Cargo is available:
bash dev.sh test
# Source-only package, with no Git checkout required:
python3 make_zip.py /tmp/RustProxy-0.26.0.zip
python3 make_zip.py /tmp/RustProxy-0.26.1.zip
```
## New management and test-origin guide
+12 -1
View File
@@ -1,8 +1,19 @@
# RustProxy roadmap
## 0.26.1 - Vhost-aware protocol diagnostics and Logs Explorer UX
Current direction: 0.26.0 -> 0.26.1
- Attribute persistent protocol errors through one shared vhost resolver using routed vhost, authority/Host and SNI in that order.
- Keep unresolved traffic in `_unmatched.jsonl` and keep low-level protocol errors out of Live Logs.
- Add bounded protocol diagnostic identity fields for Stored Logs filtering and inspection.
- Add selectable Stored Logs columns, sticky headers, highlighted selection and inline details.
- Hide configured-file inventory by default and repair mapped client-IP/search behavior.
- Preserve schema 8 and the existing saved proxy configuration format.
## 0.26.0 - Persistent protocol diagnostics and stored-log explorer
Current direction: 0.25.7 -> 0.26.0
Completed direction: 0.25.7 -> 0.26.0
- [x] Persist malformed HTTP, TLS/QUIC handshake failures, HTTP/2/HTTP/3 protocol failures, invalid h2c attempts and connection-limit rejects through the existing bounded logging sink.
- [x] Keep low-level protocol failures out of the Live Logs memory ring so hostile scans cannot displace ordinary live request history.
+62 -60
View File
@@ -1,19 +1,19 @@
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
4a5f6bf0b46f663a37e8759e8e8962e081d154b47e859f1faed7b603726c01de AGENTS.md
d951a8cc95cb902e8d1ec6d702198784458465d36d65a4b90d1df165fb2135d5 CHANGELOG.md
1af885bbc3120d440ea6c2025f4d1cb2ee29a59f62230f5202f587158864f171 Cargo.lock
1845fd0b917d70a6b092176a211d49577b81d5c6a75ddb4b33eee790ef299089 Cargo.toml
ef0142e762f044784ceb61c15c8bf16333250886963850c58e3af45b1419cf5d AGENTS.md
ffce7ba9ca03095bb1aee69eb3e806e5849ae6d1e1f24c0a105230fd27162737 CHANGELOG.md
eabd3b41a3f62cc93b6803ba759643bb8b9a7cc1de43b9587ca2154bcc04ff09 Cargo.lock
f2cb130695ac4eb7f4e5255589e282e4d1a5fd269996a74090a6275f464733b1 Cargo.toml
d6d22ba63f4bc3d16a13012ecaabff8a156be57c824d2f20f66976502fb27d1f Dockerfile
a33a2aab100f87a4f3a868ca32276dbf1e122b3294711c8543ea1ad3e15ab869 README.md
a49f373ddac5ba6a0ad778fb545a7ec82dec6b0c436c5f00b3f94c2ebfacd994 ROADMAP.md
d3f596b1e2a8caa7129bca66f13b5c6f26cf3513de495c96f20a64a6763cac26 README.md
6065f28237d57a21f58fdfa83050781b118fdef3dedf961b1adabc8e734bb788 ROADMAP.md
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
928ba6a504bc431ae100c6e587861e5088b28822d2cb19da5d437b39d26c864a build.rs
e6787f5b31d63a6c7b997562855ac7349680268d6f9a7d55d550f33a702b7ad1 debian/README.md
1fa83ad64f747e27822815de2d85221ac4bb20e5003a72112fe9b11d7b95cad8 debian/build-package.sh
fe2a113daf65042c84838b1e1b7268a74775288ff3fc9bb1570f973d5f8106b7 debian/README.md
2690b8c6a61383a2b1752736e44822c8cfc6119beeacbcba326fcc9f02fc162a debian/build-package.sh
0763a4bd8b429f7922b23a7276ff694f3a751b83fb127f6ad7ec654e5e507ecd debian/build-repository.sh
228319d6ad21c699dc5cc6104686d02d26f41c5d7d8df73fea021896389b314a debian/changelog
0e4306f33dd4a04100af9b3932d2cc264aa901a149cec0d3f95e8aeb4c639152 debian/changelog
c90457b19b1b2f7998d3db59a656d215eb0fab23769197405960457b66d95cf7 debian/control
dff2ecb9c14623416981f444bc6df1c4e2bf55d330260f36a312d8d42245400f debian/rules
c537d54fd92a9b97c5f6e6ec63bc1276cda324513e2946b341b2193f63b62e6a debian/rustproxy.environment
@@ -24,7 +24,7 @@ cb0db03a2c1b06fa04a11f7690755a0ca90b17f19ed8fdb228810f505c48c78a debian/rustpro
e8e18df40bcd69d6aa404282679186d9b8256ac63a5b4e80d7f081e8a0095a2e debian/source/format
385ac7970fa94f043d0bcf04a91184e6e2c807587b85e55517ed064bb4bddba6 deploy/README.md
7b7fdf383bbaea4f37b5c120e98a24e9a075fba56ae7838a31a5152c7edcde02 deploy/rustproxy.service
162551960700a3a1c68dba29f37248f6a31d6a0d39144559e9653f6657ca4d94 dev.sh
cf4180779795cf06416d34a55372ff7d258f0ce6a1e7208bf7912b62b682aa7f dev.sh
5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md
7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go
5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example
@@ -33,14 +33,14 @@ bf687a851374d8f1fa59d050f3f76532f7c12f16a6daa7d3c36102c2d9a8ec9c docker-agent/d
9d77c90767fa929ee8338734d72ce83284d3d965673270f62cb2b1613d587a33 docker-agent/install.sh
b68234472bec5cedd67c7cfe1de7d8806a965dfd4101f944aa6320132f18c14e docker-agent/main.go
3d4051a1f805d145df3877476e4b87773e74be8e123045065955a5133afdc68c docker-agent/main_test.go
9de427ff54b2fba7a8ed5ff356b1b0069bda81f3953b0252638e84efc79b73fb docker-agent/model.go
d4fa829176c8997a8fc42d59d7c45e252efa8a6f05c818d511fd24146da39a01 docker-agent/model.go
e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/network.go
634790f35ff4627df2a78cec2ed371f6e9ec9df00afb386075595fc7f3ed72eb docker-agent/rustproxy-docker-agent.service
5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go
802a30b793a6ae6a5046489612a23659f33d25e41d0794551381db202daac904 docs/ACME_PROVIDERS.md
ec15163f5f51c9212003b7edd1fc380321311c2496fbaa9b45c838281b9af475 docs/ACME_RFC2136.md
d699f2261a0d7854e49e9b8bae44fa37008a7d2f6340a73cdc37e840722ea211 docs/API.md
e386c03dba02287a864329b9b1993cbfd131764d0755c8d9f87b8b198940abcf docs/API_ACCESS.md
9b66a67392803805d0d28c7719f263288b5e6ef16f06b9b8c13a3afdf90271ed docs/API_ACCESS.md
0f7f159ad5b4b5277f80c19f3c47f4e7823ea439ef02e5913e1391f1b34d129f docs/ARCHITECTURE.md
cd2597465af031cdb47db802f8ad6d3af34ef9e79f679df906a7b84220ea3260 docs/CACHE.md
9cb338ed07ac1f92ad491e2099a7e3af63e66262417b1fef24d98c6a791d13fa docs/CACHE_DIAGNOSTICS.md
@@ -49,20 +49,20 @@ db5534427ff0f004940fd6960906d3eaa75347a110e6322827c51c5d63f20adb docs/CONTAINER
9538a52d508d8d7053c1387c42eef60e6d5dc1879c300b6fe951b3374c16884c docs/CUSTOM_LOCATIONS.md
7db65a96f696aff1d7f74e2041cfc6ecdf437b1412ece90cee26da1ceff4cf7e docs/DOCKER_DISCOVERY.md
86bb62f288f2e999994c302716fc9986dba1ac7a26e598dc97cb56a7bc21cfb5 docs/ERROR_LOGGING.md
363e46aa4d6ca133a21e47d5a530154fbd1784eb43593b36022c85c66c0e787b docs/FILE_LOGS.md
a1150f7525a20db8e8399eb04ce679e1699fd1e574485e27bdf2f565436fc508 docs/FILE_LOGS.md
df6ecd4a50796640243676ad4b3a87789f6e5d20cbebea6255e92fbfff1c7131 docs/HTTP3.md
5299c5f58056fea84e88d6d86857fb593d0a7458af3be559476de2bb57939b4d docs/INSTALL.md
1b120d86a65137319234129ce78af39733e59af4b764869a9d7fdd01b9312382 docs/INSTALL.md
3b5c31c805e5aee0dc2100b81246a9b631b5abb996faa598ae94f87ba39305f3 docs/INTEGRATIONS_040.md
4bb56add544f15d54695cf797cd16a51c09024360cdeef803a88988f4a54b3a5 docs/LIVE_LOGS.md
7c35c4cdbe7bbb81d94f3bf6aeb22b8a866af3e7f0d62bbedc299e4d6f264e0d docs/MANAGEMENT.md
ba3cbe1f80271326d6e92e98e035ff280bb8ddbf9d602273684dca147e09d172 docs/MIGRATION.md
526e24e7a9b76941efccf64f4bacab5529e08489add316ec44b33da2756b4dd5 docs/MIGRATION.md
5f090a190000c6badab2a74e5d4968e70c34de43b59c1452f5b05503b5c5c18a docs/MOCK_AUTH.md
1f85fac0e06e432ef6f744f0b5f9c1b7a27a1e9e1ad30dc1a218eab96b1caffe docs/MOCK_SERVER.md
e7516646dfb107d6240370f4e7460b1189f2eb21f398740381d5d102847bbfe6 docs/NOT_FOUND_HOSTS.md
3da047768f32064c5ef0ad7a4c73c4b14930fe7317c54f5650f426af9f484593 docs/NPM_MIGRATION.md
712cb1cbf84515384b081fd2929e86e40af7c10f65bc69919b78b7b3e2fd12b5 docs/PHP_FPM.md
572c70fbd2f61fecd80a8ff9fa5de4a94c4f158de5e90da8e920f99b051241fa docs/PROMETHEUS.md
a31cce4c476ce9d449a5083510660a569d97dcf165f36e24d26b100a29d66944 docs/README.md
087ce068b693b9a0b5fba4b884f82d9e1ece056e5b083e4d2f8cab41062f9bd7 docs/README.md
6e46a137b9c88dac8cb9e3fdca877430092392de9913d746278dbff8652460c2 docs/REDIRECTS.md
0dfd0d431c5aedc67abac1ce5de040ffa790ec9578fd7d6f91a987c333d73358 docs/REFERENCES.md
ce5afb4f531bb5fb4c453deb49f643f2a563f32f7908c995e5bc8e0d7de995b7 docs/RENEWAL.md
@@ -72,7 +72,7 @@ a699fd663bfbf001525f22f0fbacdf03a58c5e825232d8d144e51cbb8b1008b2 docs/SECURITY.
620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md
4245408286d8bdc3bed585c2f0928ecd4c69a3e48dfacbac9b952079ec2075b1 docs/SETTINGS.md
3611defd206cde107e75cac006159856d12cd8f15df7ae2eb9ec3592ed2b4abd docs/STREAMS.md
d9153240be9cca05079893e10b1649c3d5c9a3cf8517890afe0e3aff1cdd6fad docs/TEST_REPORT.md
cc678073533ef97b5b9397a1698e7a33a3d7156cc933d754adbf748443e70693 docs/TEST_REPORT.md
cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md
8ee6187a5460ca45643b7fd789e27bffd264449e3a92ff70e9359edee011bbbb docs/TRANSFORM.md
a1e9c6d38d21067d1ce7115ca3395ae8ff1f6a4c40fb0ebf00a6940ef79df439 docs/TRANSPORT.md
@@ -100,6 +100,7 @@ d0e609febbcd1cfdb8ea0b00b4fa6f081eac01e2d43a81e38a53ae25783152cc docs/V0.24.8_A
9dbb66e408bcd4d32267724e557fa9f7f44ef9b70db4ab170c61a7a839b7b157 docs/V0.25.3_AUDIT.md
583d548b4a5db9f35a2bbf8ec00ec22397764c5850a8e883a3d12680da517df7 docs/V0.25.4_AUDIT.md
bf9b4cf4fec2c8ce287e1b3c2e7d4828e101dafe4d6c173f099819f22493d144 docs/V0.25.5_AUDIT.md
05e0b4cc074b07ff37d45fabc20c7272bada217286b3db3d7997cae25c1f6f11 docs/V0.26.1_IMPLEMENTATION.md
ce259052c023e90a7bfb6e39fe82e52b845a8b2a051bfa29883d29e87a9301a2 docs/VHOSTS.md
b979afd3d56731eeb06e9dc38d37730693e7fbf3aa78467932842c0e712e7b26 docs/VHOST_ACCESS.md
f0e76167944861517d1d42b9186ff0b2f184be0371b9e8597827e60895de4a76 docs/WEB_SERVER.md
@@ -114,14 +115,14 @@ b26833cb4251f9390a44192ecc827ff7f052fd19016a5ccf7498c94503ab4ce3 examples/mock-
5a8f3a8a054abc89c6e78896b9fe2f903212199a24dc3d2d2bcbd0696a31bcde examples/transport.json
d242de9614fbb52933b4accd32f4715ca94107abe4a3b1d7e088362b07a6ba60 examples/vhosts-balanced.json
af2bdb1343a8141069e94ccd2c7f92574ad848437e2f9fdb5c7576a9a18ef4a5 examples/vhosts-private.json
a25dbbfb5f6ebdf23898cfc50828e965d14bbf9f2a397463010a07840cd84f49 grafana/README.md
8909eeefb8186c6c8a140b460501db96a7891cd062b7ad3d4ca754ea2185387c grafana/rustproxy-overview.json
b14d30a7785545a50a057f020b3df02a2f46a65c75fca0e5aba1dcb959852e57 grafana/README.md
7ba863b4731bb3240cc55fb3e97e6bb2492e43718980a0cfbddd98da70bef2c5 grafana/rustproxy-overview.json
30763fc61c38992a44bc24ad6932b88fd3b322724fce799db1f17f9a747eb59a install-debian13.sh
858b39fa6886b71d20690bf369e76580922364b3cc7f0cf6ba9b040df9cd76e3 install.sh
29cada0936762f26495064d1524a668262ed729b21f20aebddc8b6e5cfea363b langs/README.md
e034c5376b65bf23d49f652d5315e87d0d3f28b02c91955055227235121d78a3 langs/en_US.json
47680dc4c306623890febef47d847f50ed5e8780550c15391f13c3c15ca1c770 langs/pl_pl.json
d5df6eade5883206f52d6e015ddfd4cac0c904faaa7616c1c5c49ff5bd05035a make_zip.py
1b2f1e7127ccddb4f9c7cbca48282b5cb5f8fd38dbcdbc2c0a5695679821ee73 langs/en_US.json
822c1630aea00228118981a4dffdda05a0dc49dff715c111a0760b0ae50e1f4c langs/pl_pl.json
fca0b6e43ee1d1b970d9dd33eba93000c452e0380d9f0c3bbd9ee03b4868cfc1 make_zip.py
e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql
20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql
5c62da30fa9db594a4d656c77ae676d3c388072b6a85f91170ec7d911d7cde43 migrations/0004_admin_preferences.sql
@@ -140,11 +141,11 @@ edb6ee1445b01cd6f3a74a3a607f39fb107d411b49df028d2e0f4679e093de31 scripts/acme_p
e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/acme_rfc2136.py
a5b4451e45ce975685772aa8409b64a57a20a7548466584c3ea7fb975e64729c scripts/admin_audit.py
20e83f8b6347b819d1d1ea3de5c691c36f0816954a4210b0d3f39557fefe1931 scripts/backup.py
6a0425b289abfca1bc67d43ce8a586b20ffcb9d839e020ddbb1205c4a6336eb2 scripts/check_source.py
3e66e89b51f8b1328a68a68b9a87f865d4034c08f01e8ccbf37fe9fdb7e39d76 scripts/check_source.py
187ee92c8029cdd59e89b4447cf4d170e856017a2091ceb85ae7c22dd1a79c72 scripts/deploy.py
d2e954c80b229f8b4a37b2b401b0ac7611507cb76c03866cf688453c531b903e scripts/deploy_core.py
aafb8042851ae43b64fc51c793ed2134a308d1d155574ae79c1889afc226680a scripts/deploy_core.py
f014472588e889488976970dbaa95a8745b6907516aa81a7cca21d0f81eaaad3 scripts/deploy_runtime.py
948d910904143a98574c7fc256dfa2fac17572c6606876e5b64b0a8baf56eaba scripts/docs_check.py
64c746a3d225d810ee8aa9055cbafe80011fba49dbfe0f6857d631d171046ee9 scripts/docs_check.py
6facb2d65bfab234f09855097d12da8f72669a38ebceb4b1cbd03ab42b8d2469 scripts/langs_check.py
2e35b7c78887fd56f83ca9017caa168630963a9f47da71dfc53b1a8f9ad436c2 scripts/memory_contracts.py
2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migrate_npm.py
@@ -154,8 +155,8 @@ d1418f8e574a7390fd45a2aa879bdf5720b773162d65e295ab0350506aa5220a scripts/mock_a
2d3ff92300b09ce9cecbbaaa9309b4d33160fa4b2291c9952b784e8cfb599ab5 scripts/mock_server.py
2d4c859aae5ba357a6d30024c8487ae8f59f01b192711f13f9c2f8fee0f48b50 scripts/os-acme-client/README.md
c73b814a42bb4ff703441dbd177ee625055eeb115f9b5bb0c2150aede573a159 scripts/os-acme-client/opnsense-rustproxy-cert-deploy.sh
f9f8a13e3a8689a45db81c785acaa8c1a78757d3b101e430340f0a881424f9d3 scripts/preflight.py
7fdadf0f973aca3c811deb018f1191eee1bcea06446c1e641a80b7096c452bc2 scripts/release_smoke.py
6a314bfaf04760a0ae5da298a016400dab705c36769b49412400e6811d679b16 scripts/preflight.py
78e9f37c2b31314f582a232b3c45f5f620158094ca24619e47997a820ccf09fb scripts/release_smoke.py
45be151cd85fc2ef2f1fdc3003ace63381af262e73af8a685ab48cb697cc0de1 scripts/routeros-oci-build/.env.routeros.example
3b48cff8506e5a6ae7d3876f47721f5de7d63e2e02264a78cf5b6ddd52d9fbc6 scripts/routeros-oci-build/Dockerfile.routeros
7c7a3e51f43b43e1c962a4b0bb591f99c1f06977bf77f23719f7f867dfe7eb8e scripts/routeros-oci-build/Dockerfile.routeros.dockerignore
@@ -167,11 +168,11 @@ ecfe0bb21a7549225a19b0846bb6de8c99cbfbbf5a706d33cf84db2c7146abed scripts/rust_s
1c83054336b3508d7ad72cd259f039394682642439877fe69639c0b76be09154 scripts/security_contracts.py
295b6f2625b1875950ea3eecccd142f49be327afdd00c92641286e6de530ec51 scripts/security_dependency_contracts.py
a65010f828194885ffc7f0f733937957f28e6049cd978bec1380a2b8bfbd0113 scripts/source_module_contracts.py
41fe5df23e28d4b13d5e998906a8c3a18a312fbeb6cb856237cbae58291fa91e scripts/swagger_assets.py
e7790bf216ce8897d29df92c1b44b056644c3cf32417ead21197734fcc3d0f34 scripts/swagger_assets.py
102b23de19a88a8e60de392fb83aefd3dfb33e90cc97ed1690d9f3d4a33d8866 scripts/test_compression.py
c4097fecc34a7b545ffba7203b928f5edf4ed932936df903d90673fc23fac766 scripts/upgrade.py
8e5e828c8e18e78678664bdf8eea58adb7cfb736a9464cea1988fdf77c824850 scripts/upgrade.py
43db35e7264b0345592172f2d1f6a6e4919fc4cbde2a9ff787acd7fff1d031d6 scripts/web_assets.py
8af399947a8dac09ecf33aecbfe7a4c1fc38722605d936fba65d9715833fa367 scripts/web_server_contracts.py
4016a00b0d503163e01f9dae35067d813e588989a442e41e0d44a758c52a7a63 scripts/web_server_contracts.py
b8ce3e8fd08865015ce312fae9c0457612af18d6ef4513a6281b9e3212f8d458 scripts/web_service_ui_test.mjs
d8e2b51c39252dbb4305512fa9b1ef508ec610968aed316b10514fd160298762 src/acl.rs
4e3be1ea1be0a81529c7c9021fb9bb77c24e5044acd54094972745443ad2c26a src/acl/tests.rs
@@ -184,30 +185,31 @@ b9e57db4bd80ef37564dfc533a96897d8a494671d53acea9fb4bf28d23ba370d src/acme/provi
55ef7ceb44c25c7a8ddcbfd5e0d4b647526f9bdcde901ee08db5314e4d055b39 src/admin/access.rs
85f00f8157e83a152cc531f4cfecd0b1472ce269be9c8eb1ac17ad0693748b42 src/admin/certificates.rs
1de4c7c116eeab2452ccccbaba3f2b7b63c6eb1c1e146ab870d19c5a5ca16f80 src/admin/documentation.rs
c9e4237e64a11e7252d0b0b65e12adc2513b8232e9d5fbc81ef22ec81671df7a src/admin/documentation/documents.rs
89b6d6a0c2ddef5e77a8f9383105fd8a9b0e195de0ff46c012dd9d1ef57ee2a4 src/admin/documentation/documents.rs
67b901485d5858d5b5bf78a6260035db32241531d99481754c3533fdf404facb src/admin/documentation/tests.rs
b9eb9fff800ea42da1779ea123d6951f3ea8734f664aed577dbe5961f007443f src/admin/file_logs.rs
8ff4e0a57487c4244189f78bfeea0bfc509dcb996ed740000e3fa288fb8657bd src/admin/file_logs.rs
beb8be241e4f1a8cea7b6c4e02b42f0359fb1fff10ab5ce2c70ccd0e63a554b0 src/admin/logging_api.rs
aeb8351f4d8455cbc81f363862195ce3fda09a07291df7b1eba55cff64861a68 src/admin/mod.rs
ae136f17c60ea724906d170799866ef998e90418d53497696ded7424b8beb1b4 src/admin/openapi.json
fb079609d182aa63d32b7f2bfc7cf1a4229d7d500266e981fb0fde4fdd032961 src/admin/mod.rs
b40aae24eaf99ed8086951dc30d06d75fa0ae48fc2fee9745491337336b129c0 src/admin/openapi.json
a53309daf118381031122ca70764f7a2f92adc44af3fc91df5f946e6856f76ae src/admin/operations.rs
ee3e9c39eb2d645e55356b99c1af7d6a2ec14acd622e0e0287dea2fc47628fb4 src/admin/preferences.rs
b4fa393b780b2676b5d72e0f90cd7290292f424b8062d4b5983e722d6ca72c32 src/admin/public.rs
d246389975c87d125ebcb8c24282a3729d509cc70e13e0f3b56b4008d80d76b4 src/admin/router.rs
9e2981f9c96140bd6d9ca54e0d03b51b0783360e534a13e3b5cd1bec8c9440fd src/admin/session.rs
333d5f9f4c27941c535ac689e5826264ffa2bb86b00832b597a9bb9f811f01e8 src/admin/session.rs
f7938b202919a49fd2ff86ecdf09efadae12e0ffc0a4246a72a76218db11aa88 src/admin/status.rs
cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs
e5f973c6fbddc3897bc0e6d9f8e70cbe9279acea6161ffb46e013792e845e1e4 src/admin/tests.rs
5df98246c46135a176b4b8b705c92fd328719e82eb7342f088256abb976d5ae7 src/admin/tests/part_01.rs
6f8c1e30babb2cc4cb7accf4bfa28338651a469b16341f6e7eaec881dd9abf2b src/admin/tests/part_02.rs
77608ad33da348dbea41d2a7ceadb75b0bcf05336cb5a20b7f2a47502f1e4842 src/admin/tests/part_03.rs
bf3cc7a41c2575003515cc8212111395aaa0f2169b7d04b6bbd0908a6bc2af0f src/admin/tests/part_04.rs
60ad5c76db3cd6fdd144d504a7b50153e133ab357e49c8ebadb99c1c8bce562c src/admin/tests/part_04.rs
5f0062abc42a3b81c79088dc0824ab4c81a22c6ea765e77d2527d07d13bffd58 src/admin/tests/part_05.rs
b00210595ea1871cb0e04b4cf7325ade081cd9938bfabec4730344099ed45e9c src/admin/tests/part_06.rs
d6479bcf439621fb8ddc8e9ee71b599cf6d6cf72780422c490ed48b16684074d src/admin/tests/part_06.rs
23c55f23deeec844a9abc2289440c9e0583aaeb72416fc5f3632a43a864c0efd src/admin/web/acme.js
78e7d54d6536cd600dcbe66f0fc06ec109e121dfee1e57f3ca6e61cc6ae04a6a src/admin/web/app/config-history.js
a9953d0ed8ba3d301db51dabafeeac42c0697fc0d11a45abef501e790ac14758 src/admin/web/app/core.js
058c1aec00109237a3b5d88134db85cdd4197c45d0335f652b661400edf0fec5 src/admin/web/app/core.js
19abc332a1009bcb087fe4ebb58d458739b27480ab50715b03d07fd408302942 src/admin/web/app/runtime.js
2bfe0dc64d4386133b04bf71502ea9ed8ff346ab0023dc588c68496f6abf3ba3 src/admin/web/app/settings.js
cada294084cfa8cb7080143e1ad21d46136bb644ff5f15bb6d457dc034fe11ff src/admin/web/app/settings.js
d0be71555de13f732fd6ee44438a0110eb08c91e78bbc1aa36a1a84182780210 src/admin/web/cache-panel.js
50d1ebcbaf4d0b2968da23bb943fc3f67131c5d32df3f61d23ff37aad9da5b81 src/admin/web/demo.html
3b84a7a08f9f68e2c78fed4523b594c31a251addc5480a69c5f6949908230539 src/admin/web/docker.js
@@ -221,9 +223,9 @@ f7d0955ab18c15e04369204e789f341d2e832f927c1ff20a3b2b9e35e7898f00 src/admin/web/
386b9dc3aecebe7727f7cf7bbd2a02ab036a3abf01315975111a51559128df73 src/admin/web/error_503.html
e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/error_504.html
0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg
26b458e8e82e6cf844b3cda7b6d228db6f5b0560aeaff8c2e3c7113e7c9c69eb src/admin/web/file-logs.js
bd5b8789d03df1e0fd0c4da31f7980e74870c26485311f566e511433f9c09e2e src/admin/web/file-logs.js
dee3b113907f77138cdf919efdfb097e45b889cff3893bfc6f02ed998643bbae src/admin/web/host404.js
c3d38fbf046e287a77c51a5b0182e377a90d7dce7b3fb23c2e42a712e1347d80 src/admin/web/index.html
97444c4ab0441af6767e7383bf70c9f3c163b846614ce9b3623ab40e05731118 src/admin/web/index.html
24db88842707f764064657712d3660c6ac129fbfb47d188c0903925cf8244ce5 src/admin/web/integrations.js
cdab0c11a28deebdf401c0453fbc76b675e9e643722106fb20e812bb9648ccde src/admin/web/login-keys.json
a253d202dc9a147f2add34c64e0f35c38fdc7f667f9908933ce5f959b1a92a2e src/admin/web/login.html
@@ -232,7 +234,7 @@ e41619e5cb0ba01a3d287748f28eb38bbe7641f9bedade252d286c18e619c400 src/admin/web/
22318f39f7bb1ae98e65737d3c2ad9e63065b8e94ad59bed560c0e80d3a46949 src/admin/web/redirects.js
668f5162fa9f8bbe2fefce4115644a6469e01aa7defc9c7f760c28afd8e2afc7 src/admin/web/settings.js
1a30affd6a717095a1763998a5850a79ae0f86b7b694806f2aba2b880735dd1a src/admin/web/streams.js
a68cbb41d43d699aea8f71ec58af347695dfd91f5a4b13d17b96a231fa942749 src/admin/web/style.css
c6f8dcdf07f0bf27036d9b2f37bdec56c0144b7f7365e6f8b968f700047697b4 src/admin/web/style.css
47e98065281c04b4c185d53df4ed0cb12fabc716e52143e6ae3c3c25db62126a src/admin/web/traffic-panel.js
d818580e61bbb70da1f758b9231c0667639cd947d875a55adc21dcf9ed1319c2 src/admin/web/transform-panel.js
ac79a0f5471f110850bf5ceaaee9a3903bb92fb2edc10e969d8fda81f66571c0 src/admin/web/vhosts/core.js
@@ -308,11 +310,11 @@ ad47db6519e471ec53a408129bb10a794033430e28fcd3d9a4cf273669900829 src/compressio
7921ae451ccd65d647165b02919bd97399399da471454a6a1a1f63e682b76fc8 src/config.rs
36c8774fe6fdc44d13286b7688726ceaa9b55cee4c63113689df63456cbecaac src/config/backend.rs
8b39e92594133c32b6e113c67849fc10f51ceec9e3bbf69211779f6628d0e001 src/config/compile.rs
66b3061200c06c4fffe3350e3807bd9948b9b05aa91410da5d05edef1e0d791d src/config/model.rs
bb1059f07a0e7bbffeb2575334bbe5e95d1f3167c9fe0769696f585523a42471 src/config/model.rs
4d2a4a078e890a9d1582e2cc6f9b8f0e8c2c676c4c94bc927bffdbe55e932610 src/config/routing.rs
9bc22e19b7cfd3b7555faac8497c7410ef48e8c6b4eb3515f77f6a0e2f251c5d src/config/snapshot.rs
9662674d17740df4999f28bec42a2fbd3453a93e591c88f0796edc396bb23a18 src/config/snapshot.rs
7d94d1bd4eb698e0588b822d93e56e3dc5594c46edac4aa9713f148893ebe6d5 src/config/tests.rs
421501ac3c524b14a5b7f51bdca75acd69903cc45dcbc51bd5e295a29e3e5ba0 src/config/tests/part_01.rs
7592bdeaad9950a720e2d841fcb1a4edb2a68af5333a6163e672d0392616103c src/config/tests/part_01.rs
fb0d4d91ae95fe551b06b9195f9e6d41bc40fb6a3a7c547b170fcfeb9abe213c src/config/tests/part_02.rs
fa4e955ad23c0b5b3fb25af9f144c5c483f116fb79568300543b6501575d9e18 src/db.rs
2ff744223934685439374572b83fd639bb3eabdbac168bea4a6501a6e9ce0ee0 src/db/history.rs
@@ -330,23 +332,23 @@ fe571c85a924c827097f4be01b3a827a16bc5dd17a55fbf991ee3ad016641305 src/docker/tes
e40171e72327bc4350ba95cedacde2f3c9148c26a5a232d9f790f4449f20a685 src/geoip/tests.rs
650818b60f76e1ae1a26c6fbf71d7ba63bc85dcb88754a592e8da87b5938e5af src/headers.rs
01cb98570ec388170420883e4ba18b2bf85ad1af97854dbc3a12b135db511c5c src/headers/tests.rs
1988f116779d725d324fe7b8af0eed071916efaffb38636b55e6276966a36056 src/http3.rs
d0cd8589321595309834690fa9ddb98488886bc98f34ea45aa0ccb3035412fb5 src/http3.rs
2165a0a4b27d621985e2030326ad52b8bbd59d7b9e4f092f72f7e92b4e58b497 src/http3/body.rs
68bc11160bc5275a904e48af8698b37433096fb6d592364d8281d59bb9e2b3f4 src/http3/tests.rs
3dab8fac75263120e48ee869ad55a3e2ccc2a19fc9f6d6a6fe055657ec55b1e3 src/lib.rs
2551189a3e5701371026c9abd2c5d44d37495f28fb67e220180421bfd315e3dd src/logging.rs
d8abc31f130e85b38c760b96e3369639464bca513991532e025485489a0af70e src/logging/error_runtime.rs
2ee000906a56219cda4124b5bafad4af326a70ff0baa160d8f920ad90c5ebef9 src/logging/format.rs
15f88b956b2a4804ba82f493ab20e8bd1ca57920a9fd218e9e3d85867c796d81 src/logging/model.rs
c4c2f77e4e64943ac7e5d26688743a19e1349d2c984fec20dcff70373af1c254 src/logging/runtime.rs
a978bb38c87336145395d598213718ee9ede41e82e3ab89883fdb8e3b024d944 src/logging/tests.rs
a16613114273c54b6d2a16a94fa8748550e91468585469642622bd2c86087baa src/logging/format.rs
83e7bd1517ede4a2d1696ba0b22bdd5d34a4c29fcc8eb5c30c454dc4eba66853 src/logging/model.rs
7c0d8e8f46f96cbaf3a5cb5fd47953f9185d7558dd71e599676bc63578b7ca99 src/logging/runtime.rs
17425ebbca6bbbb0dcaeb65ebc44b1e51488a4092b7b6add556e764c6d486d6a src/logging/tests.rs
3836ed14b6710e183f048ee4d7807a35366c27a27338057fe4a2811129b63302 src/main.rs
46732ed6d9cf2a33031624f941b8318d2676fb1b6bb5142e96820301f9fa1ac4 src/main/admin_runtime.rs
86d62387fb73c221aeed3d0136b4b4954e5feac8533f8b3490b585d17fb743ac src/main/bootstrap.rs
7d1ed0a639ed79b914d609d913b3004d5da7ad60695a487799ef42bcc4d74c9c src/main/bootstrap.rs
5a98d0e249338e73aa57ae2e352dc16db2f79c2d042c1544ce1c2022da76a31c src/main/proxy_runtime.rs
4dd7c1084a6214c0f2513ebf86fe8ef144470695557772bfe25979edd64491ab src/main/shutdown.rs
0a6578a0f4023b9f8d73043bf942dc5e096ef9afb8c0d2f131c14f65d08ac01d src/main/shutdown/tests.rs
7110171b265945d44a45ca09a02c9860e7bbae4e9aec2bc9583c7a65a3fea907 src/main/tasks.rs
d2b53a7681476f368b169a1c3bb2e4646af0c27084e52cd45cc7c37a2451cfcb src/main/tasks.rs
49d7e104585c8791ac9a203dddd95802784b68c0ba65d6f90f3d57154b4c7871 src/metrics.rs
201ab702722d6d21585fe8bb49939150d61bfac7ed8dc198b98c2cc6098ffb5f src/metrics/global.rs
49b11df65b4fb0b5b727f9f867436584680b45e3f4fbd53f74bc0cab8b9a88ec src/metrics/vhost.rs
@@ -362,7 +364,7 @@ d3c6b08088df0003144544936a55e2a3661aac85292821b594f00adfd19a2951 src/prometheus
064b2be7fed2f6acf526b1cce070c1bb1b943d36f08fa50bbfcbdf3cf1c9318c src/prometheus/tests.rs
028e1dd9f2ec2afaf699a6b21c2d4b0d96e76f11f5e4ed009c233dadf0f3be51 src/prometheus/traffic.rs
e03f0d6b3f35dae6b98c394837aa0d17e4d73a8306767b4c607868bc00c30286 src/prometheus/vhosts_backends.rs
b4ab7010056ff095e4c4d2f878d2301b8ce626bd2059ef4fd54c3647c16d4f61 src/proxy/body.rs
b5856fd38a957b2ca9c7e3606d84d13d5cfcf3a45b487b15ae1d2dc248cbda57 src/proxy/body.rs
16b9d4db3b20b2ab14c47ac14fcd18d3d8ca77c3ad9a1f37866ebfa60f379862 src/proxy/body/tests.rs
00e50a914511754266b4eae2626244587174bfa71fff91509c530bcde7f0d397 src/proxy/cache_helpers.rs
cfb0910af8cd868bde3f6c9992319e46d5328d6d8f69c50701df0ea12bef2108 src/proxy/forward/cache_lookup.rs
@@ -399,11 +401,11 @@ b4c17b304166592e63c81884076589fbaf725c3f57043f8924c46167b061ac5b src/secret_sto
6fa1d8c352bce7a16ff0208bc4cdbe80404715df0aff4ed26910a5df57cbbcd4 src/security.rs
0e5fc2aff856bf6fd959cba12b5ddd1e3b3cbeb0fbaadde906c811cdfafdaae7 src/security/tests.rs
53a4bed4d1233aa434712b946857ea2050acaaf5de011c6dadca5b5c22155a83 src/server.rs
a5495ac40a75d8c94b6f1c47b7e9c0946f836d40735464d4d18ce6705541d063 src/server/http.rs
f2f6196632b2fdb2444189624f961edaf5bdfff57f700b0207b57e2491c1daa5 src/server/http.rs
8c6ca5eaffb9acab57199d0349722703b903cfff3752baad2cf2596bd51188de src/server/listeners.rs
c362593f2feb7cb3b22b2a4fb22f8afa128e72ca8185111ceb9bd0f8125abe55 src/server/serve.rs
b209c1135aa30c5a2d1edb3f8a80b9b95398b2c2e4c03139b59b8a4955f1eb99 src/server/serve/tests.rs
c123c2e1bfc127621a5c8915ad3e0a32f57d557b2fd8760f1cfc537d703e1a02 src/server/tls_runtime.rs
71899a9b1897fa22ab9e8eb6a670054a6d382bad78714006b9b218d07ba99902 src/server/serve.rs
e9e478cf45b88994eaffd115f51764b6b07852ee3c941cafdabb20584d3dabc0 src/server/serve/tests.rs
048f2c7a8ef698a172ba7ed844558f4118b367b2cf340748c5c28da7723d8e85 src/server/tls_runtime.rs
1be88858215d393d4f62f04f36a77ee64ce476be1b642c8f37e059c33423fea7 src/settings.rs
b8b3150abe1c6f61d3f593e169795aae397b578607710ea6fd887b90f37b872e src/settings/tests.rs
566daf9c0e1a30da2dc18e51090541a85ced3320a0fc00ed1d1de3e2b4da5072 src/stream.rs
@@ -453,7 +455,7 @@ f0479524d66bfee7a6eed5eea03601630ba82064fb944902deb9166f90fe17cf src/vhost/vali
9c1c8d6eedecb95eadbfb23c3caea562f6f7a995606ef988f25b58c2d03bcdd5 src/web_server/config/model.rs
6191f4d541476c4a3ae296e7dd28bdb9f8a123fc509b47619f6f44519c44d278 src/web_server/config/validation.rs
86c744216d7132d8294a6e90ba9be5fd871646006313d1c62506d40c81db9b12 src/web_server/fastcgi.rs
7fd2d89aa016f03e602c140c49ea24c95a66a90abfa6de186e6806b224e68252 src/web_server/fastcgi/protocol.rs
4cd184d73f00bc7abc967e0fe574e16f84de1e9d999e599de6f4a873050f9e61 src/web_server/fastcgi/protocol.rs
134c4998f6a0aa27e121af1ae3355b7e335488b2ae867ac0a934c58b4eb77191 src/web_server/fastcgi/response.rs
8186363315bd998919c6c783e6f7501528f257a5c0da77c5d60f423e8670db5e src/web_server/filesystem.rs
0aafd7f37c4fe784b047646e4ce5ddcb5fe821231a41ed0a9f519529385288a8 src/web_server/htaccess.rs
@@ -463,14 +465,14 @@ f0479524d66bfee7a6eed5eea03601630ba82064fb944902deb9166f90fe17cf src/vhost/vali
80f946570c14c8915fd21bfdb1a84dfb6a14c633ab6060acb828fab4c7c21daa src/web_server/mod.rs
67ab8bef567452f123c4b9b46a942b39c93f1f765c128edca7ee808ff1c5e304 src/web_server/request.rs
c5cb561b0f276e8082f904b9446fa5885f6b3c0608f25ea2ef425061e9f7f6b4 src/web_server/response.rs
6c1b6b61fadb978620bb6906e535e1b96ac9cb3f062b6178931a8fc267bd8191 src/web_server/response/file.rs
9dd6c0861cfd11a278002c665e81440a302d0a91f2e486f9437d0185bbc40542 src/web_server/response/file.rs
9aa68251a5f549f4ce3b1bda88d4abb9efc629caf444989381b4117c9488e37b src/web_server/response/headers.rs
2268a6aeed2f565f9791af33e1cd21c56c228c8873b031725b39a6c57184f2d1 src/web_server/routing.rs
400e1c8aa2fe857d96036a64499fbc3de44c1e3a53811c5b25101fb843359792 src/web_server/security.rs
3e0b4e92600a7d5abcec92efed532b3a01e0781cb4f2681daac6d6ae13b0a6e9 src/web_server/serve.rs
cf86c4692080723bbdf8f4105e1abfae33d7fcf020d2ad9e13ebae3943bd0cbe src/web_server/tests.rs
c62752fe2fa6b74ddc9d94d36f8d191376b1575b15d21fc434c5a5da4c932692 src/web_server/tests/part_01.rs
8c15c626e879016d5d3e3f0c30af42aa9acd18ce55b6965976ca6895e0af9028 src/web_server/tests/part_02.rs
070141f7e6f0a5659ac18f90e03fd73a73767aded81d752c342440dbee80b5f0 src/web_server/tests/part_02.rs
e54f44f6ca1ed2c41e620de9195d32c4f8bb6936c3881745316a07160f87b4fa src/web_server/tests/part_03.rs
f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json
1725c10331745dc04a07675784d385260da7b14567b8dba3cbae5e891eb5dadf tests/http2.rs
+1 -1
View File
@@ -1,6 +1,6 @@
# Debian packaging
This directory builds the `rustproxy` binary package for RustProxy 0.26.0.
This directory builds the `rustproxy` binary package for RustProxy 0.26.1.
The package installs:
- `/usr/bin/rust-proxy`
+1 -1
View File
@@ -21,6 +21,6 @@ cargo fetch --locked
dpkg-buildpackage -us -uc -b
mkdir -p dist
find .. -maxdepth 1 -type f \( -name 'rustproxy_0.26.0_*.deb' -o -name 'rustproxy_0.26.0_*.changes' -o -name 'rustproxy_0.26.0_*.buildinfo' \) \
find .. -maxdepth 1 -type f \( -name 'rustproxy_0.26.1_*.deb' -o -name 'rustproxy_0.26.1_*.changes' -o -name 'rustproxy_0.26.1_*.buildinfo' \) \
-exec cp -f {} dist/ \;
echo "Packages copied to $ROOT/dist"
+8
View File
@@ -1,3 +1,11 @@
rustproxy (0.26.1) stable; urgency=medium
* Attribute persistent protocol errors to configured vhosts using route, Host/authority or SNI.
* Improve the Logs explorer with selectable columns, sticky headers and inline record details.
* Fix mapped client-IP display and protocol-diagnostic keyword search.
-- RustProxy Maintainers <noreply@example.invalid> Mon, 05 Oct 2026 17:17:00 +0200
rustproxy (0.26.0) stable; urgency=medium
* Persist malformed HTTP, TLS/QUIC and HTTP/2/HTTP/3 protocol failures through bounded logging sinks.
+2 -2
View File
@@ -18,7 +18,7 @@ if [[ $# -gt 0 ]]; then shift; fi
case "$mode" in
help|-h|--help)
cat <<'HELP'
RustProxy 0.26.0
RustProxy 0.26.1
./dev.sh Build and start proxy + local panel
./dev.sh run [serve flags] Same, with extra server options
./dev.sh admin setup-token Generate/print a one-time setup capability before first login
@@ -139,7 +139,7 @@ if [[ "$mode" == "test" ]]; then
printf 'Checking all Rust targets before tests...\n' >&2
cargo check --locked --all-targets
fi
printf 'Building RustProxy 0.26.0...\n' >&2
printf 'Building RustProxy 0.26.1...\n' >&2
cargo build --locked
BIN="$CARGO_TARGET_DIR/debug/rust-proxy"
case "$mode" in
+1 -1
View File
@@ -1,6 +1,6 @@
package main
const version = "0.26.0"
const version = "0.26.1"
type config struct {
Listen string
+1 -1
View File
@@ -1,4 +1,4 @@
# Administration API access - 0.26.0
# Administration API access - 0.26.1
## Sign-in boundary
+45 -19
View File
@@ -1,44 +1,72 @@
# Stored log explorer
RustProxy 0.26.0 adds an authenticated, read-only **Logs** workspace at `/logs`. It is a local file-backed explorer for RustProxy logging outputs and is intentionally separate from **Live Logs**.
RustProxy 0.26.1 provides an authenticated, read-only **Logs** workspace at `/logs`. It is a local file-backed explorer for RustProxy logging outputs and is intentionally separate from **Live Logs**.
## Separation from Live Logs
Live Logs uses the bounded in-memory request history and Tokio broadcast channel. It remains available even when file logging is disabled and is optimized for current request activity.
Live Logs uses the bounded in-memory request history and Tokio broadcast channel. It remains available even when file logging is disabled and is optimized for current routed request activity.
The stored-log explorer does not read that memory ring. It reads only files derived from the active RustProxy logging configuration:
- the combined structured access-log path, or JSONL files directly inside the configured per-vhost directory;
- the configured optional error-log file.
Existing files remain browsable after their writer is disabled. Disabling file logging therefore stops new persistent access/protocol records but does not remove old files.
Existing files remain browsable after their writer is disabled. Low-level `protocol_error` records are sent only through configured persistent structured-log sinks and are deliberately not inserted into Live Logs.
## Protocol and malformed-traffic records
## Vhost-aware protocol records
Ordinary completed requests use `event: "request"`. RustProxy can also persist low-level records that fail before a normal routed HTTP exchange exists. Those records use:
Ordinary completed requests use `event: "request"`. Low-level failures use `event: "protocol_error"`. When RustProxy can safely identify a configured vhost, the record is persisted through that vhost's existing sink. In per-vhost file mode an unresolved record uses `_unmatched.jsonl`.
Attribution priority is:
1. vhost already selected by normal routing;
2. HTTP `:authority`;
3. HTTP `Host`;
4. TLS/QUIC SNI;
5. no safe match -> `_unmatched`.
All hostname matching reuses the normal exact/wildcard frontend resolver and listener binding rules. A listener or local port by itself is not sufficient to select a vhost.
A representative record is:
```json
{
"event": "protocol_error",
"reason": "tls_handshake_error",
"protocol_error": "tls_handshake_error",
"error": "...",
"error_detail": "...",
"client_ip": "192.0.2.10",
"local_addr": "192.0.2.20:443",
"tls_sni": "app.example",
"transport": "tls",
"http_version": "",
"status": 0,
"outcome": "protocol_error"
"vhost": "app",
"sni": "app.example",
"tls_sni": "app.example",
"authority": null,
"matched_by": "sni",
"duration_ms": 0
}
```
Covered classes include connection-limit rejection, malformed/incomplete HTTP/1 connections, invalid/disabled h2c upgrades, HTTP/2 protocol termination, TLS ClientHello and handshake errors/timeouts, and HTTP/3/QUIC handshake, header, protocol and stream-limit failures.
`matched_by` is `router`, `authority`, `host`, `sni` or `none`. Compatibility fields from 0.26.0 remain present so existing consumers do not need an immediate migration.
These low-level records are sent only through the configured persistent structured-log sinks (file, Logstash and/or Loki). They are deliberately **not** inserted into Live Logs, so scanner noise cannot displace the ordinary bounded live request history. For `protocol_error` events, the diagnostic envelope fields shown above are always emitted even when a custom JSON field mapping is active; custom/static fields are retained, but they cannot hide or overwrite the required protocol-diagnostic fields.
SNI, authority and Host are lookup inputs only. RustProxy never derives a filename directly from client-provided identity text. Only the configured internal vhost identifier returned by the shared resolver can select a vhost sink. Invalid internal IDs fall back to `_unmatched` rather than being sanitized into a new path.
Dependency error text is secret-redacted and capped before it enters the persistent record. Raw packets, request bodies and raw malformed request bytes are not captured.
Diagnostic identity values are control-character stripped and bounded before persistence. Existing error-detail redaction and limits remain in force. Raw packets, request bodies and raw malformed request bytes are not captured.
## Browser security model
## Logs Explorer UI
The workspace supports source, case-insensitive text, event class, HTTP status class, client IP and vhost filters. Text search scans stored values, including protocol diagnostics such as `reason`, `error`, `sni` and `authority`, and refreshes with a short debounce while typing.
The table provides account-scoped selectable columns. The default view keeps the original concise columns while optional protocol columns expose transport, match source, SNI and authority. At least one column must remain visible.
The table header stays visible while the result list scrolls. Selecting a row highlights it and expands the complete JSON record inline immediately below that row; selecting it again collapses the details.
The configured-file inventory is collapsed by default so installations with many per-vhost files do not lose most of the screen to source paths. The source selector still contains the configured sources, including `_unmatched.jsonl` when present.
For structured access logs, the explorer reconstructs canonical fields from the active JSON field mapping. In particular, `client_ip` remains available to the table and filter even when the output JSON key was renamed by a custom mapping.
## Browser security and bounds
The browser never supplies a filesystem path. `/api/file-logs` returns opaque source IDs derived from the current configuration, and `/api/file-logs/query` accepts only those IDs or `all`.
@@ -46,16 +74,14 @@ The reader is bounded:
- up to 2 MiB scanned from the tail of one source;
- up to 8 MiB across one query;
- up to 20,000 lines across the complete query, shared fairly across selected sources;
- up to 20,000 lines across the complete query, shared across selected sources;
- at most 32 sources in an `all` query;
- page size at most 500 and offset at most 10,000.
If more than 32 configured files are eligible for an `all` query, the newest 32 are scanned and the response is marked truncated. A specific source can always be selected directly.
File reads run in a blocking worker rather than the async proxy runtime. Only regular files are accepted. On Unix the reader opens with `O_NOFOLLOW`; a symlink is not followed. Per-vhost enumeration also rejects a configured directory that is itself a symlink and only lists direct regular `.jsonl` children.
The endpoints are private administration routes and inherit the existing panel session/Host/Origin access controls. There is no public-proxy route to the file reader.
## Filters
The workspace supports source, case-insensitive text, event class, HTTP status class, client IP and vhost filters. Results are paged from the bounded scanned tail. Selecting a row shows the complete parsed JSON object. RAW error-log lines are wrapped as a record with `event: "error"` and a `raw` field.
The explorer is diagnostic rather than a retention system. RustProxy does not add rollover, indexing or compression; use the existing operating-system log rotation policy for retention.
The explorer is diagnostic rather than a retention system. RustProxy does not add rollover, indexing or compression; use the operating-system log rotation policy for retention.
+1 -1
View File
@@ -1,4 +1,4 @@
# Installing and updating RustProxy 0.26.0
# Installing and updating RustProxy 0.26.1
The scripts prepare a Linux/systemd installation. This source archive is **not a
verified production build**: Rust/Cargo, real CA operations, live systemd startup
+4
View File
@@ -1,5 +1,9 @@
# Deployment and configuration migration history
## 0.26.0 -> 0.26.1
No SQLite or saved-configuration migration is required. Version 0.26.1 changes persistent protocol-error attribution and the Stored Logs UI/API normalization only. Existing JSONL files remain readable, Live Logs behavior is unchanged, and unresolved protocol errors continue to use `_unmatched.jsonl`.
## 0.25.7 -> 0.26.0
There is no SQLite schema or saved-configuration migration. Version 0.26.0 extends the existing structured access-log record with optional protocol-diagnostic fields and adds a read-only stored-log explorer. Existing logging settings remain valid and file logging remains disabled by default. Low-level malformed/TLS/protocol events are written only when at least one external structured-log sink is enabled; they do not enter the Live Logs memory ring. Existing JSONL access files and the configured error file can be browsed after upgrade without conversion.
+2 -1
View File
@@ -15,6 +15,7 @@ This is the canonical index for the current project documentation. The authentic
- [0.25.1 release audit](V0.25.1_AUDIT.md) - historical Traffic/module-split release scope and compiler-regression guards.
- [0.25.0 specification audit](V0.25.0_AUDIT.md) - Web Server foundation point-by-point implementation status.
- [0.25.0 pre-production security audit](V0.25.0_SECURITY_PREPROD_AUDIT.md) - threat-oriented source/configuration review, fixes made in-session and mandatory runtime security gates.
- [0.26.1 implementation note](V0.26.1_IMPLEMENTATION.md) - vhost-aware protocol logging, shared resolver and Stored Logs UX changes.
- [TEST_REPORT](TEST_REPORT.md) - verification commands, results and environment limitations.
## Functional documentation
@@ -60,7 +61,7 @@ This is the canonical index for the current project documentation. The authentic
## Historical or version-labelled context
Version-labelled `V*.md` files are retained as historical implementation/audit records. Their version number describes the audited milestone; they are not current-release documentation. The current audit is [V0.25.5_AUDIT.md](V0.25.5_AUDIT.md); [V0.25.4_AUDIT.md](V0.25.4_AUDIT.md) records the prior retained-history restore milestone and [V0.25.3_AUDIT.md](V0.25.3_AUDIT.md) records the prior request-policy/history milestone and [V0.25.2_AUDIT.md](V0.25.2_AUDIT.md) records the prior backup/session hardening milestone and [V0.25.1_AUDIT.md](V0.25.1_AUDIT.md) records the prior Traffic/module-split milestone and [V0.25.0_AUDIT.md](V0.25.0_AUDIT.md) remains the Web Server foundation audit.
Version-labelled `V*.md` files are retained as historical implementation/audit records. Their version number describes the audited milestone; they are not current-release documentation. The current release implementation note is [V0.26.1_IMPLEMENTATION.md](V0.26.1_IMPLEMENTATION.md); the latest retained audit is [V0.25.5_AUDIT.md](V0.25.5_AUDIT.md); [V0.25.4_AUDIT.md](V0.25.4_AUDIT.md) records the prior retained-history restore milestone and [V0.25.3_AUDIT.md](V0.25.3_AUDIT.md) records the prior request-policy/history milestone and [V0.25.2_AUDIT.md](V0.25.2_AUDIT.md) records the prior backup/session hardening milestone and [V0.25.1_AUDIT.md](V0.25.1_AUDIT.md) records the prior Traffic/module-split milestone and [V0.25.0_AUDIT.md](V0.25.0_AUDIT.md) remains the Web Server foundation audit.
Older context documents such as [INTEGRATIONS_040](INTEGRATIONS_040.md), [MANAGEMENT](MANAGEMENT.md), [SECURITY_AUDIT](SECURITY_AUDIT.md), [ACME_RFC2136](ACME_RFC2136.md) and older version audits remain available for traceability but should not override the current functional/technical documents above.
+17 -32
View File
@@ -1,55 +1,40 @@
# Verification report - RustProxy 0.26.0
# Verification report - RustProxy 0.26.1
## Scope
RustProxy 0.26.0 is a minor release focused on persistent malformed-traffic diagnostics and a built-in stored-log explorer. Public listeners now enqueue bounded `protocol_error` records for malformed/incomplete HTTP/1 traffic, TLS ClientHello/handshake failures and timeouts, invalid/disabled h2c attempts, HTTP/2 connection/drain protocol failures, HTTP/3/QUIC handshake/request/header/stream failures and connection-limit rejects. These records use the existing asynchronous structured-log sinks and deliberately bypass the Live Logs memory ring so hostile scanner traffic cannot evict ordinary live request history.
RustProxy 0.26.1 extends the persistent protocol diagnostics introduced in 0.26.0 with safe per-vhost attribution and updates the Stored Logs explorer. `protocol_error` records can now carry canonical `reason`, `error`, `sni`, `authority`, `matched_by` and resolved `vhost` diagnostics while retaining the 0.26.0 compatibility fields. Attribution reuses the normal exact/wildcard frontend resolver and never derives a filename directly from client Host/SNI input. Unresolved traffic remains `_unmatched` in per-vhost file mode, and protocol errors continue to bypass the Live Logs history/broadcast path.
The authenticated `/logs` workspace is independent from Live Logs and reads only configured RustProxy access/per-vhost/error files. It uses opaque source IDs rather than browser-supplied paths, regular-file checks plus `O_NOFOLLOW` on Unix, bounded blocking reads, a global 20,000-line / 8 MiB query budget, at most 32 sources, bounded pagination and server-side filters. Existing configured files remain browsable while their writer is disabled. Required protocol-diagnostic envelope fields are retained even when an administrator uses a custom structured JSON mapping. SQLite remains schema 8 and the saved configuration format remains compatible with 0.25.7.
Stored Logs now supports account-scoped selectable columns, sticky headers, highlighted rows with inline JSON details, a collapsed configured-file inventory, custom-field recovery for canonical `client_ip`, and value-based keyword search including protocol diagnostics.
## Implemented automated coverage
## Source-level regression coverage
0.26.0-specific Rust tests cover protocol-error separation from the Live Logs ring, required diagnostic fields under custom JSON mappings, secret redaction and UTF-8-safe detail bounding. Admin tests cover visibility of existing log files while writers are disabled, bounded tail reads, oversized source-ID rejection and Unix symlink refusal. The HTTP/1, TLS/H2/h2c and HTTP/3 adapters contain explicit persistent diagnostic emission points for the failure classes listed above. These Rust tests are present in the source tree but cannot be compiled/executed in this workspace because Cargo is unavailable.
0.26.1-specific Rust tests cover shared logging-vhost resolution and precedence, authority/Host normalization, unknown/missing SNI, safe vhost sink names, bounded protocol identity fields, persistent protocol fields under custom JSON mappings, Live Logs separation, Stored Logs canonical client-IP recovery and protocol-diagnostic text matching. Administration preference tests cover the new Stored Logs column list and backward-compatible defaults.
Rust unit tests in `src/web_server/tests.rs` cover GET/HEAD/index/slash redirect, MIME/length, validators, valid/invalid range handling, If-Range, escaped/repeated directory listings, per-directory index-before-listing fallback, generic 403/HEAD behavior, MIME overrides/default MIME, cache rules, source headers, `try_files`, rewrite redirect/internal behavior, `.htaccess` WordPress-style front-controller routing, nested DirectoryIndex/MIME/Header/Expires behavior, strict-mode rejection and PHP-source protection, hardening probe/method blocking and fingerprint-header stripping, precompressed representations/ranges, download headers, PHP FastCGI fixture execution/failure-source-disclosure behavior, request-body 413 enforcement, custom FastCGI/PHP_VALUE validation and reserved-parameter rejection, direct traversal rejection and Unix/Linux symlink policy. Upstream tests cover request-budget client generation recycling and disabled recycling; balancing tests cover the new keepalive defaults and validation bounds. `src/traffic/tests.rs` includes bounded-table, effective-client-IP view and fixed Prometheus User-Agent/method family coverage. Configuration tests cover pure static vhosts without a pool, mixed Web Server/Reverse Proxy routing, reuse of one Web Service by multiple vhosts, unknown service references and backward-compatible default source behavior. Vhost tests cover method-policy normalization/allow/block behavior, database tests cover retained configuration revision documents, actor metadata and previous-revision lookup, and admin tests cover account-scoped/CSRF-protected view preferences including legacy preference defaults, backend-table columns/sort persistence and invalid-column rejection, plus restoring revision 1 as revision 3 while preserving the previous document and emitting `config.restore`. ACL tests include encoded traversal/separator/malformed-encoding cases.
Rust-independent release checks additionally validate UI/JSON/language/source contracts and reproducible packaging.
An external 0.26.0 `cargo` build exposed two compiler regressions that the Rust-independent gates could not detect: `frontend_state` was moved into the HTTP/1 service closure and then reused for connection-error logging, and `serde_json::Map::entry("event".into())` was ambiguous with the pinned dependency graph. Both are corrected in this source tree by giving the service closure its own cloned frontend state and by passing the `"event"` literal directly to `Map::entry`. The Rust-independent release gates pass after these corrections. A fresh compiler-enabled `cargo check/test` is still required to confirm there are no further Rust errors because this workspace has no Rust/Cargo toolchain.
During the prior 0.25.4 compiler gate, an external build exposed one module-privacy error in the history split (`CONFIG_HISTORY_CAPACITY` was private to `db::history`) and one unused `country_resolved` assignment in the location GeoIP branch. Both were corrected before the 0.25.5 baseline: the capacity is exposed to its parent module with `pub(super)`, referenced through `history::CONFIG_HISTORY_CAPACITY`, and the dead assignment is removed.
### Modular source-tree verification
- `src/admin/documentation.rs` was split so the embedded Reader document catalog lives in `src/admin/documentation/documents.rs`; both files now stay below the 500-line Rust module limit without changing Reader behavior.
- The Rust module contract now recognizes the existing `*_tests.rs` naming convention, and the documentation contract validates embedded documents independently of `include_str!` line wrapping.
- `python3 scripts/rust_module_contracts.py`: PASS.
- `python3 scripts/source_module_contracts.py`: PASS. Dashboard JavaScript, Docker Agent Go implementation and operational Python modules remain within their source-size contracts.
- `python3 scripts/docs_check.py`: PASS. The documentation index, authenticated Reader/API split and local Markdown links are consistent.
- Cargo parsing/type checking remains a release-host requirement because this workspace has no Rust/Cargo toolchain.
The HTTP/TLS/H2/H3 sources carry the resolved identity through the persistent logging path. TLS ClientHello SNI is retained for later handshake and HTTP/2 failures; HTTP/3 reads the completed QUIC/Rustls handshake identity for later request/connection diagnostics. Failures before a usable identity exists remain unmatched.
## Verification status in this workspace
| Check | Status |
|---|---|
| `python3 scripts/langs_check.py` | PASS |
| `node --check src/admin/web/vhosts/render.js` | PASS |
| `node --check src/admin/web/vhosts/editor.js` | PASS |
| 0.26.0 stored-log/protocol diagnostics source contracts | PASS: persistent protocol-error emitters, Live Logs separation, authenticated `/logs` explorer, bounded source-ID file access and required diagnostic fields are present |
| retained 0.25.7 vhost/backend observability contracts | PASS: quick enable/disable, backend search/sort/column controls, retry/client-lifecycle metrics and matching dashboard coverage remain present |
| `python3 scripts/memory_contracts.py` | PASS |
| `python3 scripts/web_server_contracts.py` | PASS |
| `python3 scripts/security_contracts.py` | PASS |
| `python3 scripts/security_dependency_contracts.py` | PASS |
| `python3 scripts/source_module_contracts.py` | PASS |
| `python3 scripts/rust_module_contracts.py` | PASS |
| `python3 scripts/rust_module_contracts.py` | PASS after keeping modified Rust modules within the 500-line contract |
| `python3 scripts/docs_check.py` | PASS |
| `node scripts/web_service_ui_test.mjs` | PASS |
| OpenAPI/language/Grafana JSON parse | PASS |
| `node --check src/admin/web/file-logs.js` | PASS |
| language/OpenAPI JSON parse | PASS |
| `python3 scripts/check_source.py` | PASS; explicitly reports Rust parsing/types/runtime as not verified |
| `python3 scripts/release_smoke.py` | PASS, including upgrade/package/reproducibility contracts |
| `go test ./...` in `docker-agent` | PASS |
| `python3 scripts/check_source.py` | PASS; explicitly reports Rust parsing/types/runtime not verified |
| `python3 scripts/release_smoke.py` | PASS, including SQLite/backup/upgrade, Debian/Grafana and reproducible source-archive contracts |
| `./dev.sh test` | Static/security/UI/smoke gates PASS; exits 127 when it reaches Rust because Cargo is unavailable |
| `./dev.sh test` | Rust-independent gates PASS; exits 127 when the Rust stage is reached because Cargo is unavailable |
| `./dev.sh check` | BLOCKED: Rust/Cargo toolchain unavailable in this workspace |
| `cargo fmt --all`, `cargo check --locked --all-targets`, `cargo test --locked --all-targets` | BLOCKED: Rust/Cargo toolchain unavailable in this workspace |
| `cargo fmt --check` | BLOCKED: Rust/Cargo toolchain unavailable in this workspace |
| `cargo clippy --all-targets --all-features -- -D warnings` | BLOCKED: Rust/Cargo toolchain unavailable in this workspace |
| `cargo test --all-targets --all-features` | BLOCKED: Rust/Cargo toolchain unavailable in this workspace |
| `bash dev.sh` complete release build | BLOCKED at Rust compilation: Rust/Cargo toolchain unavailable in this workspace |
All Rust-independent release gates available in this workspace pass. Compiler formatting, parsing, type checking, linking and Rust unit/integration tests still require a Rust-enabled release host before treating the archive as compiler-verified production output.
The source archive must not be described as compiler-verified until the Rust-enabled release-host gates pass. Rust-independent gates verify source structure, security contracts, documentation, browser JavaScript, packaging invariants and deterministic archive behavior, but they cannot substitute for Rust parsing, type checking, Clippy or runtime tests.
+104
View File
@@ -0,0 +1,104 @@
# RustProxy 0.26.1 implementation note
## Scope
Version 0.26.1 is a focused follow-up to the persistent protocol diagnostics and Stored Logs explorer introduced in 0.26.0. It does not change SQLite schema 8 or the saved proxy configuration format.
The release has two goals:
1. Attribute persistent `protocol_error` records to a configured vhost when that attribution is safe and deterministic.
2. Make the Stored Logs explorer practical on installations with many vhosts and useful for protocol diagnostics.
Live Logs remains a separate bounded in-memory request view. Low-level `protocol_error` records are persistence-only and must not be published to its history/broadcast path.
## One shared vhost-resolution function
All protocol adapters use the same resolver; TLS, HTTP/1, HTTP/2 and HTTP/3 must not implement independent hostname matching.
```rust
resolve_logging_vhost(
local,
tls,
routed_vhost,
authority,
host,
sni,
) -> Option<ResolvedLogVhost>
```
Resolution priority is:
1. a vhost already selected by normal routing;
2. HTTP `:authority`;
3. HTTP `Host`;
4. TLS/QUIC SNI;
5. no match, which means `_unmatched.jsonl` in per-vhost file mode.
The helper normalizes authority/Host/SNI with the existing host parser and delegates selection to the same `Snapshot::frontend` exact/wildcard and listener-binding logic used by normal request routing. It returns the configured internal vhost ID and `matched_by`; client input is never turned directly into a filename.
## Persistent protocol records
A protocol record keeps the compatibility fields from 0.26.0 and also exposes canonical diagnostics when available:
- `event = protocol_error`;
- `reason` and compatibility `protocol_error`;
- `error` and compatibility `error_detail`;
- `client_ip` and `local_addr`;
- `transport` and `http_version`;
- `vhost` when safely resolved;
- `sni` and compatibility `tls_sni`;
- `authority`;
- `matched_by = router | authority | host | sni | none`.
Diagnostic identity strings are control-character stripped and length-bounded before persistence. Existing secret redaction and error-detail limits remain in force.
In per-vhost file mode, a resolved record uses the existing configured vhost sink. Invalid internal vhost identifiers fall back to `_unmatched` instead of being sanitized into a new filename. In combined-file mode, sink behavior is unchanged.
## Protocol integration
TLS reads SNI from ClientHello before completing the handshake. A later handshake failure/timeout carries that SNI into the shared resolver. If ClientHello itself cannot be parsed, attribution remains unavailable and the record is unmatched.
HTTP/2 retains the TLS SNI for post-handshake connection/drain diagnostics. Request paths that have an HTTP request object can additionally provide authority/Host.
HTTP/3 obtains SNI from Quinn/Rustls handshake data after a successful QUIC handshake and uses it for later request, stream and connection protocol failures. Failures that happen before usable handshake identity exists remain unmatched.
No protocol path infers a vhost solely from a shared listener or local port.
## Stored Logs explorer changes
The `/logs` workspace now:
- highlights the selected log row;
- expands Record details inline directly below that row;
- removes the separate details panel below the table;
- keeps the table header sticky while the log list scrolls;
- lets each administrator choose visible Stored Logs columns and persists the choice in existing account preferences;
- offers protocol columns for transport, match source, SNI and authority;
- keeps the configured-file inventory collapsed by default;
- canonicalizes fields through the active structured-log field mapping so `client_ip` remains visible when the JSON output key was renamed;
- searches record values case-insensitively and includes `reason`, `error`, `sni`, `authority` and other stored values;
- refreshes keyword results with a short debounce while typing;
- retains the existing bounded source/byte/line/page limits and reports truncation when an `all` query contains more sources than can be scanned safely.
## Security invariants
The browser still accepts only opaque configured source IDs; it never accepts arbitrary filesystem paths. File reads remain regular-file-only and retain Unix `O_NOFOLLOW` protection.
SNI, Host and authority are lookup inputs only. They cannot select a filesystem path directly. Only a configured vhost resolved through the existing routing model can select a per-vhost log sink.
## Regression coverage
Source tests cover:
- exact and wildcard hostname precedence using the shared resolver;
- router priority over authority/Host/SNI;
- Host with port and case-insensitive normalization;
- unknown and missing SNI returning no vhost;
- mapped `client_ip` normalization in Stored Logs;
- keyword matching against protocol diagnostic values;
- safe per-vhost filename fallback;
- bounded protocol identity fields;
- protocol errors remaining outside Live Logs;
- preference validation for selectable Stored Logs columns.
A Rust-enabled release host must still run `cargo fmt --check`, Clippy with warnings denied, all-target/all-feature tests and the complete `dev.sh` release build before the package is considered compiler-verified.
+1 -1
View File
@@ -19,7 +19,7 @@ classes, cache outcomes, capture-state gauges and configured-route traffic. Raw
are intentionally absent from Prometheus labels.
Import the JSON in Grafana and select a Prometheus datasource when prompted. The
dashboard expects RustProxy 0.26.0 metric names and a scrape interval around 15s.
dashboard expects RustProxy 0.26.1 metric names and a scrape interval around 15s.
Collectors can be disabled in RustProxy settings; panels for disabled collectors
will simply show no series.
+2 -2
View File
@@ -4529,9 +4529,9 @@
},
"timepicker": {},
"timezone": "browser",
"title": "RustProxy 0.26.0 - Operations",
"title": "RustProxy 0.26.1 - Operations",
"uid": "rustproxy-0170",
"version": 5,
"weekStart": "",
"description": "RustProxy 0.26.0 operations dashboard including backend client lifecycle/retry diagnostics, bounded Traffic analytics, country and User-Agent-family visibility, and built-in Web Server traffic."
"description": "RustProxy 0.26.1 operations dashboard including backend client lifecycle/retry diagnostics, bounded Traffic analytics, country and User-Agent-family visibility, and built-in Web Server traffic."
}
+5
View File
@@ -1995,6 +1995,11 @@
"fileLogsSearchPlaceholder": "IP, path, error, request id...",
"fileLogsTruncated": "scan limit reached",
"fileLogsSummary": "{from}–{to} of {total} matches · scanned {bytes} / {lines} lines",
"fileLogsMatchedBy": "Matched by",
"fileLogsSni": "SNI",
"fileLogsAuthority": "Authority / Host",
"fileLogsColumnsRequired": "At least one Stored Logs column must remain visible.",
"fileLogsSourcesRead": "Configured files",
"search": "Search",
"all": "All",
"clientIp": "Client IP",
+5
View File
@@ -1995,6 +1995,11 @@
"fileLogsSearchPlaceholder": "IP, ścieżka, błąd, request id...",
"fileLogsTruncated": "osiągnięto limit skanowania",
"fileLogsSummary": "{from}–{to} z {total} wyników · przeskanowano {bytes} / {lines} linii",
"fileLogsMatchedBy": "Dopasowanie przez",
"fileLogsSni": "SNI",
"fileLogsAuthority": "Authority / Host",
"fileLogsColumnsRequired": "Co najmniej jedna kolumna zapisanych logów musi pozostać widoczna.",
"fileLogsSourcesRead": "Skonfigurowane pliki",
"search": "Szukaj",
"all": "Wszystkie",
"clientIp": "IP klienta",
+1 -1
View File
@@ -88,7 +88,7 @@ def add_file(archive: zipfile.ZipFile, rel: Path, data: bytes, timestamp: tuple[
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('output', nargs='?', type=Path, default=ROOT.parent / 'RustProxy-0.26.0.zip')
parser.add_argument('output', nargs='?', type=Path, default=ROOT.parent / 'RustProxy-0.26.1.zip')
output = parser.parse_args().output.resolve()
paths = files()
checksum = ROOT / 'SHA256SUMS'
+10 -10
View File
@@ -24,7 +24,7 @@ def read_docker_agent_source() -> str:
preflight(root)
manifest = tomllib.loads((root / "Cargo.toml").read_text())
assert manifest["package"]["version"] == "0.26.0"
assert manifest["package"]["version"] == "0.26.1"
assert manifest["dependencies"]["quinn"]["version"] == "0.11.12"
h3dep = manifest["dependencies"]["h3"]
assert h3dep["package"] == "hpx-h3" and h3dep["version"] == "=2.5.19" and "quinn" in h3dep["features"]
@@ -32,14 +32,14 @@ assert "h3-quinn" not in manifest["dependencies"]
lock_path = root / "Cargo.lock"
if lock_path.exists():
lock = lock_path.read_text()
assert 'name = "rust-proxy"\nversion = "0.26.0"' in lock
assert 'name = "rust-proxy"\nversion = "0.26.1"' in lock
assert 'name = "systemstat"' in lock and 'name = "metrics-process"' in lock and 'name = "sysinfo"' in lock, "Cargo.lock must be resolver-generated after adding cross-platform statistics libraries"
for release_doc, marker in {
"README.md": "# RustProxy 0.26.0",
"CHANGELOG.md": "## 0.26.0",
"ROADMAP.md": "## 0.26.0",
"AGENTS.md": "Current source version: 0.26.0.",
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.26.0",
"README.md": "# RustProxy 0.26.1",
"CHANGELOG.md": "## 0.26.1",
"ROADMAP.md": "## 0.26.1",
"AGENTS.md": "Current source version: 0.26.1.",
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.26.1",
}.items():
assert marker in (root / release_doc).read_text(), f"stale release identity in {release_doc}"
assert tomllib.loads((root / "rust-toolchain.toml").read_text())["toolchain"]["channel"] == "stable"
@@ -240,7 +240,7 @@ assert 'Opt-in health decisions based on ordinary user traffic. Defaults to disa
assert '"force_ssl"' in openapi and '"forward_client_ip"' in openapi
assert '"backend_header"' in openapi and '"backend_timing_header"' in openapi
assert '"StreamMetrics"' in openapi and '"streams"' in openapi
for marker in ['"version": "0.26.0"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']:
for marker in ['"version": "0.26.1"', '"/api/traffic"', '"capture_user_agent"', '"capture_country"', '"hot_auto_trim_enabled"', '"hot_auto_trim_after_seconds"', '"hot_auto_trim_target_percent"', '"/api/config/{section}"', '"ConfigSectionUpdate"', '"cold_on_idle"', '"cold_on_age"', '"cold_max_hot_seconds"', '"cold_on_lru"', '"cold_on_shutdown"', '"cold_gc_interval_seconds"', '"cold_gc_max_delete"', '"collapse_enabled"', '"collapse_wait_timeout_ms"', '"collapse_timeout_fallback"', '"stale_if_error_timeout"', '"stale_if_error_connection"', '"stale_if_error_dns"', '"stale_if_error_tls"', '"stale_if_error_statuses"', '"proactive_refresh_enabled"', '"proactive_refresh_before_expiry_seconds"', '"proactive_refresh_percent"', '"proactive_refresh_max_inflight"', '"TransformSettings"', '"TransformOverrides"', '"/api/cache/cold/cleanup"', '"/api/cache/invalidate"', '"/api/cache/bans"', '"/api/cache/bans/{id}"', '"CacheInvalidationRequest"', '"CacheInvalidationResponse"', '"ColdCleanupJob"', '"match_type"', '"regex_insensitive"', '"rewrite"', '"response"', '"covered_hosts"', '"default": true']:
assert marker in openapi, marker
for marker in ['"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
assert marker in openapi, marker
@@ -1203,7 +1203,7 @@ assert '"error_log" => {' not in admin_0241 and 'request("/api/config/error_log"
assert '"error_log"' in openapi and 'logging, error_log, forwarding' not in openapi
assert 'logging_section_persists_error_log_settings_atomically' in admin_0241 and 'error_log_is_not_a_standalone_configuration_section' in admin_0241
assert 'location.pathname === "/logging"' in settings_js_0241
assert '"RuntimeInformation"' in openapi and '"version": "0.26.0"' in openapi
assert '"RuntimeInformation"' in openapi and '"version": "0.26.1"' in openapi
# 0.24.1 compiler and hostname-cardinality regressions.
tls_0241 = rust_source(root, "src/tls.rs")
@@ -1248,5 +1248,5 @@ for schema_name in ["RedirectHost", "NotFoundHost"]:
node = openapi_0241["components"]["schemas"][schema_name]["properties"]["frontend"]["properties"]["hosts"]
assert "maxItems" not in node, schema_name
print("PASS: 0.26.0 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, Debian/Grafana/Prometheus/404-host/custom-location/path/cache/dashboard/Nagios UI/source contracts, stopped legacy-renewal removal, session/admin/transport contracts, deterministic package source and embedded SQLite schema-v8 migrations.")
print("PASS: 0.26.1 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, Debian/Grafana/Prometheus/404-host/custom-location/path/cache/dashboard/Nagios UI/source contracts, stopped legacy-renewal removal, session/admin/transport contracts, deterministic package source and embedded SQLite schema-v8 migrations.")
print("NOT VERIFIED: Rust parsing, types, compilation, linking or runtime behavior. Run ./dev.sh test.")
+1 -1
View File
@@ -97,7 +97,7 @@ def digest(path: Path) -> str:
def fetch_https(url: str, max_bytes: int) -> bytes:
if urllib.parse.urlparse(url).scheme != 'https':
raise ValueError('GeoIP downloads require HTTPS.')
request = urllib.request.Request(url, headers={'User-Agent': 'RustProxy-installer/0.26.0'})
request = urllib.request.Request(url, headers={'User-Agent': 'RustProxy-installer/0.26.1'})
with urllib.request.urlopen(request, timeout=30) as response:
if urllib.parse.urlparse(response.geturl()).scheme != 'https':
raise ValueError('GeoIP download redirected away from HTTPS.')
+5 -5
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Rust-independent documentation/Reader consistency checks for the 0.26.0 release."""
"""Rust-independent documentation/Reader consistency checks for the 0.26.1 release."""
from pathlib import Path
import re
from rust_source import rust_source
@@ -55,16 +55,16 @@ assert '"/docs/content.json"' in admin
readme = (ROOT / "README.md").read_text(encoding="utf-8")
roadmap = (ROOT / "ROADMAP.md").read_text(encoding="utf-8")
changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8")
assert readme.startswith("# RustProxy 0.26.0")
assert readme.startswith("# RustProxy 0.26.1")
assert "docs/WEB_SERVER.md" in readme and "docs/README.md" in readme
assert readme.count("## What changed in ") == 1, "README duplicates historical release notes; use CHANGELOG/archive"
assert "## Release history" in readme and "docs/ROADMAP_HISTORY.md" in readme
assert roadmap.startswith("# RustProxy roadmap") and "## 0.26.0" in roadmap and "## 0.25.5" in roadmap and "## 0.25.4" in roadmap and "## 0.25.3" in roadmap and "## 0.25.2" in roadmap and "## 0.25.1" in roadmap and "## 0.25.0" in roadmap
assert roadmap.startswith("# RustProxy roadmap") and "## 0.26.1" in roadmap and "## 0.26.0" in roadmap and "## 0.25.5" in roadmap and "## 0.25.4" in roadmap and "## 0.25.3" in roadmap and "## 0.25.2" in roadmap and "## 0.25.1" in roadmap and "## 0.25.0" in roadmap
assert "## 0.24." not in roadmap and "## Web Server follow-up" in roadmap and "docs/ROADMAP_HISTORY.md" in roadmap
assert changelog.startswith("# Changelog\n")
for release in ["0.26.0", "0.25.6", "0.25.5", "0.25.4", "0.25.3"]:
for release in ["0.26.1", "0.26.0", "0.25.6", "0.25.5", "0.25.4", "0.25.3"]:
assert f"## {release}" in changelog, release
assert changelog.index("## 0.26.0") < changelog.index("## 0.25.6") < changelog.index("## 0.25.5")
assert changelog.index("## 0.26.1") < changelog.index("## 0.26.0") < changelog.index("## 0.25.6") < changelog.index("## 0.25.5")
assert "CHANGELOG_TECHNICAL_ARCHIVE.md" in changelog
for developer_only in ["Clean the remaining compiler warnings", "Update package/source/UI/OpenAPI/release identity", "source regressions and update release identity"]:
assert developer_only not in changelog, f"developer-only CHANGELOG detail: {developer_only}"
+2 -2
View File
@@ -75,10 +75,10 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}")
for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/proxy/websocket.rs", "src/cache/mod.rs", "src/cache/model.rs", "src/cache/lifecycle.rs", "src/cache/cold.rs", "src/cache/dashboard.rs", "src/cache/tickets.rs", "src/cache/lookup.rs", "src/cache/policy.rs", "src/cache/fill.rs", "src/cache/tests.rs", "src/settings.rs", "src/compression.rs", "src/diagnostics.rs", "src/traffic.rs", "src/system_stats/mod.rs", "src/system_stats/linux.rs", "src/vhost.rs", "src/web_server/mod.rs", "src/web_server/config.rs", "src/web_server/filesystem.rs", "src/web_server/security.rs", "src/web_server/response.rs", "src/web_server/request.rs", "src/web_server/routing.rs", "src/web_server/fastcgi.rs", "src/web_server/htaccess.rs", "src/web_server/serve.rs", "src/web_server/tests.rs", "src/balance.rs", "src/security.rs", "src/admin/web/vhosts/core.js", "src/admin/web/vhosts/editor.js", "src/admin/web/vhosts/render.js", "src/admin/web/web-server.js", "src/admin/web/streams.js", "src/admin/web/host404.js", "src/admin/web/app/core.js", "src/admin/web/app/config-history.js", "src/admin/web/app/runtime.js", "src/admin/web/app/settings.js", "src/admin/web/management.js", "src/pages.rs", "scripts/mock_server.py", "scripts/mock_auth.py", "src/forwarding.rs", "src/tinyauth.rs", "src/http3.rs", "src/stream.rs", "src/admin/web/integrations.js", "src/admin/web/traffic-panel.js", "src/prometheus.rs", "src/admin/web/settings.js", "src/admin/web/docker.js", "src/docker.rs", "docker-agent/main.go", "docker-agent/model.go", "docker-agent/docker.go", "docker-agent/network.go", "docker-agent/certificate.go", "docker-agent/server.go", "docker-agent/main_test.go", "docker-agent/rustproxy-docker-agent.service", "docker-agent/install.sh", "docs/DOCKER_DISCOVERY.md", "docs/WEB_SERVER.md", "docs/PHP_FPM.md", "docs/README.md", "docs/TODO.md", "docs/V0.25.5_AUDIT.md", "docs/V0.25.4_AUDIT.md", "docs/V0.25.3_AUDIT.md", "docs/V0.25.2_AUDIT.md", "docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "docs/ROADMAP_HISTORY.md", "scripts/docs_check.py", "scripts/web_server_contracts.py", "scripts/rust_source.py", "scripts/rust_module_contracts.py", "scripts/source_module_contracts.py", "scripts/acme_provider.py", "src/basic_auth.rs", "scripts/deploy.py", "scripts/deploy_core.py", "scripts/deploy_runtime.py", "scripts/migrate_npm.py", "scripts/migrate_npm_core.py", "scripts/migrate_npm_api.py", "docs/NPM_MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/swagger_assets.py", "src/admin/documentation.rs", "src/admin/openapi.json", "langs/en_US.json", "langs/pl_pl.json", "Dockerfile", ".dockerignore", "docs/CONTAINER.md", "docs/PROMETHEUS.md", "docs/NOT_FOUND_HOSTS.md", "debian/control", "debian/rules", "debian/rustproxy.service", "debian/rustproxy.logrotate", "grafana/rustproxy-overview.json"]:
if not (root / path).is_file():
raise ValueError(f"Incomplete RustProxy 0.26.0 source tree: missing {path}")
raise ValueError(f"Incomplete RustProxy 0.26.1 source tree: missing {path}")
text = (root / "src/lib.rs").read_text(encoding="utf-8")
if '#[path = "auth/mod.rs"]' not in text:
raise ValueError("src/lib.rs is not the 0.26.0 entry point; extract the complete package, not individual files.")
raise ValueError("src/lib.rs is not the 0.26.1 entry point; extract the complete package, not individual files.")
return messages
+5 -5
View File
@@ -123,7 +123,7 @@ def upgrade_smoke(tmp: Path) -> None:
assert backup.is_dir()
assert (backup / "src/obsolete.rs").is_file()
assert not (target / "src/obsolete.rs").exists()
assert 'version = "0.26.0"' in (target / "Cargo.toml").read_text(encoding="utf-8")
assert 'version = "0.26.1"' in (target / "Cargo.toml").read_text(encoding="utf-8")
assert (target / "data/keep.txt").read_text(encoding="utf-8") == "preserve\n"
assert (target / ".env").read_text(encoding="utf-8") == "RUST_PROXY_DEMO=0\n"
assert (target / "langs/site.json").is_file()
@@ -134,7 +134,7 @@ def upgrade_smoke(tmp: Path) -> None:
assert (target / "docker-agent/certificate.go").is_file()
assert (target / "docker-agent/server.go").is_file()
manifest = json.loads((backup / "upgrade.json").read_text(encoding="utf-8"))
assert manifest["to_version"] == "0.26.0"
assert manifest["to_version"] == "0.26.1"
assert manifest["removed_obsolete_runtime_state"] is True
assert not legacy.exists()
assert not (backup / "legacy-data").exists()
@@ -554,7 +554,7 @@ def source_contract_smoke() -> None:
assert marker in admin, marker
anonymous_block = admin.split("fn anonymous_allowed", 1)[1].split("const CERTIFICATE_API_KEY_HEADER", 1)[0]
assert '"/api/status"' not in anonymous_block
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.26.0"' in openapi_0241
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.26.1"' in openapi_0241
assert 'id="panel-logging"' not in html
for marker in ['id="settings-tab-logging"', 'id="settings-page-logging"', 'id="logging-form"', 'id="error-log-form"']:
assert marker in html, marker
@@ -1011,7 +1011,7 @@ def source_contract_smoke() -> None:
assert '.managed-update' in main and "flush_hot_to_cold_for_update" in main
openapi = (ROOT / "src/admin/openapi.json").read_text(encoding="utf-8")
assert '"/api/config/history/restore"' in openapi
for marker in ['"version": "0.26.0"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
for marker in ['"version": "0.26.1"', '"memory_preset"', '"cold_preset"', '"memory_budget_bytes"', '"max_inflight"', '"preset"', '"session_days"', '"session_lifetime_seconds"', '"accept_local_listener_risk"', '"backend_header"', '"backend_timing_header"', '"cold_on_age"', '"match_type"', '"regex_insensitive"', '"NotFoundHost"', '"not_found_hosts"', '"PrometheusSettings"', '"allow_trusted_network_without_token"', '"include_docker_vhosts"', '"include_streams"', '"include_logging"']:
assert marker in openapi, marker
assert '"software_update"' in openapi and '"update_available"' in openapi
for marker in ['"/api/certificates/deploy"', '"CertificateDeploy"', '"CertificateDeployResult"', '"/api/certificates/reload"']:
@@ -1186,7 +1186,7 @@ def main() -> None:
source_contract_smoke()
container_contract_smoke()
debian_grafana_contract_smoke()
print("PASS: 0.26.0 SQLite/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, Debian packaging, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.")
print("PASS: 0.26.1 SQLite/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, Debian packaging, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.")
if __name__ == "__main__":
+1 -1
View File
@@ -85,7 +85,7 @@ def download(destination: Path) -> None:
"""TLS verification uses Python's default trust store; never executes a shell."""
started = time.monotonic()
opener = build_opener(HttpsRedirects())
request = Request(DOWNLOAD_URL, headers={"User-Agent": "RustProxy-build/0.26.0"})
request = Request(DOWNLOAD_URL, headers={"User-Agent": "RustProxy-build/0.26.1"})
with opener.open(request, timeout=30) as response, destination.open("wb") as output:
length = response.headers.get("Content-Length")
if length and int(length) > MAX_ARCHIVE:
+3 -3
View File
@@ -46,7 +46,7 @@ def upgrade(source: Path, target: Path, *, stopped: bool, data_dir: Path | None
for name in MANAGED:
if (target / name).is_symlink():
raise ValueError(f"Managed target {name} is a symlink; refusing to replace it.")
backup = target / ".upgrade-backup" / ("before-0.26.0-" + uuid.uuid4().hex[:12])
backup = target / ".upgrade-backup" / ("before-0.26.1-" + uuid.uuid4().hex[:12])
backup.mkdir(parents=True, mode=0o700)
installed: list[str] = []
moved: list[str] = []
@@ -84,7 +84,7 @@ def upgrade(source: Path, target: Path, *, stopped: bool, data_dir: Path | None
remove_legacy = legacy.exists()
if remove_legacy and (legacy.is_symlink() or not legacy.is_file()):
raise ValueError("Obsolete renewal observation sidecar must be a regular file.")
(backup / "upgrade.json").write_text(json.dumps({"from_directory":str(target), "to_version":"0.26.0", "managed_paths":MANAGED, "removed_obsolete_runtime_state": remove_legacy}, indent=2)+"\n", encoding="utf-8")
(backup / "upgrade.json").write_text(json.dumps({"from_directory":str(target), "to_version":"0.26.1", "managed_paths":MANAGED, "removed_obsolete_runtime_state": remove_legacy}, indent=2)+"\n", encoding="utf-8")
if remove_legacy:
legacy.unlink()
except BaseException:
@@ -115,7 +115,7 @@ def main() -> None:
backup = upgrade(Path(__file__).resolve().parents[1], args.target, stopped=args.stopped, data_dir=data_dir)
except (OSError, ValueError) as error:
parser.exit(2, f"Upgrade refused or rolled back: {error}\n")
print(f"Installed RustProxy 0.26.0. Previous managed source files: {backup}")
print(f"Installed RustProxy 0.26.1. Previous managed source files: {backup}")
print("Preserved data, .env, .git, target and other unmanaged files. No database migration was run.")
print(f"Next: cd {args.target}; ./dev.sh test; ./dev.sh")
+1 -1
View File
@@ -96,7 +96,7 @@ need(web,
'HeaderValue::from_static("no-store")',
"builtin_error_response(StatusCode::FORBIDDEN, &method)",
"empty_index_can_go_directly_to_directory_listing",
"RustProxy <b>0.26.0</b>",
"RustProxy <b>0.26.1</b>",
"traversal_and_symlink_escape_are_not_served",
"try_files_rewrites_cache_rules_headers_and_downloads_work",
"precompressed_sidecars_keep_mime_validators_and_range_semantics",
+8
View File
@@ -399,6 +399,14 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
"/docs/V0.25.1_AUDIT.md"
)),
),
(
"docs/V0.26.1_IMPLEMENTATION.md",
"technical",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.26.1_IMPLEMENTATION.md"
)),
),
(
"docs/V0.25.5_AUDIT.md",
"audits",
+73 -4
View File
@@ -229,12 +229,62 @@ fn read_tail(
Ok((lines, bytes, truncated || iter.next().is_some()))
}
fn normalized_record(source: &FileLogSource, line: &str) -> Value {
fn copy_mapped_field(
object: &mut serde_json::Map<String, Value>,
format: &crate::logging::JsonFormat,
canonical: &str,
) {
if object.contains_key(canonical) {
return;
}
let mapped = format
.fields
.iter()
.find_map(|(output, source)| (source == canonical).then_some(output));
if let Some(value) = mapped.and_then(|output| object.get(output)).cloned() {
object.insert(canonical.to_owned(), value);
}
}
fn normalized_record(
source: &FileLogSource,
line: &str,
format: &crate::logging::JsonFormat,
) -> Value {
let mut value = serde_json::from_str::<Value>(line).unwrap_or_else(|_| json!({"raw": line}));
if !value.is_object() {
value = json!({"value": value});
}
let object = value.as_object_mut().expect("record normalized to object");
if source.kind == "access" {
for field in [
"event", "time_ms", "status", "client_ip", "vhost", "method", "host", "path",
"transport", "http_version", "protocol_error", "error_detail", "tls_sni", "reason",
"error", "sni", "authority", "matched_by",
] {
copy_mapped_field(object, format, field);
}
if !object.contains_key("client_ip") {
if let Some(value) = object.get("source_ip").cloned() {
object.insert("client_ip".into(), value);
}
}
if !object.contains_key("reason") {
if let Some(value) = object.get("protocol_error").cloned() {
object.insert("reason".into(), value);
}
}
if !object.contains_key("error") {
if let Some(value) = object.get("error_detail").cloned() {
object.insert("error".into(), value);
}
}
if !object.contains_key("sni") {
if let Some(value) = object.get("tls_sni").cloned() {
object.insert("sni".into(), value);
}
}
}
object.entry("event").or_insert_with(|| {
Value::String(
if source.kind == "error" {
@@ -256,13 +306,28 @@ fn contains_ci(value: Option<&Value>, needle: &str) -> bool {
.is_some_and(|text| text.to_ascii_lowercase().contains(needle))
}
fn append_search_values(value: &Value, output: &mut String) {
match value {
Value::Null => {}
Value::Bool(value) => { output.push_str(if *value { " true" } else { " false" }); }
Value::Number(value) => { output.push(' '); output.push_str(&value.to_string()); }
Value::String(value) => { output.push(' '); output.push_str(&value.to_ascii_lowercase()); }
Value::Array(values) => values.iter().for_each(|value| append_search_values(value, output)),
Value::Object(values) => values.values().for_each(|value| append_search_values(value, output)),
}
}
fn record_matches(value: &Value, query: &FileLogQuery, q: &str) -> bool {
let Some(object) = value.as_object() else {
return false;
};
if !q.is_empty() && !value.to_string().to_ascii_lowercase().contains(q) {
if !q.is_empty() {
let mut searchable = String::new();
append_search_values(value, &mut searchable);
if !q.split_whitespace().all(|term| searchable.contains(term)) {
return false;
}
}
if let Some(event) = query
.event
.as_deref()
@@ -345,7 +410,9 @@ async fn file_logs_query(
let config = state.config.load().envelope.config.clone();
tokio::task::spawn_blocking(move || {
let mut sources = configured_file_log_sources(&config);
let log_format = config.logging.format.clone();
let requested = query.source.as_deref().unwrap_or("all");
let mut truncated = false;
if requested != "all" {
sources.retain(|source| source.id == requested);
if sources.is_empty() {
@@ -356,8 +423,11 @@ async fn file_logs_query(
}
} else {
sources.sort_by(|a, b| b.modified_ms.cmp(&a.modified_ms));
if sources.len() > FILE_LOG_MAX_SOURCES {
truncated = true;
sources.truncate(FILE_LOG_MAX_SOURCES);
}
}
let q = query.q.clone().unwrap_or_default().to_ascii_lowercase();
let offset = query.offset.unwrap_or(0);
let limit = query.limit.unwrap_or(100).clamp(1, FILE_LOG_MAX_LIMIT);
@@ -365,7 +435,6 @@ async fn file_logs_query(
let mut scanned_bytes = 0u64;
let mut remaining_lines = FILE_LOG_SCAN_LINES;
let mut scanned_lines = 0usize;
let mut truncated = false;
let mut records = Vec::new();
for (index, source) in sources.iter().enumerate() {
if remaining_bytes == 0 || remaining_lines == 0 {
@@ -385,7 +454,7 @@ async fn file_logs_query(
if line.is_empty() {
continue;
}
let value = normalized_record(source, &line);
let value = normalized_record(source, &line, &log_format);
if record_matches(&value, &query, &q) {
records.push(value);
}
+1
View File
@@ -3,6 +3,7 @@
include!("router.rs");
include!("access.rs");
include!("session.rs");
include!("preferences.rs");
include!("status.rs");
include!("logging_api.rs");
include!("file_logs.rs");
+33 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.26.0",
"version": "0.26.1",
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust."
},
"servers": [
@@ -2909,6 +2909,38 @@
},
"description": "Account-scoped Live Logs columns shown in the dashboard. JSON log export still contains every field present in each entry."
},
"file_log_columns": {
"type": "array",
"minItems": 1,
"maxItems": 11,
"uniqueItems": true,
"items": {
"type": "string",
"enum": [
"time",
"event",
"status",
"client_ip",
"vhost",
"transport",
"matched_by",
"sni",
"authority",
"record",
"source"
]
},
"default": [
"time",
"event",
"status",
"client_ip",
"vhost",
"record",
"source"
],
"description": "Account-scoped Stored Logs explorer columns shown in the dashboard."
},
"theme": {
"type": "string",
"enum": [
+197
View File
@@ -0,0 +1,197 @@
const LIVE_LOG_COLUMNS: &[&str] = &[
"time",
"request",
"host",
"source_ip",
"vhost",
"backend",
"status",
"duration",
"cache",
"outcome",
"transport",
"websocket",
"upstream_error",
];
const FILE_LOG_COLUMNS: &[&str] = &[
"time", "event", "status", "client_ip", "vhost", "transport", "matched_by", "sni",
"authority", "record", "source",
];
const BACKEND_TABLE_COLUMNS: &[&str] = &[
"vhost",
"backend",
"status",
"active",
"requests",
"errors",
"latency",
"traffic",
"generation",
"generationRequests",
"generationAge",
"rotations",
"lastFailure",
"lastRecovery",
];
fn default_admin_theme() -> String {
"dark".to_owned()
}
fn default_file_log_columns() -> Vec<String> {
["time", "event", "status", "client_ip", "vhost", "record", "source"]
.into_iter()
.map(str::to_owned)
.collect()
}
fn default_backend_table_columns() -> Vec<String> {
BACKEND_TABLE_COLUMNS
.iter()
.map(|value| (*value).to_owned())
.collect()
}
fn default_backend_table_sort() -> String {
"vhost".to_owned()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct AdminPreferences {
live_log_columns: Vec<String>,
#[serde(default = "default_admin_theme")]
theme: String,
#[serde(default = "default_file_log_columns")]
file_log_columns: Vec<String>,
#[serde(default = "default_backend_table_columns")]
backend_table_columns: Vec<String>,
#[serde(default = "default_backend_table_sort")]
backend_table_sort: String,
#[serde(default)]
backend_table_descending: bool,
}
impl Default for AdminPreferences {
fn default() -> Self {
Self {
live_log_columns: LIVE_LOG_COLUMNS
.iter()
.map(|value| (*value).to_owned())
.collect(),
theme: default_admin_theme(),
file_log_columns: default_file_log_columns(),
backend_table_columns: default_backend_table_columns(),
backend_table_sort: default_backend_table_sort(),
backend_table_descending: false,
}
}
}
fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiError> {
if preferences.live_log_columns.is_empty()
|| preferences.live_log_columns.len() > LIVE_LOG_COLUMNS.len()
{
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"at least one live-log column must be visible".into(),
));
}
let mut seen = std::collections::HashSet::new();
for column in &preferences.live_log_columns {
if !LIVE_LOG_COLUMNS.contains(&column.as_str()) || !seen.insert(column.as_str()) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid live-log column preference".into(),
));
}
}
if preferences.file_log_columns.is_empty()
|| preferences.file_log_columns.len() > FILE_LOG_COLUMNS.len()
{
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"at least one stored-log column must be visible".into(),
));
}
let mut seen = std::collections::HashSet::new();
for column in &preferences.file_log_columns {
if !FILE_LOG_COLUMNS.contains(&column.as_str()) || !seen.insert(column.as_str()) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid stored-log column preference".into(),
));
}
}
if !matches!(preferences.theme.as_str(), "dark" | "light") {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid administration theme preference".into(),
));
}
if preferences.backend_table_columns.is_empty()
|| preferences.backend_table_columns.len() > BACKEND_TABLE_COLUMNS.len()
{
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"at least one backend table column must be visible".into(),
));
}
let mut seen = std::collections::HashSet::new();
for column in &preferences.backend_table_columns {
if !BACKEND_TABLE_COLUMNS.contains(&column.as_str()) || !seen.insert(column.as_str()) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid backend table column preference".into(),
));
}
}
if !BACKEND_TABLE_COLUMNS.contains(&preferences.backend_table_sort.as_str()) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid backend table sort preference".into(),
));
}
Ok(())
}
async fn preferences(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
) -> Result<Json<AdminPreferences>, ApiError> {
let stored = state
.store
.admin_preferences(&session.username)
.await
.map_err(auth::internal)?;
let preferences = match stored {
Some(document) => match serde_json::from_str::<AdminPreferences>(&document) {
Ok(preferences) if validate_admin_preferences(&preferences).is_ok() => preferences,
Ok(_) | Err(_) => {
tracing::warn!(user = %session.username, "invalid admin view preferences ignored");
AdminPreferences::default()
}
},
None => AdminPreferences::default(),
};
Ok(Json(preferences))
}
async fn update_preferences(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
input: Result<Json<AdminPreferences>, axum::extract::rejection::JsonRejection>,
) -> Result<Json<AdminPreferences>, ApiError> {
let Json(preferences) = input.map_err(public::invalid_json)?;
validate_admin_preferences(&preferences)?;
let document = serde_json::to_string(&preferences).map_err(auth::internal)?;
if !state
.store
.save_admin_preferences(&session.username, &document)
.await
.map_err(auth::internal)?
{
return Err(ApiError(
StatusCode::UNAUTHORIZED,
"auth.invalid_credentials".into(),
));
}
Ok(Json(preferences))
}
-167
View File
@@ -252,173 +252,6 @@ async fn session(
Json(session_payload(&session, session_seconds))
}
const LIVE_LOG_COLUMNS: &[&str] = &[
"time",
"request",
"host",
"source_ip",
"vhost",
"backend",
"status",
"duration",
"cache",
"outcome",
"transport",
"websocket",
"upstream_error",
];
const BACKEND_TABLE_COLUMNS: &[&str] = &[
"vhost",
"backend",
"status",
"active",
"requests",
"errors",
"latency",
"traffic",
"generation",
"generationRequests",
"generationAge",
"rotations",
"lastFailure",
"lastRecovery",
];
fn default_admin_theme() -> String {
"dark".to_owned()
}
fn default_backend_table_columns() -> Vec<String> {
BACKEND_TABLE_COLUMNS
.iter()
.map(|value| (*value).to_owned())
.collect()
}
fn default_backend_table_sort() -> String {
"vhost".to_owned()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct AdminPreferences {
live_log_columns: Vec<String>,
#[serde(default = "default_admin_theme")]
theme: String,
#[serde(default = "default_backend_table_columns")]
backend_table_columns: Vec<String>,
#[serde(default = "default_backend_table_sort")]
backend_table_sort: String,
#[serde(default)]
backend_table_descending: bool,
}
impl Default for AdminPreferences {
fn default() -> Self {
Self {
live_log_columns: LIVE_LOG_COLUMNS
.iter()
.map(|value| (*value).to_owned())
.collect(),
theme: default_admin_theme(),
backend_table_columns: default_backend_table_columns(),
backend_table_sort: default_backend_table_sort(),
backend_table_descending: false,
}
}
}
fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiError> {
if preferences.live_log_columns.is_empty()
|| preferences.live_log_columns.len() > LIVE_LOG_COLUMNS.len()
{
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"at least one live-log column must be visible".into(),
));
}
let mut seen = std::collections::HashSet::new();
for column in &preferences.live_log_columns {
if !LIVE_LOG_COLUMNS.contains(&column.as_str()) || !seen.insert(column.as_str()) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid live-log column preference".into(),
));
}
}
if !matches!(preferences.theme.as_str(), "dark" | "light") {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid administration theme preference".into(),
));
}
if preferences.backend_table_columns.is_empty()
|| preferences.backend_table_columns.len() > BACKEND_TABLE_COLUMNS.len()
{
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"at least one backend table column must be visible".into(),
));
}
let mut seen = std::collections::HashSet::new();
for column in &preferences.backend_table_columns {
if !BACKEND_TABLE_COLUMNS.contains(&column.as_str()) || !seen.insert(column.as_str()) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid backend table column preference".into(),
));
}
}
if !BACKEND_TABLE_COLUMNS.contains(&preferences.backend_table_sort.as_str()) {
return Err(ApiError(
StatusCode::UNPROCESSABLE_ENTITY,
"invalid backend table sort preference".into(),
));
}
Ok(())
}
async fn preferences(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
) -> Result<Json<AdminPreferences>, ApiError> {
let stored = state
.store
.admin_preferences(&session.username)
.await
.map_err(auth::internal)?;
let preferences = match stored {
Some(document) => match serde_json::from_str::<AdminPreferences>(&document) {
Ok(preferences) if validate_admin_preferences(&preferences).is_ok() => preferences,
Ok(_) | Err(_) => {
tracing::warn!(user = %session.username, "invalid admin view preferences ignored");
AdminPreferences::default()
}
},
None => AdminPreferences::default(),
};
Ok(Json(preferences))
}
async fn update_preferences(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
input: Result<Json<AdminPreferences>, axum::extract::rejection::JsonRejection>,
) -> Result<Json<AdminPreferences>, ApiError> {
let Json(preferences) = input.map_err(public::invalid_json)?;
validate_admin_preferences(&preferences)?;
let document = serde_json::to_string(&preferences).map_err(auth::internal)?;
if !state
.store
.save_admin_preferences(&session.username, &document)
.await
.map_err(auth::internal)?
{
return Err(ApiError(
StatusCode::UNAUTHORIZED,
"auth.invalid_credentials".into(),
));
}
Ok(Json(preferences))
}
fn logged_out(secure_cookie: bool) -> Response {
let mut response = Json(json!({"logged_out":true})).into_response();
response.headers_mut().insert(
+6
View File
@@ -20,6 +20,10 @@
LIVE_LOG_COLUMNS.len()
);
assert_eq!(initial["theme"], "dark");
assert_eq!(
initial["file_log_columns"].as_array().unwrap().len(),
default_file_log_columns().len()
);
assert_eq!(
initial["backend_table_columns"].as_array().unwrap().len(),
BACKEND_TABLE_COLUMNS.len()
@@ -45,6 +49,7 @@
let legacy = json_body(response).await;
assert_eq!(legacy["live_log_columns"], json!(["time", "status"]));
assert_eq!(legacy["theme"], "dark");
assert_eq!(legacy["file_log_columns"], json!(default_file_log_columns()));
assert_eq!(
legacy["backend_table_columns"].as_array().unwrap().len(),
BACKEND_TABLE_COLUMNS.len()
@@ -55,6 +60,7 @@
let selected = json!({
"live_log_columns":["time","request","status","upstream_error"],
"theme":"light",
"file_log_columns":["time","event","client_ip","record"],
"backend_table_columns":["vhost","backend","status","requests","latency"],
"backend_table_sort":"requests",
"backend_table_descending":true
+31
View File
@@ -55,6 +55,37 @@
assert!(validate_file_log_query(&query).is_err());
}
#[test]
fn stored_log_normalizes_mapped_client_ip_and_searches_diagnostics() {
let dir = tempfile::tempdir().unwrap();
let source = source_from_path(
"vhost:app".into(),
"app.jsonl".into(),
"access",
dir.path().join("app.jsonl"),
true,
"JSONL",
);
let mut format = crate::logging::JsonFormat::default();
format.fields.remove("client_ip");
format
.fields
.insert("remote_addr".into(), "client_ip".into());
let record = normalized_record(
&source,
r#"{"event":"protocol_error","remote_addr":"192.0.2.10","reason":"tls_handshake_error","error":"HTTP parser error","sni":"app.example.test","matched_by":"sni","vhost":"app"}"#,
&format,
);
assert_eq!(record["client_ip"], "192.0.2.10");
assert_eq!(record["sni"], "app.example.test");
let query = FileLogQuery::default();
assert!(record_matches(&record, &query, "error"));
assert!(record_matches(&record, &query, "http parser"));
assert!(record_matches(&record, &query, "app.example.test"));
assert!(!record_matches(&record, &query, "missing"));
}
#[cfg(unix)]
#[test]
fn stored_log_reader_refuses_symlink_targets() {
+1 -1
View File
@@ -355,7 +355,7 @@ function lock(notify = true) {
if (navigating) { document.documentElement.classList.add("app-loading"); location.replace(destination); }
}
async function activate(info) { pendingAuthDestination = null; authMessage(""); $("api-docs").hidden = false; $("logs-open").hidden = false; $("file-logs-open").hidden = false; authEpoch++; sessionInfo = info; csrf = info.csrf_token; authenticated = true; const preferences = await loadAdminPreferences(); window.rustProxyLogs?.applyPreferences(preferences); window.rustProxyVhosts?.applyPreferences(preferences); await loadConfig(); $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("login").hidden = true; $("workspace").hidden = false; $("logout").hidden = false; renderSession(); await refresh(true); startLive(); document.documentElement.classList.remove("app-loading"); }
async function activate(info) { pendingAuthDestination = null; authMessage(""); $("api-docs").hidden = false; $("logs-open").hidden = false; $("file-logs-open").hidden = false; authEpoch++; sessionInfo = info; csrf = info.csrf_token; authenticated = true; const preferences = await loadAdminPreferences(); window.rustProxyLogs?.applyPreferences(preferences); window.rustProxyFileLogs?.applyPreferences(preferences); window.rustProxyVhosts?.applyPreferences(preferences); await loadConfig(); $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("login").hidden = true; $("workspace").hidden = false; $("logout").hidden = false; renderSession(); await refresh(true); startLive(); document.documentElement.classList.remove("app-loading"); }
async function loadAudit() { if (!authenticated) return; const epoch = authEpoch; try { const data = await api("/api/audit"); if (!authenticated || epoch !== authEpoch) return; const rows = data.entries.map(item => { const tr = el("tr"); for (const value of [new Date(item.time * 1000).toLocaleString(locale()), item.actor, item.action, item.detail]) { const td = el("td", value); td.title = value; tr.append(td); } return tr; }); $("audit-rows").replaceChildren(...rows); $("empty-audit").hidden = rows.length > 0; } catch (e) { toast(e.message, true); } }
let tunnelRefreshTimer = null;
function tunnelMessage(value = "", error = false) { const node = $("tunnels-status"); if (!node) return; node.textContent = value; node.classList.toggle("error", !!error); }
+1 -1
View File
@@ -6,7 +6,7 @@ function renderSettings() {
if (!settingsDirty.has("response-form")) {
const server = config.server_header || {}, compression = config.compression || {}, diagnostics = config.diagnostic_headers || {};
$("expose-request-id").checked = !!diagnostics.request_id;
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.26.0";
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.26.1";
for (const name of ["gzip", "br", "zstd", "deflate"]) $(`compress-${name}`).checked = !!compression[name];
$("compress-min").value = compression.min_bytes ?? 1024; syncServerMode();
}
+95 -15
View File
@@ -1,7 +1,28 @@
(() => {
let inventory = null, offset = 0, lastTotal = 0, busy = false;
let currentEntries = [], selectedIndex = null;
let preferencesLoaded = false, preferenceSaveRunning = false, preferenceSaveQueued = false;
let searchTimer = null;
const columns = [
{ id: "time", label: "time", value: entry => entryTime(entry) },
{ id: "event", label: "fileLogsEvent", value: entry => entry.event || "—" },
{ id: "status", label: "status", value: entry => entryStatus(entry) },
{ id: "client_ip", label: "clientIp", value: entry => entry.client_ip || entry.source_ip || "—" },
{ id: "vhost", label: "vhost", value: entry => entry.vhost || "—" },
{ id: "transport", label: "logTransport", value: entry => entry.transport ? `${entry.transport}${entry.http_version ? ` / HTTP ${entry.http_version}` : ""}` : "—" },
{ id: "matched_by", label: "fileLogsMatchedBy", value: entry => entry.matched_by || "—" },
{ id: "sni", label: "fileLogsSni", value: entry => entry.sni || entry.tls_sni || "—" },
{ id: "authority", label: "fileLogsAuthority", value: entry => entry.authority || entry.host || "—" },
{ id: "record", label: "fileLogsRecord", value: entry => entrySummary(entry) },
{ id: "source", label: "fileLogsSource", value: entry => entry._source || "—" },
];
const defaultColumnIds = ["time", "event", "status", "client_ip", "vhost", "record", "source"];
let visibleColumns = new Set(defaultColumnIds);
const sourceSelect = () => $("file-log-source");
const currentLimit = () => Math.max(1, Math.min(500, Number($("file-log-limit").value) || 100));
function sourceOption(source) {
const label = `${source.label} · ${source.format} · ${bytes(source.size || 0)}${source.enabled ? ` · ${t("enabled")}` : ""}`;
const option = el("option", label); option.value = source.id; return option;
@@ -26,8 +47,9 @@
return "—";
}
function entrySummary(entry) {
if (entry.protocol_error) return entry.protocol_error;
if (entry.method || entry.path) return `${entry.method || ""} ${entry.host || ""}${entry.path || ""}`.trim();
if (entry.reason || entry.protocol_error) return entry.reason || entry.protocol_error;
if (entry.method || entry.path) return `${entry.method || ""} ${entry.host || entry.authority || ""}${entry.path || ""}`.trim();
if (entry.error || entry.error_detail) return entry.error || entry.error_detail;
if (entry.message) return entry.message;
if (entry.raw) return entry.raw;
return entry.outcome || "—";
@@ -36,19 +58,72 @@
const status = Number(entry.status) || 0;
return status ? String(status) : "—";
}
function renderRows(entries) {
const rows = entries.map(entry => {
const row = el("tr"); row.tabIndex = 0;
for (const value of [entryTime(entry), entry.event || "—", entryStatus(entry), entry.client_ip || "—", entry.vhost || "—", entrySummary(entry), entry._source || "—"]) {
function syncHeaders() {
document.querySelectorAll("#file-logs-table-head [data-file-log-column]").forEach(header => {
header.hidden = !visibleColumns.has(header.dataset.fileLogColumn);
});
}
function syncColumnPicker() {
const picker = $("file-log-column-options");
picker.replaceChildren(...columns.map(column => {
const label = el("label", undefined, "check log-column-option");
const input = document.createElement("input"); input.type = "checkbox"; input.checked = visibleColumns.has(column.id); input.dataset.column = column.id;
const text = el("span", t(column.label)); text.dataset.i18n = column.label;
input.addEventListener("change", () => {
if (!input.checked && visibleColumns.size === 1) { input.checked = true; toast(t("fileLogsColumnsRequired"), true); return; }
if (input.checked) visibleColumns.add(column.id); else visibleColumns.delete(column.id);
syncHeaders(); renderRows(); void savePreferences();
});
label.append(input, text); return label;
}));
}
function applyPreferences(preferences) {
const selected = Array.isArray(preferences?.file_log_columns)
? preferences.file_log_columns.filter(id => columns.some(column => column.id === id))
: [];
visibleColumns = new Set(selected.length ? selected : defaultColumnIds);
preferencesLoaded = true;
syncColumnPicker(); syncHeaders(); renderRows();
}
async function savePreferences() {
if (!authenticated || !preferencesLoaded) return;
if (preferenceSaveRunning) { preferenceSaveQueued = true; return; }
preferenceSaveRunning = true;
try {
do {
preferenceSaveQueued = false;
const selected = columns.filter(column => visibleColumns.has(column.id)).map(column => column.id);
const preferences = await window.rustProxyPreferences.update({ file_log_columns: selected });
if (!preferenceSaveQueued) applyPreferences(preferences);
} while (preferenceSaveQueued);
} catch (error) { toast(error.message, true); }
finally { preferenceSaveRunning = false; }
}
function renderRows(entries = currentEntries) {
currentEntries = entries;
const nodes = [];
entries.forEach((entry, index) => {
const row = el("tr"); row.tabIndex = 0; row.classList.toggle("selected", selectedIndex === index);
row.setAttribute("aria-expanded", selectedIndex === index ? "true" : "false");
for (const column of columns) {
if (!visibleColumns.has(column.id)) continue;
const value = column.value(entry);
const cell = el("td", String(value)); cell.title = String(value); row.append(cell);
}
const show = () => { $("file-log-detail").textContent = JSON.stringify(entry, null, 2); };
row.addEventListener("click", show); row.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); show(); } });
return row;
const select = () => { selectedIndex = selectedIndex === index ? null : index; renderRows(); };
row.addEventListener("click", select);
row.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); select(); } });
nodes.push(row);
if (selectedIndex === index) {
const detailRow = el("tr", undefined, "file-log-detail-row");
const cell = el("td"); cell.colSpan = Math.max(1, visibleColumns.size);
const heading = el("strong", t("fileLogsDetails")); heading.dataset.i18n = "fileLogsDetails";
const pre = el("pre", JSON.stringify(entry, null, 2), "metrics-example");
const detail = el("div", undefined, "file-log-inline-detail"); detail.append(heading, pre); cell.append(detail); detailRow.append(cell); nodes.push(detailRow);
}
});
$("file-log-rows").replaceChildren(...rows);
$("file-logs-empty").hidden = rows.length !== 0;
if (!rows.length) $("file-log-detail").textContent = t("fileLogsSelectRecord");
$("file-log-rows").replaceChildren(...nodes);
$("file-logs-empty").hidden = entries.length !== 0;
}
function queryString() {
const params = new URLSearchParams();
@@ -66,7 +141,8 @@
try {
if (!inventory) await loadInventory();
const value = await api(`/api/file-logs/query?${queryString()}`);
lastTotal = Number(value.total) || 0; renderRows(Array.isArray(value.entries) ? value.entries : []);
lastTotal = Number(value.total) || 0; selectedIndex = null;
renderRows(Array.isArray(value.entries) ? value.entries : []);
const from = lastTotal ? offset + 1 : 0, to = Math.min(offset + currentLimit(), lastTotal);
$("file-log-summary").textContent = t("fileLogsSummary")
.replace("{from}", fmt(from)).replace("{to}", fmt(to)).replace("{total}", fmt(lastTotal))
@@ -78,14 +154,18 @@
finally { busy = false; $("file-logs-refresh").disabled = false; }
}
function reset() {
inventory = null; offset = 0; lastTotal = 0; $("file-log-rows").replaceChildren(); $("file-log-detail").textContent = "";
inventory = null; offset = 0; lastTotal = 0; currentEntries = []; selectedIndex = null;
preferencesLoaded = false; preferenceSaveRunning = false; preferenceSaveQueued = false;
visibleColumns = new Set(defaultColumnIds); $("file-log-rows").replaceChildren(); syncColumnPicker(); syncHeaders();
}
$("file-logs-open").addEventListener("click", () => setPanel("filelogs"));
$("settings-file-logs-open").addEventListener("click", () => setPanel("filelogs"));
$("file-logs-refresh").addEventListener("click", () => refresh(true));
$("file-logs-filter").addEventListener("submit", event => { event.preventDefault(); void refresh(true); });
$("file-log-q").addEventListener("input", () => { clearTimeout(searchTimer); searchTimer = setTimeout(() => void refresh(true), 250); });
$("file-logs-prev").addEventListener("click", () => { offset = Math.max(0, offset - currentLimit()); void refresh(); });
$("file-logs-next").addEventListener("click", () => { if (offset + currentLimit() < lastTotal) { offset += currentLimit(); void refresh(); } });
sourceSelect().addEventListener("change", () => refresh(true));
window.rustProxyFileLogs = { refresh: () => refresh(false), reset, inventory: loadInventory };
syncColumnPicker(); syncHeaders();
window.rustProxyFileLogs = { refresh: () => refresh(false), reset, inventory: loadInventory, applyPreferences };
})();
+22 -15
View File
@@ -1539,20 +1539,31 @@
<option>500</option>
</select></label>
<button class="primary" data-i18n="applyFilters" type="submit">Apply filters</button>
<details class="log-column-picker file-log-column-picker">
<summary data-i18n="logColumns">Columns</summary>
<div class="log-column-options" id="file-log-column-options"></div>
</details>
</form>
<details class="file-log-inventory-details">
<summary data-i18n="fileLogsSourcesRead">Configured files</summary>
<p class="fine file-log-inventory" id="file-log-inventory"></p>
</details>
<p class="fine" id="file-log-summary" role="status"></p>
<div class="table-scroll file-logs-table">
<table>
<thead>
<thead id="file-logs-table-head">
<tr>
<th data-i18n="time">Time</th>
<th data-i18n="fileLogsEvent">Event</th>
<th data-i18n="status">Status</th>
<th data-i18n="clientIp">Client IP</th>
<th data-i18n="vhost">Vhost</th>
<th data-i18n="fileLogsRecord">Request / reason</th>
<th data-i18n="fileLogsSource">Source</th>
<th data-file-log-column="time" data-i18n="time">Time</th>
<th data-file-log-column="event" data-i18n="fileLogsEvent">Event</th>
<th data-file-log-column="status" data-i18n="status">Status</th>
<th data-file-log-column="client_ip" data-i18n="clientIp">Client IP</th>
<th data-file-log-column="vhost" data-i18n="vhost">Vhost</th>
<th data-file-log-column="transport" data-i18n="logTransport" hidden>Transport</th>
<th data-file-log-column="matched_by" data-i18n="fileLogsMatchedBy" hidden>Matched by</th>
<th data-file-log-column="sni" data-i18n="fileLogsSni" hidden>SNI</th>
<th data-file-log-column="authority" data-i18n="fileLogsAuthority" hidden>Authority</th>
<th data-file-log-column="record" data-i18n="fileLogsRecord">Request / reason</th>
<th data-file-log-column="source" data-i18n="fileLogsSource">Source</th>
</tr>
</thead>
<tbody id="file-log-rows"></tbody>
@@ -1563,10 +1574,6 @@
<div class="file-logs-pager"><button class="quiet" data-i18n="previous" disabled
id="file-logs-prev" type="button">Previous</button><button class="quiet"
data-i18n="next" disabled id="file-logs-next" type="button">Next</button></div>
<details open class="file-log-detail-card">
<summary data-i18n="fileLogsDetails">Record details</summary>
<pre class="metrics-example" id="file-log-detail"></pre>
</details>
</article>
</section>
<section class="panel" hidden="" id="panel-settings">
@@ -1742,7 +1749,7 @@
<option data-i18n="serverCustom" value="custom">Replace / add custom value</option>
</select>
<label data-i18n="serverValue" for="server-value">Custom Server value</label><input
id="server-value" maxlength="256" placeholder="RustProxy/v0.26.0"
id="server-value" maxlength="256" placeholder="RustProxy/v0.26.1"
spellcheck="false" />
<h3 class="subheading" data-i18n="diagnosticHeaders">Diagnostic headers</h3>
<div class="switch-grid">
@@ -2541,7 +2548,7 @@
</section>
</div>
</section>
<footer class="page-footer"><span>RustProxy <b>0.26.0</b></span><span id="footer-status">API - /
<footer class="page-footer"><span>RustProxy <b>0.26.1</b></span><span id="footer-status">API - /
dropped: 0</span></footer>
</div>
</main>
@@ -3252,7 +3259,7 @@ max_execution_time = 30</code></pre>
<article class="card about-card">
<div class="about-product">
<span aria-hidden="true" class="brand-mark">r<span>/</span>p</span>
<div><span class="small-label">RustProxy</span><strong>0.26.0</strong></div>
<div><span class="small-label">RustProxy</span><strong>0.26.1</strong></div>
</div>
<dl class="about-meta">
<div>
+49 -11
View File
@@ -8207,7 +8207,7 @@ button.config-history-day.is-selected {
.file-logs-filter {
display: grid;
grid-template-columns: repeat(7, minmax(120px, 1fr));
grid-template-columns: repeat(8, minmax(120px, 1fr));
gap: 10px;
align-items: end;
}
@@ -8220,14 +8220,31 @@ button.config-history-day.is-selected {
grid-column: span 2;
}
.file-logs-table {
max-height: 52vh;
.file-log-column-picker {
align-self: end;
}
.file-logs-table tbody tr {
.file-logs-table {
max-height: 62vh;
}
.file-logs-table thead th {
position: sticky;
top: 0;
z-index: 4;
background: var(--panel);
box-shadow: 0 1px 0 var(--line);
}
.file-logs-table tbody tr:not(.file-log-detail-row) {
cursor: pointer;
}
.file-logs-table tbody tr.selected > td {
background: var(--panel2);
box-shadow: inset 3px 0 0 var(--accent);
}
.file-logs-table td {
max-width: 340px;
overflow: hidden;
@@ -8235,19 +8252,33 @@ button.config-history-day.is-selected {
white-space: nowrap;
}
.file-log-detail-row td {
max-width: none;
padding: 0;
background: var(--panel2);
white-space: normal;
}
.file-log-inline-detail {
padding: 12px;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.file-log-inline-detail pre {
max-height: 32vh;
margin: 8px 0 0;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.file-logs-pager {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.file-log-detail-card pre {
max-height: 32vh;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.file-logs-launcher {
display: flex;
align-items: center;
@@ -8255,7 +8286,14 @@ button.config-history-day.is-selected {
gap: 16px;
}
.file-log-inventory-details > summary {
cursor: pointer;
color: var(--muted);
font-size: 0.86rem;
}
.file-log-inventory {
margin: 8px 0 0;
overflow-wrap: anywhere;
}
+6
View File
@@ -238,6 +238,12 @@ pub struct CompiledFrontend {
pub pages: crate::pages::VhostPages,
pub proxy_timeouts: crate::vhost::ProxyTimeouts,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedLogVhost {
pub vhost: String,
pub matched_by: &'static str,
}
pub struct Snapshot {
pub vhost_metrics: std::collections::BTreeMap<String, Arc<crate::metrics::VhostMetrics>>,
pub forwarding: crate::forwarding::Compiled,
+48
View File
@@ -93,6 +93,54 @@ impl Snapshot {
}
None
}
/// Resolve a persistent protocol-error record to a configured vhost using
/// the same exact/wildcard precedence and listener binding rules as normal routing.
/// Client-provided Host/SNI values are never used as file names directly.
pub fn resolve_logging_vhost(
&self,
local: SocketAddr,
tls: bool,
routed_vhost: Option<&str>,
authority: Option<&str>,
host: Option<&str>,
sni: Option<&str>,
) -> Option<ResolvedLogVhost> {
if let Some(routed) = routed_vhost.filter(|value| !value.is_empty()) {
if self.frontends.values().flatten().any(|frontend| {
frontend.id == routed
&& frontend
.bindings
.iter()
.any(|binding| binding.matches(local, tls))
}) {
return Some(ResolvedLogVhost {
vhost: routed.to_owned(),
matched_by: "router",
});
}
}
for (candidate, matched_by) in [
(authority, "authority"),
(host, "host"),
(sni, "sni"),
] {
let Some(candidate) = candidate else {
continue;
};
let Ok(normalized) = request_host(candidate) else {
continue;
};
if let Some(frontend) = self.frontend(&normalized, local, tls) {
return Some(ResolvedLogVhost {
vhost: frontend.id.clone(),
matched_by,
});
}
}
None
}
/// Select a redirect host/rule using the accepted socket, TLS state and normalized path.
/// Exact hostnames win over the longest matching wildcard, matching vhost behavior.
pub fn redirect_on(
+51
View File
@@ -187,6 +187,57 @@ fn wildcard_host_patterns_are_normalized_and_exact_hosts_win() {
"wildcard/main"
);
}
#[test]
fn logging_vhost_resolution_reuses_router_host_precedence() {
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
config.vhosts[0].id = "wildcard".into();
config.vhosts[0].frontend.hosts = vec!["*.example.test".into()];
let mut exact = config.vhosts[0].clone();
exact.id = "exact".into();
exact.frontend.hosts = vec!["api.example.test".into()];
config.vhosts.push(exact);
let snapshot = compile(config, 1, &policy()).unwrap();
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
let routed = snapshot
.resolve_logging_vhost(
local,
false,
Some("wildcard"),
Some("api.example.test"),
None,
None,
)
.unwrap();
assert_eq!(routed.vhost, "wildcard");
assert_eq!(routed.matched_by, "router");
let exact = snapshot
.resolve_logging_vhost(local, false, None, Some("API.EXAMPLE.TEST:8080"), None, None)
.unwrap();
assert_eq!(exact.vhost, "exact");
assert_eq!(exact.matched_by, "authority");
let wildcard = snapshot
.resolve_logging_vhost(local, false, None, None, Some("www.example.test:8080"), None)
.unwrap();
assert_eq!(wildcard.vhost, "wildcard");
assert_eq!(wildcard.matched_by, "host");
let sni = snapshot
.resolve_logging_vhost(local, false, None, None, None, Some("api.example.test"))
.unwrap();
assert_eq!(sni.vhost, "exact");
assert_eq!(sni.matched_by, "sni");
assert!(snapshot
.resolve_logging_vhost(local, false, None, None, None, Some("unknown.test"))
.is_none());
assert!(snapshot
.resolve_logging_vhost(local, false, None, None, None, None)
.is_none());
}
#[test]
fn reusable_acl_profile_reference_is_compiled_and_unknown_reference_fails() {
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
+75 -16
View File
@@ -28,7 +28,6 @@ use tokio::{
task::JoinSet,
};
use tower::ServiceExt;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
@@ -191,6 +190,63 @@ impl Drop for Lease {
.fetch_sub(1, Ordering::Relaxed);
}
}
#[allow(clippy::too_many_arguments)]
fn log_protocol_error(
state: &App,
kind: &'static str,
detail: impl std::fmt::Display,
peer: SocketAddr,
local: SocketAddr,
status: u16,
authority: Option<String>,
host: Option<String>,
sni: Option<String>,
) {
let resolved = state.config.load().resolve_logging_vhost(
local,
true,
None,
authority.as_deref(),
host.as_deref(),
sni.as_deref(),
);
let (vhost, matched_by) = resolved
.map(|resolved| (Some(resolved.vhost), resolved.matched_by))
.unwrap_or((None, "none"));
state.logs.push_protocol_error_attributed(
kind,
detail,
peer,
local,
"quic",
"3",
status,
sni,
authority.or(host),
vhost,
matched_by,
);
}
fn request_identity<B>(request: &http::Request<B>) -> (Option<String>, Option<String>) {
let authority = request
.uri()
.authority()
.map(|value| value.as_str().to_owned());
let host = request
.headers()
.get(http::header::HOST)
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
(authority, host)
}
fn connection_sni(connection: &quinn::Connection) -> Option<String> {
connection
.handshake_data()?
.downcast::<quinn::crypto::rustls::HandshakeData>()
.ok()?
.server_name
.and_then(|name| crate::config::request_host(&name).ok())
}
pub async fn serve(bound: Bound, router: Router, state: Arc<App>, grace: Duration) -> Result<()> {
let slots = Arc::new(Semaphore::new(
bound.settings.max_concurrent_connections as usize,
@@ -258,15 +314,16 @@ pub async fn serve(bound: Bound, router: Router, state: Arc<App>, grace: Duratio
None if !address.ip().is_unspecified()=>address,
None=>{connection.close(0x102u32.into(),b"local destination unavailable");return;},
};
let tls_sni=connection_sni(&connection);
state.metrics.connections.fetch_add(1,Ordering::Relaxed);
state.metrics.active_connections.fetch_add(1,Ordering::Relaxed);
state.metrics.http3_connections.fetch_add(1,Ordering::Relaxed);
state.metrics.http3_active_connections.fetch_add(1,Ordering::Relaxed);
state.metrics.tls_connections.fetch_add(1,Ordering::Relaxed);
let _lease=Lease {_permit:permit,metrics:state.metrics.clone()};
if let Err(error)=connection_loop(connection,local,router,state.clone(),limit,grace).await {
state.logs.push_protocol_error(
"http3_connection_error", &error, peer, local, "quic", "3", 0, None,
if let Err(error)=connection_loop(connection,local,router,state.clone(),limit,grace,tls_sni.clone()).await {
log_protocol_error(
&state, "http3_connection_error", &error, peer, local, 0, None, None, tls_sni,
);
tracing::debug!(%error,"HTTP/3 connection ended");
}
@@ -290,6 +347,7 @@ async fn connection_loop(
state: Arc<App>,
limit: usize,
grace: Duration,
tls_sni: Option<String>,
) -> Result<()> {
let mut builder = h3::server::builder();
builder.max_field_section_size(32 * 1024);
@@ -316,27 +374,27 @@ async fn connection_loop(
Ok(Some(resolver))=>{
// Completed JoinSet entries are drained before accepting more.
if requests.len()>=limit {
state.logs.push_protocol_error(
"http3_stream_limit_rejected", "HTTP/3 concurrent request stream limit reached",
connection.remote_address(), local, "quic", "3", 0, None,
log_protocol_error(
&state, "http3_stream_limit_rejected", "HTTP/3 concurrent request stream limit reached",
connection.remote_address(), local, 0, None, None, tls_sni.clone(),
);
drop(resolver);continue;
}
let router=router.clone();let state=state.clone();let peer=connection.remote_address();
let router=router.clone();let state=state.clone();let peer=connection.remote_address();let tls_sni=tls_sni.clone();
requests.spawn(async move {
let resolved=tokio::time::timeout(state.header_timeout,resolver.resolve_request()).await;
let (mut request,stream)=match resolved {
Ok(Ok(request))=>request,
Ok(Err(error))=>{
state.logs.push_protocol_error(
"http3_request_error", error, peer, local, "quic", "3", 400, None,
log_protocol_error(
&state, "http3_request_error", error, peer, local, 400, None, None, tls_sni.clone(),
);
return;
},
Err(_)=>{
state.logs.push_protocol_error(
"http3_header_timeout", "HTTP/3 request header timeout",
peer, local, "quic", "3", 408, None,
log_protocol_error(
&state, "http3_header_timeout", "HTTP/3 request header timeout",
peer, local, 408, None, None, tls_sni.clone(),
);
return;
},
@@ -347,9 +405,10 @@ async fn connection_loop(
let websocket=extended_connect && protocol.as_deref()==Some("websocket");
let (mut send,receive)=stream.split();
if invalid_headers(request.headers()) {
state.logs.push_protocol_error(
"http3_invalid_headers", "HTTP/3 request contains forbidden connection-specific headers",
peer, local, "quic", "3", 400, None,
let (authority, host) = request_identity(&request);
log_protocol_error(
&state, "http3_invalid_headers", "HTTP/3 request contains forbidden connection-specific headers",
peer, local, 400, authority, host, tls_sni.clone(),
);
send.stop_stream(Code::H3_MESSAGE_ERROR);return;
}
+74 -2
View File
@@ -2,9 +2,14 @@
pub struct LogEntry {
pub event: &'static str,
pub protocol_error: Option<&'static str>,
pub reason: Option<&'static str>,
pub error_detail: Option<String>,
pub error: Option<String>,
pub local_addr: Option<String>,
pub tls_sni: Option<String>,
pub sni: Option<String>,
pub authority: Option<String>,
pub matched_by: Option<&'static str>,
pub vhost: Option<String>,
pub route: Option<String>,
pub cache_effective_enabled: Option<bool>,
@@ -85,6 +90,21 @@ fn protocol_error_detail(detail: impl std::fmt::Display) -> String {
detail
}
fn protocol_identity_field(value: Option<String>) -> Option<String> {
value.map(|value| {
let mut value: String = value.chars().filter(|ch| !ch.is_control()).collect();
if value.len() > 512 {
let mut end = 512;
while !value.is_char_boundary(end) {
end -= 1;
}
value.truncate(end);
value.push_str("...");
}
value
})
}
impl Logs {
pub fn start(settings: Settings, shutdown: CancellationToken) -> Arc<Self> {
let (tx, rx) = mpsc::channel(SINK_QUEUE);
@@ -143,18 +163,47 @@ impl Logs {
http_version: &'static str,
status: u16,
tls_sni: Option<String>,
) {
self.push_protocol_error_attributed(
kind, detail, client, local, transport, http_version, status, tls_sni, None, None,
"none",
);
}
#[allow(clippy::too_many_arguments)]
pub fn push_protocol_error_attributed(
&self,
kind: &'static str,
detail: impl std::fmt::Display,
client: std::net::SocketAddr,
local: std::net::SocketAddr,
transport: &'static str,
http_version: &'static str,
status: u16,
tls_sni: Option<String>,
authority: Option<String>,
vhost: Option<String>,
matched_by: &'static str,
) {
if !self.settings.load().enabled() {
return;
}
let detail = protocol_error_detail(detail);
let tls_sni = protocol_identity_field(tls_sni);
let authority = protocol_identity_field(authority);
let sni = tls_sni.clone();
self.push_sink(LogEntry {
event: "protocol_error",
protocol_error: Some(kind),
error_detail: Some(detail),
reason: Some(kind),
error_detail: Some(detail.clone()),
error: Some(detail),
local_addr: Some(local.to_string()),
tls_sni,
vhost: None,
sni,
authority,
matched_by: Some(matched_by),
vhost,
route: None,
cache_effective_enabled: None,
transform_effective_enabled: None,
@@ -221,11 +270,20 @@ fn value_for(entry: &LogEntry, name: &str) -> Value {
.protocol_error
.map(|v| Value::String(v.into()))
.unwrap_or(Value::Null),
"reason" => entry
.reason
.map(|v| Value::String(v.into()))
.unwrap_or(Value::Null),
"error_detail" => entry
.error_detail
.clone()
.map(Value::String)
.unwrap_or(Value::Null),
"error" => entry
.error
.clone()
.map(Value::String)
.unwrap_or(Value::Null),
"local_addr" => entry
.local_addr
.clone()
@@ -236,6 +294,20 @@ fn value_for(entry: &LogEntry, name: &str) -> Value {
.clone()
.map(Value::String)
.unwrap_or(Value::Null),
"sni" => entry
.sni
.clone()
.map(Value::String)
.unwrap_or(Value::Null),
"authority" => entry
.authority
.clone()
.map(Value::String)
.unwrap_or(Value::Null),
"matched_by" => entry
.matched_by
.map(|v| Value::String(v.into()))
.unwrap_or(Value::Null),
"vhost" => entry
.vhost
.clone()
+5
View File
@@ -231,9 +231,14 @@ fn source_value_name(name: &str) -> bool {
name,
"event"
| "protocol_error"
| "reason"
| "error_detail"
| "error"
| "local_addr"
| "tls_sni"
| "sni"
| "authority"
| "matched_by"
| "vhost"
| "route"
| "cache_effective_enabled"
+10 -18
View File
@@ -19,11 +19,17 @@ fn format_entry(entry: &LogEntry, format: &JsonFormat) -> Value {
for name in [
"event",
"protocol_error",
"reason",
"error_detail",
"error",
"time_ms",
"client_ip",
"local_addr",
"tls_sni",
"sni",
"authority",
"matched_by",
"vhost",
"transport",
"http_version",
"status",
@@ -63,24 +69,10 @@ struct FileState {
const PER_VHOST_OPEN_FILES: usize = 64;
fn vhost_log_name(vhost: Option<&str>) -> String {
let raw = vhost
.filter(|value| !value.is_empty())
.unwrap_or("_unmatched");
let mut safe = String::with_capacity(raw.len().min(96));
for ch in raw.chars().take(96) {
safe.push(
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
ch
} else {
'_'
},
);
}
if safe.is_empty() {
"_unmatched".into()
} else {
safe
}
vhost
.filter(|value| crate::config::valid_id(value))
.unwrap_or("_unmatched")
.to_owned()
}
struct LogstashState {
address: String,
+25 -1
View File
@@ -5,9 +5,14 @@ mod tests {
LogEntry {
event: "request",
protocol_error: None,
reason: None,
error_detail: None,
error: None,
local_addr: Some("192.0.2.1:443".into()),
tls_sni: Some("app.example".into()),
sni: Some("app.example".into()),
authority: None,
matched_by: None,
vhost: Some("app".into()),
route: Some("root".into()),
cache_effective_enabled: Some(true),
@@ -95,7 +100,7 @@ mod tests {
fn per_vhost_file_mode_has_safe_names_and_requires_directory() {
assert_eq!(vhost_log_name(Some("app-1")), "app-1");
assert_eq!(vhost_log_name(None), "_unmatched");
assert_eq!(vhost_log_name(Some("bad/name")), "bad_name");
assert_eq!(vhost_log_name(Some("bad/name")), "_unmatched");
let mut settings = Settings::default();
settings.file.enabled = true;
settings.file.per_vhost = true;
@@ -124,7 +129,12 @@ mod tests {
let mut item = entry();
item.event = "protocol_error";
item.protocol_error = Some("tls_handshake_error");
item.reason = Some("tls_handshake_error");
item.error_detail = Some("invalid handshake".into());
item.error = Some("invalid handshake".into());
item.sni = Some("app.example".into());
item.authority = Some("app.example:443".into());
item.matched_by = Some("sni");
item.outcome = "protocol_error";
item.status = 0;
item.transport = "tls";
@@ -135,7 +145,12 @@ mod tests {
assert_eq!(value["site"], "app");
assert_eq!(value["event"], "protocol_error");
assert_eq!(value["protocol_error"], "tls_handshake_error");
assert_eq!(value["reason"], "tls_handshake_error");
assert_eq!(value["error_detail"], "invalid handshake");
assert_eq!(value["error"], "invalid handshake");
assert_eq!(value["sni"], "app.example");
assert_eq!(value["authority"], "app.example:443");
assert_eq!(value["matched_by"], "sni");
assert_eq!(value["client_ip"], "192.0.2.10");
assert_eq!(value["transport"], "tls");
assert_eq!(value["outcome"], "protocol_error");
@@ -170,6 +185,15 @@ mod tests {
assert!(detail.ends_with("..."));
}
#[test]
fn protocol_identity_fields_are_bounded_and_strip_controls() {
let value = protocol_identity_field(Some(format!("host\n{}", "€".repeat(300)))).unwrap();
assert!(!value.contains('\n'));
assert!(value.is_char_boundary(value.len()));
assert!(value.len() <= 515);
assert!(value.ends_with("..."));
}
#[test]
fn error_log_defaults_off_and_redacts_common_secret_shapes() {
let settings = ErrorLogSettings::default();
+1 -1
View File
@@ -41,7 +41,7 @@ use zeroize::Zeroizing;
#[command(
name = "rust-proxy",
version,
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.26.0"
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.26.1"
)]
struct Cli {
#[arg(
+1 -1
View File
@@ -239,7 +239,7 @@ async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> {
}
});
let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
println!("RustProxy live logs 0.26.0 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
println!("RustProxy live logs 0.26.1 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
if interactive { print!("> "); io::stdout().flush()?; }
let mut filters = LiveLogFilters::default();
let mut history = std::collections::BTreeMap::<u64, serde_json::Value>::new();
+5
View File
@@ -204,9 +204,14 @@ impl Drop for Exchange {
self.logs.push(LogEntry {
event: "request",
protocol_error: None,
reason: None,
error_detail: None,
error: None,
local_addr: None,
tls_sni: None,
sni: None,
authority: None,
matched_by: None,
cache_reason: *self.cache_reason.lock().unwrap_or_else(|e| e.into_inner()),
vhost: self.vhost.clone(),
route,
+57 -10
View File
@@ -88,6 +88,61 @@ pub struct Transport {
pub tls: bool,
}
#[allow(clippy::too_many_arguments)]
fn log_protocol_error_with_identity(
state: Option<&Arc<App>>,
kind: &'static str,
detail: impl std::fmt::Display,
peer: SocketAddr,
local: SocketAddr,
transport: &'static str,
http_version: &'static str,
status: u16,
authority: Option<String>,
host: Option<String>,
tls_sni: Option<String>,
) {
if let Some(state) = state {
let resolved = state.config.load().resolve_logging_vhost(
local,
matches!(transport, "tls" | "quic"),
None,
authority.as_deref(),
host.as_deref(),
tls_sni.as_deref(),
);
let (vhost, matched_by) = resolved
.map(|resolved| (Some(resolved.vhost), resolved.matched_by))
.unwrap_or((None, "none"));
state.logs.push_protocol_error_attributed(
kind,
detail,
peer,
local,
transport,
http_version,
status,
tls_sni,
authority.or(host),
vhost,
matched_by,
);
}
}
fn request_log_identity<B>(request: &Request<B>) -> (Option<String>, Option<String>) {
let authority = request
.uri()
.authority()
.map(|value| value.as_str().to_owned());
let host = request
.headers()
.get(http::header::HOST)
.and_then(|value| value.to_str().ok())
.map(str::to_owned);
(authority, host)
}
fn log_protocol_error(
state: Option<&Arc<App>>,
kind: &'static str,
@@ -99,18 +154,10 @@ fn log_protocol_error(
status: u16,
tls_sni: Option<String>,
) {
if let Some(state) = state {
state.logs.push_protocol_error(
kind,
detail,
peer,
local,
transport,
http_version,
status,
log_protocol_error_with_identity(
state, kind, detail, peer, local, transport, http_version, status, None, None,
tls_sni,
);
}
}
#[derive(Clone)]
pub struct ServerShutdown(pub CancellationToken);
+3 -2
View File
@@ -5,6 +5,7 @@ async fn http2_connection<I>(
peer: SocketAddr,
local: SocketAddr,
transport: Transport,
tls_sni: Option<String>,
extended_connect: bool,
frontend_state: Option<Arc<App>>,
lease: Arc<ConnectionLease>,
@@ -69,7 +70,7 @@ async fn http2_connection<I>(
if let Err(error) = result {
log_protocol_error(
frontend_state.as_ref(), "http2_connection_error", &error, peer, local,
if transport.tls { "tls" } else { "tcp" }, "2", 0, None,
if transport.tls { "tls" } else { "tcp" }, "2", 0, tls_sni.clone(),
);
tracing::debug!(%error, "HTTP/2 connection closed");
}
@@ -108,7 +109,7 @@ async fn http2_connection<I>(
if transport.tls { "tls" } else { "tcp" },
"2",
0,
None,
tls_sni.clone(),
);
tracing::debug!(?cause, %error, "HTTP/2 graceful shutdown ended with protocol error")
}
+2
View File
@@ -100,7 +100,9 @@ mod h2c_tests {
peer,
local,
Transport { tls: false },
None,
false,
None,
lease,
server_stop,
Duration::from_secs(5),
+16 -3
View File
@@ -125,6 +125,7 @@ async fn serve_inner(
peer,
local,
Transport { tls: true },
host.clone(),
h2,
extended_connect,
false,
@@ -168,6 +169,7 @@ async fn serve_inner(
peer,
local,
Transport { tls: false },
None,
false,
false,
false,
@@ -197,6 +199,7 @@ async fn serve_inner(
peer,
local,
Transport { tls: true },
None,
h2,
false,
false,
@@ -245,6 +248,7 @@ async fn serve_inner(
peer,
local,
Transport { tls: false },
None,
prior_h2,
extended_connect,
h2c && !prior_h2,
@@ -276,6 +280,7 @@ async fn connection<I>(
peer: SocketAddr,
local: SocketAddr,
transport: Transport,
tls_sni: Option<String>,
h2: bool,
extended_connect: bool,
h2c_upgrade: bool,
@@ -294,6 +299,7 @@ async fn connection<I>(
peer,
local,
transport,
tls_sni.clone(),
extended_connect,
frontend_state.clone(),
lease,
@@ -322,7 +328,8 @@ async fn connection<I>(
if h2c_attempt(&request) {
if !h2c_upgrade {
log_protocol_error(
let (authority, host) = request_log_identity(&request);
log_protocol_error_with_identity(
upgrade_state.as_ref(),
"h2c_upgrade_rejected",
"h2c upgrade is disabled on this listener",
@@ -331,6 +338,8 @@ async fn connection<I>(
"tcp",
"1.1",
400,
authority,
host,
None,
);
return Ok::<_, Infallible>(
@@ -344,7 +353,8 @@ async fn connection<I>(
let injected_headers = match h2_headers_frame(&request) {
Ok(frame) => frame,
Err(message) => {
log_protocol_error(
let (authority, host) = request_log_identity(&request);
log_protocol_error_with_identity(
upgrade_state.as_ref(),
"h2c_upgrade_invalid",
message,
@@ -353,6 +363,8 @@ async fn connection<I>(
"tcp",
"1.1",
400,
authority,
host,
None,
);
return Ok::<_, Infallible>(
@@ -389,6 +401,7 @@ async fn connection<I>(
peer,
local,
Transport { tls: false },
None,
extended_connect,
upgrade_state_for_task.clone(),
upgrade_lease,
@@ -448,7 +461,7 @@ async fn connection<I>(
if let Err(error) = result {
log_protocol_error(
frontend_state.as_ref(), "http1_connection_error", &error, peer, local,
if transport.tls { "tls" } else { "tcp" }, "1.1", 400, None,
if transport.tls { "tls" } else { "tcp" }, "1.1", 400, tls_sni.clone(),
);
tracing::debug!(%error, "HTTP/1 connection closed");
}
+1 -1
View File
@@ -159,7 +159,7 @@ pub(super) fn fastcgi_params(
if context.tls { "on" } else { "off" }.into(),
);
params.insert("GATEWAY_INTERFACE".into(), "CGI/1.1".into());
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.26.0".into());
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.26.1".into());
params.insert("REDIRECT_STATUS".into(), "200".into());
if let Some(value) = headers
.get(CONTENT_TYPE)
+1 -1
View File
@@ -185,7 +185,7 @@ pub(super) fn directory_listing(path: &str, entries: &[DirectoryEntry]) -> Vec<u
"<a class=\"{class}\" href=\"{href}\"><span class=\"type\">{type_label}</span><span class=\"name\">{label}{slash}</span><span class=\"kind\">{kind}</span></a>"
));
}
html.push_str(r#"</div></section><footer class="page-footer"><span>RustProxy <b>0.26.0</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
html.push_str(r#"</div></section><footer class="page-footer"><span>RustProxy <b>0.26.1</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
html.into_bytes()
}
+1 -1
View File
@@ -202,7 +202,7 @@ async fn empty_index_can_go_directly_to_directory_listing() {
let body = std::str::from_utf8(&body).unwrap();
assert!(body.contains("file.txt"));
assert!(body.contains("DIRECTORY LISTING"));
assert!(body.contains("RustProxy <b>0.26.0</b>"));
assert!(body.contains("RustProxy <b>0.26.1</b>"));
}
#[tokio::test]