From b9d137eee1a40086cf6a3b757a48677d3c9235b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 5 Oct 2026 19:05:55 +0200 Subject: [PATCH] v0.26.1 --- AGENTS.md | 2 +- CHANGELOG.md | 9 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 26 ++-- ROADMAP.md | 13 +- SHA256SUMS | 122 +++++++++-------- debian/README.md | 2 +- debian/build-package.sh | 2 +- debian/changelog | 8 ++ dev.sh | 4 +- docker-agent/model.go | 2 +- docs/API_ACCESS.md | 2 +- docs/FILE_LOGS.md | 64 ++++++--- docs/INSTALL.md | 2 +- docs/MIGRATION.md | 4 + docs/README.md | 3 +- docs/TEST_REPORT.md | 49 +++---- docs/V0.26.1_IMPLEMENTATION.md | 104 ++++++++++++++ grafana/README.md | 2 +- grafana/rustproxy-overview.json | 4 +- langs/en_US.json | 5 + langs/pl_pl.json | 5 + make_zip.py | 2 +- scripts/check_source.py | 20 +-- scripts/deploy_core.py | 2 +- scripts/docs_check.py | 10 +- scripts/preflight.py | 4 +- scripts/release_smoke.py | 10 +- scripts/swagger_assets.py | 2 +- scripts/upgrade.py | 6 +- scripts/web_server_contracts.py | 2 +- src/admin/documentation/documents.rs | 8 ++ src/admin/file_logs.rs | 81 ++++++++++- src/admin/mod.rs | 1 + src/admin/openapi.json | 34 ++++- src/admin/preferences.rs | 197 +++++++++++++++++++++++++++ src/admin/session.rs | 167 ----------------------- src/admin/tests/part_04.rs | 6 + src/admin/tests/part_06.rs | 31 +++++ src/admin/web/app/core.js | 2 +- src/admin/web/app/settings.js | 2 +- src/admin/web/file-logs.js | 110 +++++++++++++-- src/admin/web/index.html | 39 +++--- src/admin/web/style.css | 60 ++++++-- src/config/model.rs | 6 + src/config/snapshot.rs | 48 +++++++ src/config/tests/part_01.rs | 51 +++++++ src/http3.rs | 91 ++++++++++--- src/logging/format.rs | 76 ++++++++++- src/logging/model.rs | 5 + src/logging/runtime.rs | 28 ++-- src/logging/tests.rs | 26 +++- src/main/bootstrap.rs | 2 +- src/main/tasks.rs | 2 +- src/proxy/body.rs | 5 + src/server/http.rs | 71 ++++++++-- src/server/serve.rs | 5 +- src/server/serve/tests.rs | 2 + src/server/tls_runtime.rs | 19 ++- src/web_server/fastcgi/protocol.rs | 2 +- src/web_server/response/file.rs | 2 +- src/web_server/tests/part_02.rs | 2 +- 63 files changed, 1232 insertions(+), 445 deletions(-) create mode 100644 docs/V0.26.1_IMPLEMENTATION.md create mode 100644 src/admin/preferences.rs diff --git a/AGENTS.md b/AGENTS.md index 5876dea..160f4b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e29ac6..a222f46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Cargo.lock b/Cargo.lock index 4b692f9..bc20441 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1688,7 +1688,7 @@ dependencies = [ [[package]] name = "rust-proxy" -version = "0.26.0" +version = "0.26.1" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 9e1e0a4..56dcd26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md index e1eebd0..3b08e9a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index 745dc78..edbe271 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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. diff --git a/SHA256SUMS b/SHA256SUMS index 1fc3ff3..810fa90 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -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 diff --git a/debian/README.md b/debian/README.md index 6b4b31a..d9b07aa 100644 --- a/debian/README.md +++ b/debian/README.md @@ -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` diff --git a/debian/build-package.sh b/debian/build-package.sh index a27ba17..b301594 100755 --- a/debian/build-package.sh +++ b/debian/build-package.sh @@ -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" diff --git a/debian/changelog b/debian/changelog index 12bf484..1e30a17 100644 --- a/debian/changelog +++ b/debian/changelog @@ -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 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. diff --git a/dev.sh b/dev.sh index 46bc2ad..fc76834 100755 --- a/dev.sh +++ b/dev.sh @@ -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 diff --git a/docker-agent/model.go b/docker-agent/model.go index e6491a0..37faf79 100644 --- a/docker-agent/model.go +++ b/docker-agent/model.go @@ -1,6 +1,6 @@ package main -const version = "0.26.0" +const version = "0.26.1" type config struct { Listen string diff --git a/docs/API_ACCESS.md b/docs/API_ACCESS.md index 844b6b0..10de324 100644 --- a/docs/API_ACCESS.md +++ b/docs/API_ACCESS.md @@ -1,4 +1,4 @@ -# Administration API access - 0.26.0 +# Administration API access - 0.26.1 ## Sign-in boundary diff --git a/docs/FILE_LOGS.md b/docs/FILE_LOGS.md index c823ba5..dcaf97a 100644 --- a/docs/FILE_LOGS.md +++ b/docs/FILE_LOGS.md @@ -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. diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 38af198..0052b4c 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -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 diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index e1e6030..2c90901 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -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. diff --git a/docs/README.md b/docs/README.md index 7a40179..8391b88 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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. diff --git a/docs/TEST_REPORT.md b/docs/TEST_REPORT.md index 5e464be..0f7471c 100644 --- a/docs/TEST_REPORT.md +++ b/docs/TEST_REPORT.md @@ -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. diff --git a/docs/V0.26.1_IMPLEMENTATION.md b/docs/V0.26.1_IMPLEMENTATION.md new file mode 100644 index 0000000..dfc59d2 --- /dev/null +++ b/docs/V0.26.1_IMPLEMENTATION.md @@ -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 +``` + +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. diff --git a/grafana/README.md b/grafana/README.md index e88ff48..66d0d27 100644 --- a/grafana/README.md +++ b/grafana/README.md @@ -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. diff --git a/grafana/rustproxy-overview.json b/grafana/rustproxy-overview.json index 2a08664..141f86c 100644 --- a/grafana/rustproxy-overview.json +++ b/grafana/rustproxy-overview.json @@ -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." } diff --git a/langs/en_US.json b/langs/en_US.json index 7be7a11..3f44308 100644 --- a/langs/en_US.json +++ b/langs/en_US.json @@ -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", diff --git a/langs/pl_pl.json b/langs/pl_pl.json index fe4ff14..c524f50 100644 --- a/langs/pl_pl.json +++ b/langs/pl_pl.json @@ -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", diff --git a/make_zip.py b/make_zip.py index af18aaa..54cc9db 100755 --- a/make_zip.py +++ b/make_zip.py @@ -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' diff --git a/scripts/check_source.py b/scripts/check_source.py index 9ad66c2..d36171d 100755 --- a/scripts/check_source.py +++ b/scripts/check_source.py @@ -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.") diff --git a/scripts/deploy_core.py b/scripts/deploy_core.py index 8d642a9..3203bca 100755 --- a/scripts/deploy_core.py +++ b/scripts/deploy_core.py @@ -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.') diff --git a/scripts/docs_check.py b/scripts/docs_check.py index 3c5799a..f224a1e 100755 --- a/scripts/docs_check.py +++ b/scripts/docs_check.py @@ -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}" diff --git a/scripts/preflight.py b/scripts/preflight.py index e02cda4..5736f08 100755 --- a/scripts/preflight.py +++ b/scripts/preflight.py @@ -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 diff --git a/scripts/release_smoke.py b/scripts/release_smoke.py index 3921937..69b08b3 100755 --- a/scripts/release_smoke.py +++ b/scripts/release_smoke.py @@ -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__": diff --git a/scripts/swagger_assets.py b/scripts/swagger_assets.py index 19a263e..37bd6b1 100755 --- a/scripts/swagger_assets.py +++ b/scripts/swagger_assets.py @@ -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: diff --git a/scripts/upgrade.py b/scripts/upgrade.py index 7688af7..e9aa8e8 100755 --- a/scripts/upgrade.py +++ b/scripts/upgrade.py @@ -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") diff --git a/scripts/web_server_contracts.py b/scripts/web_server_contracts.py index 97e5daf..c446465 100755 --- a/scripts/web_server_contracts.py +++ b/scripts/web_server_contracts.py @@ -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 0.26.0", + "RustProxy 0.26.1", "traversal_and_symlink_escape_are_not_served", "try_files_rewrites_cache_rules_headers_and_downloads_work", "precompressed_sidecars_keep_mime_validators_and_range_semantics", diff --git a/src/admin/documentation/documents.rs b/src/admin/documentation/documents.rs index 76c2354..d7faffc 100644 --- a/src/admin/documentation/documents.rs +++ b/src/admin/documentation/documents.rs @@ -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", diff --git a/src/admin/file_logs.rs b/src/admin/file_logs.rs index 20cada0..8dfe760 100644 --- a/src/admin/file_logs.rs +++ b/src/admin/file_logs.rs @@ -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, + 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::(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,12 +306,27 @@ 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) { - return false; + 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 @@ -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,7 +423,10 @@ async fn file_logs_query( } } else { sources.sort_by(|a, b| b.modified_ms.cmp(&a.modified_ms)); - sources.truncate(FILE_LOG_MAX_SOURCES); + 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); @@ -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); } diff --git a/src/admin/mod.rs b/src/admin/mod.rs index d00f28e..0bfbe39 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -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"); diff --git a/src/admin/openapi.json b/src/admin/openapi.json index f593d96..be70245 100644 --- a/src/admin/openapi.json +++ b/src/admin/openapi.json @@ -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": [ diff --git a/src/admin/preferences.rs b/src/admin/preferences.rs new file mode 100644 index 0000000..37338d5 --- /dev/null +++ b/src/admin/preferences.rs @@ -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 { + ["time", "event", "status", "client_ip", "vhost", "record", "source"] + .into_iter() + .map(str::to_owned) + .collect() +} +fn default_backend_table_columns() -> Vec { + 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, + #[serde(default = "default_admin_theme")] + theme: String, + #[serde(default = "default_file_log_columns")] + file_log_columns: Vec, + #[serde(default = "default_backend_table_columns")] + backend_table_columns: Vec, + #[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>, + Extension(session): Extension, +) -> Result, 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::(&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>, + Extension(session): Extension, + input: Result, axum::extract::rejection::JsonRejection>, +) -> Result, 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)) +} diff --git a/src/admin/session.rs b/src/admin/session.rs index c1caaa9..4b149a0 100644 --- a/src/admin/session.rs +++ b/src/admin/session.rs @@ -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 { - 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, - #[serde(default = "default_admin_theme")] - theme: String, - #[serde(default = "default_backend_table_columns")] - backend_table_columns: Vec, - #[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>, - Extension(session): Extension, -) -> Result, 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::(&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>, - Extension(session): Extension, - input: Result, axum::extract::rejection::JsonRejection>, -) -> Result, 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( diff --git a/src/admin/tests/part_04.rs b/src/admin/tests/part_04.rs index e3e8a8f..63a3aab 100644 --- a/src/admin/tests/part_04.rs +++ b/src/admin/tests/part_04.rs @@ -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 diff --git a/src/admin/tests/part_06.rs b/src/admin/tests/part_06.rs index a6b8406..8ddce7b 100644 --- a/src/admin/tests/part_06.rs +++ b/src/admin/tests/part_06.rs @@ -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() { diff --git a/src/admin/web/app/core.js b/src/admin/web/app/core.js index aba3848..2eabda8 100644 --- a/src/admin/web/app/core.js +++ b/src/admin/web/app/core.js @@ -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); } diff --git a/src/admin/web/app/settings.js b/src/admin/web/app/settings.js index f16564c..ff89a18 100644 --- a/src/admin/web/app/settings.js +++ b/src/admin/web/app/settings.js @@ -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(); } diff --git a/src/admin/web/file-logs.js b/src/admin/web/file-logs.js index 53236dd..333e3a8 100644 --- a/src/admin/web/file-logs.js +++ b/src/admin/web/file-logs.js @@ -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 }; })(); diff --git a/src/admin/web/index.html b/src/admin/web/index.html index 7b0565c..bba6c98 100644 --- a/src/admin/web/index.html +++ b/src/admin/web/index.html @@ -1539,20 +1539,31 @@ +
+ Columns +
+
-

+
+ Configured files +

+

- + - - - - - - - + + + + + + + + + + + @@ -1563,10 +1574,6 @@
-
- Record details -

-                        
-
RustProxy 0.26.0API - / +
RustProxy 0.26.1API - / dropped: 0
@@ -3252,7 +3259,7 @@ max_execution_time = 30
-
RustProxy0.26.0
+
RustProxy0.26.1
diff --git a/src/admin/web/style.css b/src/admin/web/style.css index 50f45fd..b5ce382 100644 --- a/src/admin/web/style.css +++ b/src/admin/web/style.css @@ -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; } diff --git a/src/config/model.rs b/src/config/model.rs index ddca088..e77f885 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -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>, pub forwarding: crate::forwarding::Compiled, diff --git a/src/config/snapshot.rs b/src/config/snapshot.rs index 7d85a6e..25fef29 100644 --- a/src/config/snapshot.rs +++ b/src/config/snapshot.rs @@ -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 { + 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( diff --git a/src/config/tests/part_01.rs b/src/config/tests/part_01.rs index ca4cdff..3e2103b 100644 --- a/src/config/tests/part_01.rs +++ b/src/config/tests/part_01.rs @@ -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()); diff --git a/src/http3.rs b/src/http3.rs index 3f9fdb7..5b2dcc2 100644 --- a/src/http3.rs +++ b/src/http3.rs @@ -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, + host: Option, + sni: Option, +) { + 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(request: &http::Request) -> (Option, Option) { + 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 { + connection + .handshake_data()? + .downcast::() + .ok()? + .server_name + .and_then(|name| crate::config::request_host(&name).ok()) +} pub async fn serve(bound: Bound, router: Router, state: Arc, 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, 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, limit: usize, grace: Duration, + tls_sni: Option, ) -> 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; } diff --git a/src/logging/format.rs b/src/logging/format.rs index ea9e168..6db12ee 100644 --- a/src/logging/format.rs +++ b/src/logging/format.rs @@ -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, + pub error: Option, pub local_addr: Option, pub tls_sni: Option, + pub sni: Option, + pub authority: Option, + pub matched_by: Option<&'static str>, pub vhost: Option, pub route: Option, pub cache_effective_enabled: Option, @@ -85,6 +90,21 @@ fn protocol_error_detail(detail: impl std::fmt::Display) -> String { detail } +fn protocol_identity_field(value: Option) -> Option { + 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 { let (tx, rx) = mpsc::channel(SINK_QUEUE); @@ -143,18 +163,47 @@ impl Logs { http_version: &'static str, status: u16, tls_sni: Option, + ) { + 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, + authority: Option, + vhost: Option, + 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() diff --git a/src/logging/model.rs b/src/logging/model.rs index cb587c3..a750348 100644 --- a/src/logging/model.rs +++ b/src/logging/model.rs @@ -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" diff --git a/src/logging/runtime.rs b/src/logging/runtime.rs index 636c951..d56540d 100644 --- a/src/logging/runtime.rs +++ b/src/logging/runtime.rs @@ -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, diff --git a/src/logging/tests.rs b/src/logging/tests.rs index c967fe9..55c539f 100644 --- a/src/logging/tests.rs +++ b/src/logging/tests.rs @@ -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(); diff --git a/src/main/bootstrap.rs b/src/main/bootstrap.rs index 606c842..0cd05cb 100644 --- a/src/main/bootstrap.rs +++ b/src/main/bootstrap.rs @@ -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( diff --git a/src/main/tasks.rs b/src/main/tasks.rs index cf79625..b12817c 100644 --- a/src/main/tasks.rs +++ b/src/main/tasks.rs @@ -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::::new(); diff --git a/src/proxy/body.rs b/src/proxy/body.rs index 87c321a..ef2f9e8 100644 --- a/src/proxy/body.rs +++ b/src/proxy/body.rs @@ -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, diff --git a/src/server/http.rs b/src/server/http.rs index 3b2d5d5..f602461 100644 --- a/src/server/http.rs +++ b/src/server/http.rs @@ -88,6 +88,61 @@ pub struct Transport { pub tls: bool, } +#[allow(clippy::too_many_arguments)] +fn log_protocol_error_with_identity( + state: Option<&Arc>, + kind: &'static str, + detail: impl std::fmt::Display, + peer: SocketAddr, + local: SocketAddr, + transport: &'static str, + http_version: &'static str, + status: u16, + authority: Option, + host: Option, + tls_sni: Option, +) { + 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(request: &Request) -> (Option, Option) { + 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>, kind: &'static str, @@ -99,18 +154,10 @@ fn log_protocol_error( status: u16, tls_sni: Option, ) { - if let Some(state) = state { - state.logs.push_protocol_error( - kind, - detail, - peer, - local, - transport, - http_version, - status, - tls_sni, - ); - } + 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); diff --git a/src/server/serve.rs b/src/server/serve.rs index 50e74eb..be41e22 100644 --- a/src/server/serve.rs +++ b/src/server/serve.rs @@ -5,6 +5,7 @@ async fn http2_connection( peer: SocketAddr, local: SocketAddr, transport: Transport, + tls_sni: Option, extended_connect: bool, frontend_state: Option>, lease: Arc, @@ -69,7 +70,7 @@ async fn http2_connection( 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( if transport.tls { "tls" } else { "tcp" }, "2", 0, - None, + tls_sni.clone(), ); tracing::debug!(?cause, %error, "HTTP/2 graceful shutdown ended with protocol error") } diff --git a/src/server/serve/tests.rs b/src/server/serve/tests.rs index 5d6c9d4..5607acc 100644 --- a/src/server/serve/tests.rs +++ b/src/server/serve/tests.rs @@ -100,7 +100,9 @@ mod h2c_tests { peer, local, Transport { tls: false }, + None, false, + None, lease, server_stop, Duration::from_secs(5), diff --git a/src/server/tls_runtime.rs b/src/server/tls_runtime.rs index 238d748..6533a00 100644 --- a/src/server/tls_runtime.rs +++ b/src/server/tls_runtime.rs @@ -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( peer: SocketAddr, local: SocketAddr, transport: Transport, + tls_sni: Option, h2: bool, extended_connect: bool, h2c_upgrade: bool, @@ -294,6 +299,7 @@ async fn connection( peer, local, transport, + tls_sni.clone(), extended_connect, frontend_state.clone(), lease, @@ -322,7 +328,8 @@ async fn connection( 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( "tcp", "1.1", 400, + authority, + host, None, ); return Ok::<_, Infallible>( @@ -344,7 +353,8 @@ async fn connection( 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( "tcp", "1.1", 400, + authority, + host, None, ); return Ok::<_, Infallible>( @@ -389,6 +401,7 @@ async fn connection( peer, local, Transport { tls: false }, + None, extended_connect, upgrade_state_for_task.clone(), upgrade_lease, @@ -448,7 +461,7 @@ async fn connection( 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"); } diff --git a/src/web_server/fastcgi/protocol.rs b/src/web_server/fastcgi/protocol.rs index 9cb6ce1..3335dc6 100644 --- a/src/web_server/fastcgi/protocol.rs +++ b/src/web_server/fastcgi/protocol.rs @@ -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) diff --git a/src/web_server/response/file.rs b/src/web_server/response/file.rs index d49edba..9e27fa8 100644 --- a/src/web_server/response/file.rs +++ b/src/web_server/response/file.rs @@ -185,7 +185,7 @@ pub(super) fn directory_listing(path: &str, entries: &[DirectoryEntry]) -> Vec{type_label}{label}{slash}{kind}" )); } - html.push_str(r#"
RustProxy 0.26.0Web Server · Directory listing
"#); + html.push_str(r#"
RustProxy 0.26.1Web Server · Directory listing
"#); html.into_bytes() } diff --git a/src/web_server/tests/part_02.rs b/src/web_server/tests/part_02.rs index 5364093..219bd0d 100644 --- a/src/web_server/tests/part_02.rs +++ b/src/web_server/tests/part_02.rs @@ -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 0.26.0")); + assert!(body.contains("RustProxy 0.26.1")); } #[tokio::test]
TimeEventStatusClient IPVhostRequest / reasonSourceTimeEventStatusClient IPVhostRequest / reasonSource