This commit is contained in:
Mateusz Gruszczyński committed 2026-10-05 09:30:21 +02:00
1 parent 1dea7f55c8
commit c819f0a335
149 files changed
+4545 -1199

No files matched your search

+1 -1
View File
@@ -2,7 +2,7 @@
Read README.md, ROADMAP.md and docs/TEST_REPORT.md before making changes.
The project name is RustProxy; the Cargo package/binary remains `rust-proxy` for
compatibility. Current source version: 0.25.4.
compatibility. Current source version: 0.25.5.
## Delivery
+9
View File
@@ -4,6 +4,15 @@
> test-only changes and packaging implementation notes were moved to
> [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md).
## 0.25.5
- Expand Prometheus/Grafana coverage with bounded Traffic analytics for countries, fixed User-Agent families, methods, status classes, cache outcomes, routes and serving source; add explicit built-in Web Server inventory/source metrics while keeping raw IPs, paths and full User-Agent values out of Prometheus labels.
- Add an About dialog in the dashboard top bar with project author, open-source notice and source repository link; rename the top-bar Docs action to Documentation.
- Replace the always-expanded mobile dashboard navigation with a compact on-demand Menu control that closes after section selection, Escape or an outside click; desktop navigation is unchanged.
- Make the Revision History modal use the same square-corner dialog styling as the rest of the dashboard and remove its duplicated modal/backdrop CSS.
- For vhosts using verified Cloudflare client-IP mode, use trusted `CF-IPCountry` for country analytics/logging and country rules, falling back to local GeoIP when the header is absent or invalid.
- Keep SQLite schema 8 and the existing configuration format; no database migration is required from 0.25.4.
## 0.25.4
- Add restore from retained configuration history. An older retained document is validated and saved as a new revision instead of rewinding or overwriting history.
Generated
+1 -1
View File
@@ -1688,7 +1688,7 @@ dependencies = [
[[package]]
name = "rust-proxy"
version = "0.25.4"
version = "0.25.5"
dependencies = [
"anyhow",
"arc-swap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "rust-proxy"
version = "0.25.4"
version = "0.25.5"
edition = "2021"
rust-version = "1.88"
description = "RustProxy - independent reverse proxy with local administration"
+12 -10
View File
@@ -1,9 +1,9 @@
# RustProxy 0.25.4
# RustProxy 0.25.5
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.25.4 release source.**
**0.25.5 release source.**
## Layer-4 streams
@@ -11,13 +11,14 @@ 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.25.4
## What changed in 0.25.5
- Add authenticated restore from retained manual-configuration history. Selecting an older revision validates it through the normal configuration pipeline and publishes it as a new revision; history is never rewound or overwritten.
- Protect restore with the same revision compare-and-swap, administrator session and CSRF controls as ordinary configuration writes, and record `config.restore` with source/new revision metadata in the administration audit.
- Keep SQLite schema 8 and the existing 512-revision retention limit; upgrading from 0.25.3 requires no data migration.
- Align the **Revision history** modal with the shared dashboard dialog styling: square corners, shared backdrop/window rules and no duplicated modal-only appearance declarations.
- When a vhost enables verified Cloudflare client-IP mode, accept `CF-IPCountry` only from a direct Cloudflare edge and use that trusted country for country analytics/log capture and country policy evaluation. Invalid, duplicate, unknown or absent values fall back to the local GeoIP lookup of the verified effective client IP.
- Expand the dedicated Prometheus exporter and bundled Grafana dashboard with bounded Traffic analytics: countries, fixed User-Agent families, methods, status classes, cache outcomes, serving source, configured routes and built-in Web Server inventory/traffic. Raw client IPs, request paths and full User-Agent strings remain excluded from Prometheus labels.
- Keep SQLite schema 8 and all existing configuration formats; upgrading from 0.25.4 requires no data migration.
The 0.25.3 method-policy, location ACL/country, SHA-256 request-ID and revision-history features remain unchanged. See [host access](docs/VHOST_ACCESS.md), [Custom locations](docs/CUSTOM_LOCATIONS.md), [documentation index](docs/README.md) and the [0.25.4 release audit](docs/V0.25.4_AUDIT.md). Open follow-up work is tracked separately in [TODO](docs/TODO.md).
The 0.25.4 retained-history restore behavior remains unchanged. See [host access](docs/VHOST_ACCESS.md), [live logs and Traffic](docs/LIVE_LOGS.md), [documentation index](docs/README.md) and the [0.25.5 release audit](docs/V0.25.5_AUDIT.md). Open follow-up work is tracked separately in [TODO](docs/TODO.md).
## Documentation
@@ -25,7 +26,8 @@ The 0.25.3 method-policy, location ACL/country, SHA-256 request-ID and revision-
- [docs/README.md](docs/README.md) - canonical documentation index.
- [docs/WEB_SERVER.md](docs/WEB_SERVER.md) - Web Server configuration, HTTP behavior and filesystem security.
- [docs/PHP_FPM.md](docs/PHP_FPM.md) - implemented FastCGI/PHP-FPM integration, safety model and runtime requirements.
- [docs/V0.25.4_AUDIT.md](docs/V0.25.4_AUDIT.md) - 0.25.4 configuration-history restore release audit and compiler gate.
- [docs/V0.25.5_AUDIT.md](docs/V0.25.5_AUDIT.md) - 0.25.5 Cloudflare country analytics and Revision History styling release audit.
- [docs/V0.25.4_AUDIT.md](docs/V0.25.4_AUDIT.md) - historical 0.25.4 configuration-history restore release audit and compiler gate.
- [docs/V0.25.3_AUDIT.md](docs/V0.25.3_AUDIT.md) - historical method-policy/location-ACL/request-ID/config-history release audit.
- [docs/V0.25.2_AUDIT.md](docs/V0.25.2_AUDIT.md) - historical 0.25.2 live-backup/symlink/origin recovery audit.
- [docs/V0.25.0_AUDIT.md](docs/V0.25.0_AUDIT.md) - Web Server foundation specification audit.
@@ -93,7 +95,7 @@ CARGO_BUILD_JOBS=1 CARGO_PROFILE_RELEASE_LTO=false \
CARGO_PROFILE_RELEASE_CODEGEN_UNITS=8 cargo build --release --locked
```
The 0.25.4 source archive preserves the resolved `Cargo.lock` supplied with the source tree and
The 0.25.5 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
@@ -332,7 +334,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.25.4.zip
python3 make_zip.py /tmp/RustProxy-0.25.5.zip
```
## New management and test-origin guide
+12
View File
@@ -1,5 +1,17 @@
# RustProxy roadmap
## 0.25.5 - Traffic country consistency and Revision History styling
Current direction: 0.25.4 -> 0.25.5
- [x] Use trusted `CF-IPCountry` when verified Cloudflare client-IP mode is active, with local GeoIP fallback for missing/invalid country headers.
- [x] Keep country analytics, retained country logging and country policy evaluation on the same verified country source.
- [x] Align the Revision History dialog with the shared square-corner modal styling and remove duplicated history-only dialog CSS.
- [x] Add Cloudflare-country trust regression coverage and release/source-contract checks.
- [x] Extend Prometheus with a live Traffic analytics collector, fixed-cardinality User-Agent/method/source families, country/top-route views and built-in Web Server inventory/source metrics.
- [x] Extend the bundled Grafana dashboard with a Traffic vhost filter, Web Server request rate, countries, User-Agent families, serving sources, methods, status classes, cache outcomes, capture-state gauges and configured routes.
- [ ] Run compiler-enabled `cargo fmt --all`, `cargo check --locked --all-targets` and `cargo test --locked --all-targets` on a Rust release host.
## 0.25.4 - Configuration history restore
Current direction: 0.25.3 -> 0.25.4
+63 -61
View File
@@ -1,19 +1,19 @@
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
2ebaea6883711a6e1ba51bd8dbffc0b42423c04b7117057ea257cc6bbe2d1df1 AGENTS.md
a3d46a56da8acdf9d93062b22dc1245a32e02515af0e0ba006513a8ef5a82edf CHANGELOG.md
8a98d49a48ff13ca071e6d772977b047e658a7f3fedb5fee39262a754ddbb5be Cargo.lock
d68edcf9603597063ab8554b1f66017406ba1e3231d991ae108a1247040ff123 Cargo.toml
6c4f8f8b1cb127854ce228dc6f93f7b45405bd156df50a712f3483624551cad9 AGENTS.md
1c7570cbd73a16f2ff581e014825a5f5fc2996c761c79e39f5e5985dfdc183d2 CHANGELOG.md
e0337e1b205915bb9aa1c4e4c5302c582a29e92ce9d57bb94e973413f2a08ce0 Cargo.lock
a3db5cdfac8f8602143f9bb625a1487c79f7524299215c20e16a52e492006d1f Cargo.toml
d6d22ba63f4bc3d16a13012ecaabff8a156be57c824d2f20f66976502fb27d1f Dockerfile
86a633c40f7cfa4122ece46e977f1eda2383a8025b8d34d72e05837a2806d0c1 README.md
13d36f416d7b9aa0e34d5ab3265d5d32c108b05385de32d44a4f03e2688ef860 ROADMAP.md
d85b4765506884e0c03925998d675e10cdd4bed65a93c5525d0e2843b4236d73 README.md
94363612c185da471d32510da269aa54fd77b4bd012352ed169cc3bab399fac5 ROADMAP.md
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
928ba6a504bc431ae100c6e587861e5088b28822d2cb19da5d437b39d26c864a build.rs
6ff0bef85dd8006eb604297ca287ee984286ee84dfad2a5e6f9dbfc29335220f debian/README.md
9e26eb93e7facb4c771021ebc8492224659c9316cc2adaec05eeba63cad95efa debian/build-package.sh
e7064c2b7ac3c86a2e1f9c6c755665ad971588632287646f6e469dd4fca49b44 debian/README.md
5ade138d66129988ac6ef4d4dd4677cb6e1d883bfa82fb0fd4913097f62431c4 debian/build-package.sh
0763a4bd8b429f7922b23a7276ff694f3a751b83fb127f6ad7ec654e5e507ecd debian/build-repository.sh
2076f96789448281f5e7dd1701bd6c0f46b88e002c7f357946f45cae675e2860 debian/changelog
1cb1be86cc5e4de6feef9d4475a346161af710eba0022af2afb6146ec7e94736 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
847962eae2ed508e3f53a8f4610a4137db132e6d2980c93f062b4f61fbb4bcfd dev.sh
9d448d3a24eb529b1846a6bd7cd0a81a1b58a10ccf30c41d7b8db64b8099c727 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
1d59f9a52597c3b1fcf7e861703b333e1cf23d07581fea9bb6cfff4e44ea6d82 docker-agent/model.go
85f490ce949a3457d3cc48c38fc4f5357e069921ba3e64002b507632f862da5e 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
e8d585a496dce2c4fe03698e0203c2ff52a24406975a55978c4316450c974dac docs/API.md
36dcb2f163b865afeb55338c28285ea2dd15a75c062d1c5ecf35b97e06f6ea18 docs/API_ACCESS.md
710087c9d0fb4d466d66098c0a3e6a0bf5c1ec074ea751badb73e3e022626a0c docs/API.md
64f91768d214bc8ea460f39393b4492c4ad8d422cbab1421c0e308b35bb8fdb7 docs/API_ACCESS.md
0f7f159ad5b4b5277f80c19f3c47f4e7823ea439ef02e5913e1391f1b34d129f docs/ARCHITECTURE.md
cd2597465af031cdb47db802f8ad6d3af34ef9e79f679df906a7b84220ea3260 docs/CACHE.md
c25f3f6763c35dacf718b4b87c5665c2030b1fb3584e8c20940fc3ca7a2d26a3 docs/CACHE_DIAGNOSTICS.md
@@ -50,18 +50,18 @@ db5534427ff0f004940fd6960906d3eaa75347a110e6322827c51c5d63f20adb docs/CONTAINER
7db65a96f696aff1d7f74e2041cfc6ecdf437b1412ece90cee26da1ceff4cf7e docs/DOCKER_DISCOVERY.md
9a882f07945b207d032936feb843f29467e4654f8255fa0104d988a54987f982 docs/ERROR_LOGGING.md
df6ecd4a50796640243676ad4b3a87789f6e5d20cbebea6255e92fbfff1c7131 docs/HTTP3.md
c802ed6eae5b9835a01b852ee5df64eac26737e82eed47d680d78286f93b19e9 docs/INSTALL.md
6dee18b438c277dc22c28f4532872ef57be9060aed368dd2437741b36a1a8738 docs/INSTALL.md
3b5c31c805e5aee0dc2100b81246a9b631b5abb996faa598ae94f87ba39305f3 docs/INTEGRATIONS_040.md
2942a5a4310a5a21a75b844daf9e82be406e008a60b103cc0e4f16884df93701 docs/LIVE_LOGS.md
b640a87349e125d1cce1b93caed06aeee09ef5acd0824d0ca1472757b696d477 docs/LIVE_LOGS.md
7c35c4cdbe7bbb81d94f3bf6aeb22b8a866af3e7f0d62bbedc299e4d6f264e0d docs/MANAGEMENT.md
4ee1c176ee014327a66b703939d0875c7417fb5c79c4b722e65cd60ff27c4469 docs/MIGRATION.md
b9d7ca444c894f22b3a13cbd9173c48fcc3e1e2f3c3a8f1f5fb635040101930e 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
44e8b1940af190417574866911800ee5385ddce59c1f13c76dff35f02c134af8 docs/PROMETHEUS.md
7a02c823e5eebe9db65f91d98c8091eb2dc1cdb3c6412c55ad7042f28a790bf2 docs/README.md
d3e3bff7e447356127814a9704706ec11eb94068eb6dc744eb614923dfe742b1 docs/PROMETHEUS.md
0832608a28022bec27b82a6c4abfbfeac7c4bdbe84572e8b3a430cee8d76efa2 docs/README.md
6e46a137b9c88dac8cb9e3fdca877430092392de9913d746278dbff8652460c2 docs/REDIRECTS.md
0dfd0d431c5aedc67abac1ce5de040ffa790ec9578fd7d6f91a987c333d73358 docs/REFERENCES.md
ce5afb4f531bb5fb4c453deb49f643f2a563f32f7908c995e5bc8e0d7de995b7 docs/RENEWAL.md
@@ -69,9 +69,9 @@ ce5afb4f531bb5fb4c453deb49f643f2a563f32f7908c995e5bc8e0d7de995b7 docs/RENEWAL.m
4bf7b3ea0bfe6c6b0ae0b7884b48b10ba29ff3a71611ec96957a3327d3c6ec88 docs/SECRET_STORE_BACKUP.md
a699fd663bfbf001525f22f0fbacdf03a58c5e825232d8d144e51cbb8b1008b2 docs/SECURITY.md
620b69e566d27af04f0604f4ca3f88cf95cdfea7fbc6ae9dd92446d2b2b5d9be docs/SECURITY_AUDIT.md
eb7b00e01f8d143722e25bba36a17311cccfe3a7427feb8c017a3a0c2f890a85 docs/SETTINGS.md
a2be253670072567459010b79f64df6093ccf9614aa249bdaa98917a83caeb31 docs/SETTINGS.md
3611defd206cde107e75cac006159856d12cd8f15df7ae2eb9ec3592ed2b4abd docs/STREAMS.md
67c5965cb0732c671dd5be4cee246e48349114292c32fad3a8851c805c1d8e0d docs/TEST_REPORT.md
a471f700eca3d01e8e7bd477e05b93c57b40c10d169db32c3ed7232002dba327 docs/TEST_REPORT.md
cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md
8ee6187a5460ca45643b7fd789e27bffd264449e3a92ff70e9359edee011bbbb docs/TRANSFORM.md
b02957b708174c9def830ced3c70c943a47af7a33467ef858255d04ab92c4af0 docs/TRANSPORT.md
@@ -98,8 +98,9 @@ d0e609febbcd1cfdb8ea0b00b4fa6f081eac01e2d43a81e38a53ae25783152cc docs/V0.24.8_A
41793fe2d0fa4a2bd88448f5b3870649f08eeaadd4cd420820647d332e7fdb5a docs/V0.25.2_AUDIT.md
9dbb66e408bcd4d32267724e557fa9f7f44ef9b70db4ab170c61a7a839b7b157 docs/V0.25.3_AUDIT.md
583d548b4a5db9f35a2bbf8ec00ec22397764c5850a8e883a3d12680da517df7 docs/V0.25.4_AUDIT.md
bf9b4cf4fec2c8ce287e1b3c2e7d4828e101dafe4d6c173f099819f22493d144 docs/V0.25.5_AUDIT.md
49a0ddd3071a480b331c108b718d497af9c2ec9844b3bfab5c01fbfab64c560d docs/VHOSTS.md
ace2c0dfe9154cbad760fd759efe8f120eb55dd8aa6f10f99933b7556d341f8f docs/VHOST_ACCESS.md
b979afd3d56731eeb06e9dc38d37730693e7fbf3aa78467932842c0e712e7b26 docs/VHOST_ACCESS.md
f0e76167944861517d1d42b9186ff0b2f184be0371b9e8597827e60895de4a76 docs/WEB_SERVER.md
c336f998027330c58d5afbbbb3a3a682fd44ae3997643882fe342633822f8bb3 docs/screenshots/README.md
5502e8ce44276213803b66c58e3a4d804a1354f5ed5d83f47ee805e8c44ab088 examples/acl-headers.json
@@ -112,14 +113,14 @@ b26833cb4251f9390a44192ecc827ff7f052fd19016a5ccf7498c94503ab4ce3 examples/mock-
5a8f3a8a054abc89c6e78896b9fe2f903212199a24dc3d2d2bcbd0696a31bcde examples/transport.json
17e7971fa9d9b141019fba3135d87cb1aa0cbc55d56b0718f879f880b615da26 examples/vhosts-balanced.json
e06905b6363103b1bcee1a09b9e02292033f114d88f3edbea22ddab53e9e5071 examples/vhosts-private.json
529b47c6d4ea44d2a25d1cf9561cb5ee1e05f50a568c6dba5889741bcf936434 grafana/README.md
abf25ef3430fae2bb89c0ae3479b35ba7a6ba973a15c663b311c56c21127e26b grafana/rustproxy-overview.json
64800051e89c3a0028eb17cd4b5ca3c52d4685e75b2dc610251ffeb2170f95fc grafana/README.md
1be61d025a8d922485659cf7fe10b72083378f2155eb50a849c10d3c82845a16 grafana/rustproxy-overview.json
30763fc61c38992a44bc24ad6932b88fd3b322724fce799db1f17f9a747eb59a install-debian13.sh
858b39fa6886b71d20690bf369e76580922364b3cc7f0cf6ba9b040df9cd76e3 install.sh
29cada0936762f26495064d1524a668262ed729b21f20aebddc8b6e5cfea363b langs/README.md
f2724c4c684f7b16e59e022be48d337104b95d9c7bb037bdaf00f8f70faed812 langs/en_US.json
f61e0a008511879fa28f07286d3e5f0bceb46710e7f61ed4404b1c287884518e langs/pl_pl.json
b66fc0f10b1b438e4f4e1c14684f05df58220be0613ef08f490650c0da17add6 make_zip.py
a3ce2c48ee034f037bad2f6cd3392bbc44470b1bd1a1ea3bfae4f8e81ae600de langs/en_US.json
13d376f3a55e5cbf6515f3bfe9543f67435f1f5fb4760ba120175c76b73dc05d langs/pl_pl.json
c0603bb9a0248d6ee1af0fdc3eab745ed7b7251a0b6e499c0e6ed5761f3a52a4 make_zip.py
e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql
20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql
5c62da30fa9db594a4d656c77ae676d3c388072b6a85f91170ec7d911d7cde43 migrations/0004_admin_preferences.sql
@@ -138,11 +139,11 @@ edb6ee1445b01cd6f3a74a3a607f39fb107d411b49df028d2e0f4679e093de31 scripts/acme_p
e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/acme_rfc2136.py
7e524285bfa2e3dbde0391257660635b039f23b0b3807e9510df663f757a5ad8 scripts/admin_audit.py
20e83f8b6347b819d1d1ea3de5c691c36f0816954a4210b0d3f39557fefe1931 scripts/backup.py
b2312413cddfe608e76d25cbcf1efd495906af430a8abc32a706b483c9c120d7 scripts/check_source.py
7ec90c877e6c13324f1ea9bba2a8b8d789efcd4180912cb7fa06af4a6fea3a70 scripts/check_source.py
187ee92c8029cdd59e89b4447cf4d170e856017a2091ceb85ae7c22dd1a79c72 scripts/deploy.py
cf14d73bd73f5b12ec84e8e63428a421fc00beedefdc9837efb3304eef9cc55d scripts/deploy_core.py
028dceb22371cecc4d9b1d2e1ea12947a867a389aaff9fc120c5191bd5fb6ece scripts/deploy_core.py
f014472588e889488976970dbaa95a8745b6907516aa81a7cca21d0f81eaaad3 scripts/deploy_runtime.py
02122574c1b88fd76779354f8552862ca619ec243a6ed9ec53573160f831b914 scripts/docs_check.py
ee71ccb9fe38d5af4dd131daba0e3edc2f333ae4582af98fd94a3d2b56598be6 scripts/docs_check.py
6facb2d65bfab234f09855097d12da8f72669a38ebceb4b1cbd03ab42b8d2469 scripts/langs_check.py
2f846042b98a9148be944fb74a2b023b871ff884693a36847837228613a17679 scripts/memory_contracts.py
2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migrate_npm.py
@@ -152,8 +153,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
88ed7bc34a9d0a8479112295c5c15e8aaca5b4b495c95e896e05a4cfd74bb4d2 scripts/preflight.py
49971b07f0153d8c5afa761418ed22b08d25f174e7b4cc38e3e3d77d4eb21ef4 scripts/release_smoke.py
c7f696782d99e2fc35d3fe5cc4abd406ad043cf0f71aa884648da10a42d4f7ff scripts/preflight.py
3b058247f3a0ee106610eca523e4774d64b4379f09ae08d27ceb03f4e84274f9 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
@@ -165,11 +166,11 @@ ecfe0bb21a7549225a19b0846bb6de8c99cbfbbf5a706d33cf84db2c7146abed scripts/rust_s
1c83054336b3508d7ad72cd259f039394682642439877fe69639c0b76be09154 scripts/security_contracts.py
295b6f2625b1875950ea3eecccd142f49be327afdd00c92641286e6de530ec51 scripts/security_dependency_contracts.py
a65010f828194885ffc7f0f733937957f28e6049cd978bec1380a2b8bfbd0113 scripts/source_module_contracts.py
e535270272a2f8c8e624a349be9e40a5af431edc49b1291ad7d1be5627fad1f5 scripts/swagger_assets.py
d42818a70eeba2ee9f2aa8b887dd0cf7fb8e1bcc52990018fc571454e9474d3a scripts/swagger_assets.py
102b23de19a88a8e60de392fb83aefd3dfb33e90cc97ed1690d9f3d4a33d8866 scripts/test_compression.py
094d4d6680d13418e4ecfc1c4e577642f23ee81247dc1b159b4953a90e644dae scripts/upgrade.py
dc2064e5c1c13b2a1fbc30e8e185ce3ac1e6eba58ab26077fd015ee2d26f7274 scripts/upgrade.py
43db35e7264b0345592172f2d1f6a6e4919fc4cbde2a9ff787acd7fff1d031d6 scripts/web_assets.py
ddeb7ca056e890269ff4ccd07aa4f9fdc41368ed2d63610e707d2bdeaded9979 scripts/web_server_contracts.py
6695c70fe4e64434afe75269136bffce2c30ecf1e2d4c56b6b88b0bf61aaee76 scripts/web_server_contracts.py
b8ce3e8fd08865015ce312fae9c0457612af18d6ef4513a6281b9e3212f8d458 scripts/web_service_ui_test.mjs
d8e2b51c39252dbb4305512fa9b1ef508ec610968aed316b10514fd160298762 src/acl.rs
e8ffed9a9f50a467062d0758aec6aefdc8a39615282b2eef73603d08bc33c00a src/acl/tests.rs
@@ -181,11 +182,11 @@ b9e57db4bd80ef37564dfc533a96897d8a494671d53acea9fb4bf28d23ba370d src/acme/provi
7acbaf34e7b953b753ab074893e47b5024f0cd756dc4c7a1074067dd936c2e8e src/acme/tests.rs
8a07a8c9d845e55ffa47332d4379e8ffdc2cee09b22e6c6396edb8e768b24540 src/admin/access.rs
21e4ae7e72b0c86ba674bc820aabdd201cd62126fdd09ba22cc4b420e38ad300 src/admin/certificates.rs
94b4e595d0056e0bce6e5d5fcda3a0a89a06d41fbf2ebfaa289eb7b81c34e0aa src/admin/documentation.rs
4361b93f5202f20907781679f8898e8935f044e88a113b5bf93eedccca0c9d81 src/admin/documentation.rs
67b901485d5858d5b5bf78a6260035db32241531d99481754c3533fdf404facb src/admin/documentation/tests.rs
cc5c84d8476e2ba11c3a4b1996ca616eb402bb0aea734434ac6e185e1041736e src/admin/logging_api.rs
468b6062cb806ef489bc107f69d4725dbf9c08d7e18d15b4482ae0f309c15d2f src/admin/mod.rs
6de3410a03ad4b7b3722e603ff9f6b101072a02c12e0216c77c07406fa8802a2 src/admin/openapi.json
630e8faea7ed0d8e2a3524b3c52c6ac2b330ca9a59919959e763d69e03f6f54e src/admin/openapi.json
04ac147a823a1b5510977185fc10b4f5e3fddaa76c00ee73ab5209782dd4d859 src/admin/operations.rs
b4fa393b780b2676b5d72e0f90cd7290292f424b8062d4b5983e722d6ca72c32 src/admin/public.rs
f77cdb17512797f3ff216bc423db647a9e0d2854b7cf775c6ffb853d5c3b2adb src/admin/router.rs
@@ -200,9 +201,9 @@ b31d594499d7f8164c14b66dd77971b9985cefd99bd4d3686f9ea26e11e5a59c src/admin/test
5f0062abc42a3b81c79088dc0824ab4c81a22c6ea765e77d2527d07d13bffd58 src/admin/tests/part_05.rs
23c55f23deeec844a9abc2289440c9e0583aaeb72416fc5f3632a43a864c0efd src/admin/web/acme.js
78e7d54d6536cd600dcbe66f0fc06ec109e121dfee1e57f3ca6e61cc6ae04a6a src/admin/web/app/config-history.js
f97188d3c9dcd60b19ff1a18f1e3eb5c942c996fbefe805fdc2ca07fa9785139 src/admin/web/app/core.js
22252759b3bbdacf887616d6c06113cc3f690884ffe958e7cfbd28bfb2d6805f src/admin/web/app/core.js
19abc332a1009bcb087fe4ebb58d458739b27480ab50715b03d07fd408302942 src/admin/web/app/runtime.js
0d0a74e32dc8187962284d80e57df28472f4d51ef66c944f39b7ede2255417b9 src/admin/web/app/settings.js
66cfbcd7c23dd5731dbe541351968a58b0869681073857086dea5a4256e5c6ca 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
@@ -217,16 +218,16 @@ f7d0955ab18c15e04369204e789f341d2e832f927c1ff20a3b2b9e35e7898f00 src/admin/web/
e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/error_504.html
0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg
dee3b113907f77138cdf919efdfb097e45b889cff3893bfc6f02ed998643bbae src/admin/web/host404.js
9db0fb42b9ac129c1017f3d273f6397b99924f087705568f5b48cbbe36d62474 src/admin/web/index.html
91fcc217e73f85b67b58a7c15ec604bd0f57b5c0ff1df2793f956021cbcaa191 src/admin/web/index.html
24db88842707f764064657712d3660c6ac129fbfb47d188c0903925cf8244ce5 src/admin/web/integrations.js
cdab0c11a28deebdf401c0453fbc76b675e9e643722106fb20e812bb9648ccde src/admin/web/login-keys.json
a253d202dc9a147f2add34c64e0f35c38fdc7f667f9908933ce5f959b1a92a2e src/admin/web/login.html
211ebcd166f023ebfbed4b0805fd9942f193d9e2a916864dd634cae997466f3d src/admin/web/login.js
e41619e5cb0ba01a3d287748f28eb38bbe7641f9bedade252d286c18e619c400 src/admin/web/management.js
22318f39f7bb1ae98e65737d3c2ad9e63065b8e94ad59bed560c0e80d3a46949 src/admin/web/redirects.js
811a6b6d7f7c0d0a268113849be021176098097cd33279acb9a9ffc40d2b934f src/admin/web/settings.js
668f5162fa9f8bbe2fefce4115644a6469e01aa7defc9c7f760c28afd8e2afc7 src/admin/web/settings.js
1a30affd6a717095a1763998a5850a79ae0f86b7b694806f2aba2b880735dd1a src/admin/web/streams.js
bb8b1e2ae25b90351388f42ac573abc7c7ee7f865ab087548c84f7d6a162a0f2 src/admin/web/style.css
55be686f59bdf8a462c18912542874fa94661a4fdc3ff70be9d642db27679fa5 src/admin/web/style.css
47e98065281c04b4c185d53df4ed0cb12fabc716e52143e6ae3c3c25db62126a src/admin/web/traffic-panel.js
049be02e0f68c3f6702f3bf29be760c4e5bc1199393ed198107c0a264711300d src/admin/web/transform-panel.js
ac79a0f5471f110850bf5ceaaee9a3903bb92fb2edc10e969d8fda81f66571c0 src/admin/web/vhosts/core.js
@@ -318,8 +319,8 @@ b45d6040810c93ae2acb04ef6b0a59c189751edf9008cd81a4521c9e04e7f07d src/docker.rs
a1eebaf42bd039fd72015adc062a04554f18a26c5aa652008f9d4e17d5558674 src/docker/model.rs
845192f983bbd1fa82500543d2dcb0fdb32b755bbca4c409b05300c6b329c07b src/docker/reconcile.rs
fe571c85a924c827097f4be01b3a827a16bc5dd17a55fbf991ee3ad016641305 src/docker/tests.rs
d90db562b53af317e36d4a02e87fa77b1ef65fd9098f52114f242ea4969876f8 src/forwarding.rs
94bf4a4c5d222fe1d7316f76df2ecc2909e2e0248b642d85a00eb49cc72608c8 src/forwarding/tests.rs
2a02ea2e010cde1b1fe99b2e6baa80ea344ea6b332dbb3c4be795b71541f64a7 src/forwarding.rs
057e652f216a1478638cbb53b1c64ac88c7f413a65825caacbf893c5af004a63 src/forwarding/tests.rs
72215ef6911893f55cc82278169268f03d869b94728f118bf007fc555d609fde src/geoip.rs
e40171e72327bc4350ba95cedacde2f3c9148c26a5a232d9f790f4449f20a685 src/geoip/tests.rs
650818b60f76e1ae1a26c6fbf71d7ba63bc85dcb88754a592e8da87b5938e5af src/headers.rs
@@ -334,11 +335,11 @@ e77598ade4ad6fc27f384bd1ee42005cd745c31c2306ffe03a94be6fbf97eee2 src/logging/fo
8c1f4a05a538daeda2de5a35d073750c910aed808746275afe58cf5a9d2c392d src/logging/tests.rs
3836ed14b6710e183f048ee4d7807a35366c27a27338057fe4a2811129b63302 src/main.rs
79e1d39d7be966c22faad99146e384407427f2b1cec917b73786c5808ecc12f1 src/main/admin_runtime.rs
e67829ea786e19413cf6299a3023c64da993de3c5fc91cefbdf11cd524afd002 src/main/bootstrap.rs
7751340e2931390f8464e04e4d8d874936b2620624ae4e52db17cdc2a21c4c09 src/main/bootstrap.rs
192623ac6d9ef376648d3dc372e7dedc0a37afbaa3dacf737e2e372228de09e5 src/main/proxy_runtime.rs
f58e3b75ed39e85fb4f943b970ea5bb271e709bce0979fd96a25e416387a81d9 src/main/shutdown.rs
a8783a34c01a288822c71b2a6807cc8d7645e22af5766053fed5c429d4536407 src/main/shutdown/tests.rs
71a093c8afef3e0f8dd0a79111e172b6bccd48a0ace5ea133ee978b8325f9117 src/main/tasks.rs
6f61f3fb5e08bd0786c93ba98e1693edd5bcb2585bda2a6bee4dc9ad415b022e src/main/tasks.rs
49d7e104585c8791ac9a203dddd95802784b68c0ba65d6f90f3d57154b4c7871 src/metrics.rs
201ab702722d6d21585fe8bb49939150d61bfac7ed8dc198b98c2cc6098ffb5f src/metrics/global.rs
b1ed4042f2d6c1029523c3977b85e2ab6020e2da7d5079f98051cef5e96ca390 src/metrics/vhost.rs
@@ -347,28 +348,29 @@ a4702c95c7e5077c2ccff86e2e3216e1da6ff9ee4383ae199a0aff2f78db325f src/not_found.
5b3c5f28245df529f3099f16fb4fd93c00e760b1202d4b692c1d1d79601f6144 src/not_found/tests.rs
6aab4f97d72ec6352c940bc89e911b8e50c580821da8202744e9832621458784 src/pages.rs
73a0770726eb90f89fe1a65e0e146c39209120e70d5817fe2e8e63f3c3521c40 src/pages/tests.rs
4c78f7b66e2305bf9ac379270b65ed412fee40da2c30e62097bca21708567d58 src/prometheus.rs
c055093b450c8f4ab81db2338d87a88024f151fdb79b1caa2c00045f460fd279 src/prometheus/core.rs
b40d1404dae711f9d79a777ce3e4459ecebc3601d16067bdb2a017412e703715 src/prometheus/docker_misc.rs
33e409fae3f8e9574ba2776b0d2eb6c75b26f45f48806e307d78b6fa355d8c86 src/prometheus/model.rs
53bb68f43cf66916dcb4f5ffb47c89a63275c589b6a23691bd9ccaf4fdc56332 src/prometheus/tests.rs
4198844bc70c59ca3f8da812928391e2ca3d4eebc84bde432a5724c0c63d414b src/prometheus.rs
32456ef4b45b8f57cafda60c636c9f41b1cc37c1050f45839f60e6adaaa4e1ac src/prometheus/core.rs
000bfc20822a254f0330e595136018991af88529198aa298cbeff83447e421cc src/prometheus/docker_misc.rs
1b614f3a258c8c9c3b7818f42cbc75777370261a388b5fd9ef1546acad2bd1f2 src/prometheus/model.rs
c1b30f843149ab3a3c91c9bcb44c691308234696a1c450cb41b4dceedb2c2eb9 src/prometheus/tests.rs
f59ff86433d2922704e1982b30a36c4b2e8b067386d5dffa945594b1bdf512da src/prometheus/traffic.rs
b3ea198d87f95942a2991c122b4c90e1f0244dd1f592fa276f0df8a43e43ca43 src/prometheus/vhosts_backends.rs
1db8f018421a2c6821bb6c80d36ce2b555eb524011ff418c158fa44767cb028e src/proxy/body.rs
60d6b2049b420934f838394a351cdc731ecafbb6deaadff666af0373f95d97d9 src/proxy/body/tests.rs
a07b344affed0d464d514a19294822db0d93e241888653752a4d131a94c535d9 src/proxy/body.rs
16b9d4db3b20b2ab14c47ac14fcd18d3d8ca77c3ad9a1f37866ebfa60f379862 src/proxy/body/tests.rs
2f04b5530d44827f9fa722ba9f5e80d5f8afdf3af973970afce4a3d540c5fe9c src/proxy/cache_helpers.rs
cfb0910af8cd868bde3f6c9992319e46d5328d6d8f69c50701df0ea12bef2108 src/proxy/forward/cache_lookup.rs
212de22f407dd96800afcf65fd1fc7c40fede1b88f80d04850eb8d1227b08a5b src/proxy/forward/response.rs
ab853f2dd6d870315fc22cbfe865d98cbb9f55c6ff09172e79d78721c797bd9b src/proxy/forward/route_cache.rs
4bc846cb54bb56577fbbb3c9f3d0c1acd023f4d2b559bb3ed03c6e1e25d93390 src/proxy/forward/route_cache.rs
51c620b77004cca26edf387f9650f07d27801e093d32cc0162eaee1d0702c126 src/proxy/forward/success_finish.rs
5b639e63bc0110c93ab166bd22ac5bea5d8fa260e62c41d4bdb25d5ffff129d4 src/proxy/forward/success_prepare.rs
23b6bd26f6175b52c13d2571318fddd12550ba34ee77b87b5ac0e4811f8e2bee src/proxy/forward/upstream.rs
720e61e70e3b595900595090265a2e536efd05337eacd42a904ebba37f5ca390 src/proxy/forward_entry.rs
1ae46baf8b0ffab630f9f67977be67215549a5d8c2a57708d28a2d3076c490d0 src/proxy/forward_entry.rs
10d740d2e41c2b1349553f8f6e8d624fda8085f665fe6b4a84d913cd465c5825 src/proxy/headers.rs
5cb7522ceb9a9f083a15596ac4898559295e38d1138c80099577211de8a868ec src/proxy/headers/tests.rs
f6aef54204b5bb9305dd1367818c7ffd3cb273008404c9fab0317af1d13ba7a6 src/proxy/mod.rs
ac678d8a2c565fc5662d97acf60e3c59850446861103d2fe04719f96295933a8 src/proxy/tests/authority_tests.rs
b8214b1c847d80c4d42390e913832e2a393dd35e0c8f0657d6eb8aea3254c155 src/proxy/types.rs
df161a330667b6409ffd46ba22bf2f9fa9ae61eb060277187af576133ee1e62d src/proxy/upstream_helpers.rs
e0668cc142d7d0b082371a1e3e78c809bbad8a53fbc2aad62a0dff6138bb8e1e src/proxy/upstream_helpers.rs
69f775f6746b4c6d729dc10961dbef54168846f4ce9cf7fe453ab5a2b879c9b8 src/proxy/websocket.rs
5980ffd2f48248abab72ec96ce22da80a023ccd5e14f6bc21e3c85125876f8b0 src/proxy/websocket/handshake.rs
11b1d7291c75c5b41aeb551ae87fb4878f93ba68c87178a9aeff88fd84fe2b6a src/proxy/websocket/tests.rs
@@ -413,8 +415,8 @@ f5af75a2f6b360576393f2fd628144e54c28dbd28cbbc0f9ba59643d12c21c1f src/tls/server
4b546ac47c142286e27c729f24df23810e38fc33d02c3b90837663041e55630c src/tls/tests.rs
e0ab65406911d4b95cb26594df65779a88d9011d5b11adb22c1a28831cda3826 src/tls/tests/acme.rs
9196ec11be2f0107bc86c71e302a10868801709c979d07aa9a1119296803c932 src/tls/types.rs
c1645d0bfc620dd4aa20047fb59ac998357f36741619f1c4e1aa37d3c8be7318 src/traffic.rs
f9ffe22f38c31e4ae4516eb517a0a608a49c0fa21637fac173761e19a9403df8 src/traffic/tests.rs
438506b1743daddcbefe0ebec5a6050d5f9555710446a1578c0a3d326d4b68ac src/traffic.rs
18d8e9f34255ae7dfd7e7d0d5a8d358a9a6cce5356e9c78db52271b9fea22a7f src/traffic/tests.rs
725eefaab8bd8dcc5a8c92248c72b20860ac49ec3890b618c7cb19c3e4da55e5 src/transform/apply.rs
58178c9fcbe2d6f829c1cbba53a72a7afc0920b4b563b6e0c052f0caa7d27784 src/transform/compile.rs
7005259b77047a8afd8e32f7c1eccac5683cfab9beb2cbf8ae6a4923fc321b6c src/transform/css.rs
@@ -434,7 +436,7 @@ b5e505a2c8e38ca5915dd3ea4f0b33a5bf9cc4ef3e0ec20054748bb1820502c3 src/update_che
e4b85f407103f941cc535116b61527be20e5489af0dfce5371fdfc17bb627844 src/upstream.rs
84bb415eac5d6d7965763681339f2608d2c72aa12c7b7f3ae530ff94740a9223 src/upstream/tests.rs
e4c1ff8f53112e7cfad101a266355f7e47b82895480db431662f4cc59a1d9702 src/vhost.rs
5cbd6b00b3c4294f24d59f5c141b392c32806eaf62de429b2da5f1146b918dec src/vhost/model.rs
f5bf21d13cb094706ae42c8b5cb760e12b4cf9c71a559c57ac0e1661b40cc17c src/vhost/model.rs
7127e081d884863b21c06f700b54884f90ec04efdfce835591b930f2d210faf6 src/vhost/tests.rs
f0479524d66bfee7a6eed5eea03601630ba82064fb944902deb9166f90fe17cf src/vhost/validation.rs
8d1eff4b24f01d137414ef5419f75dd281879228adb70409645b11b3c78c20e9 src/web_server/config.rs
@@ -442,7 +444,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
2f389254aad49f67939a85d34a9fee13719ab1c4c577c052921e9feeaa56d203 src/web_server/fastcgi/protocol.rs
a03c662379b52fe31384dde9f76a41e1b982d6232d2fd73c033e18dbd66b6b52 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
@@ -452,14 +454,14 @@ f0479524d66bfee7a6eed5eea03601630ba82064fb944902deb9166f90fe17cf src/vhost/vali
80f946570c14c8915fd21bfdb1a84dfb6a14c633ab6060acb828fab4c7c21daa src/web_server/mod.rs
67ab8bef567452f123c4b9b46a942b39c93f1f765c128edca7ee808ff1c5e304 src/web_server/request.rs
c5cb561b0f276e8082f904b9446fa5885f6b3c0608f25ea2ef425061e9f7f6b4 src/web_server/response.rs
13fe8e7bc524d330742dd17b3a2b2d6538c4c2a393c6ac835dc0ef433834cb3a src/web_server/response/file.rs
1ed4b5bfa67f34bcb109df9d21ef75caab931e15c7bc89c642d8c2fd97292b72 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
4e6b46de6d13a92f35daef8f599acf4842671e5c15f3b859d3699a6eb67e03f7 src/web_server/tests/part_02.rs
b9f6e50669209d68fd69784bbad4b6336b7dc33a8d60c28a241eb57988d8e47f src/web_server/tests/part_02.rs
e54f44f6ca1ed2c41e620de9195d32c4f8bb6936c3881745316a07160f87b4fa src/web_server/tests/part_03.rs
f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json
1725c10331745dc04a07675784d385260da7b14567b8dba3cbae5e891eb5dadf tests/http2.rs
+1 -1
View File
@@ -1,6 +1,6 @@
# Debian packaging
This directory builds the `rustproxy` binary package for RustProxy 0.25.4.
This directory builds the `rustproxy` binary package for RustProxy 0.25.5.
The package installs:
- `/usr/bin/rust-proxy`
+1 -1
View File
@@ -21,6 +21,6 @@ cargo fetch --locked
dpkg-buildpackage -us -uc -b
mkdir -p dist
find .. -maxdepth 1 -type f \( -name 'rustproxy_0.25.4_*.deb' -o -name 'rustproxy_0.25.4_*.changes' -o -name 'rustproxy_0.25.4_*.buildinfo' \) \
find .. -maxdepth 1 -type f \( -name 'rustproxy_0.25.5_*.deb' -o -name 'rustproxy_0.25.5_*.changes' -o -name 'rustproxy_0.25.5_*.buildinfo' \) \
-exec cp -f {} dist/ \;
echo "Packages copied to $ROOT/dist"
+8
View File
@@ -1,3 +1,11 @@
rustproxy (0.25.5) stable; urgency=medium
* Align Revision History modal styling with the shared square-corner dashboard dialogs.
* Use trusted Cloudflare CF-IPCountry for country analytics when Cloudflare client-IP mode is active, with GeoIP fallback.
* Expand Prometheus and Grafana with bounded Traffic analytics, Web Server traffic/source visibility and safe User-Agent family metrics.
-- RustProxy Maintainers <noreply@example.invalid> Mon, 05 Oct 2026 08:23:00 +0200
rustproxy (0.25.4) stable; urgency=medium
* Add vhost/location HTTP method policy and location-level ACL/country controls.
+2 -2
View File
@@ -18,7 +18,7 @@ if [[ $# -gt 0 ]]; then shift; fi
case "$mode" in
help|-h|--help)
cat <<'HELP'
RustProxy 0.25.4
RustProxy 0.25.5
./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.25.4...\n' >&2
printf 'Building RustProxy 0.25.5...\n' >&2
cargo build --locked
BIN="$CARGO_TARGET_DIR/debug/rust-proxy"
case "$mode" in
+1 -1
View File
@@ -1,6 +1,6 @@
package main
const version = "0.25.4"
const version = "0.25.5"
type config struct {
Listen string
+1 -1
View File
@@ -502,7 +502,7 @@ or changed/restart-pending settings return 404; denied peers 403; invalid tokens
401; non-GET 405; exhausted scrape capacity 429. No administrative actions are
available there. Admin `/api/metrics` and `/metrics` remain session-protected
previews on the admin listener, even when the dedicated endpoint is disabled.
See PROMETHEUS.md for the full settings and metric contract. Docker discovery is an independent collector group (`include_docker_vhosts`) and exports only dedicated `rust_proxy_docker_vhost_*` / `rust_proxy_docker_backend_*` families; ordinary vhost/backend families exclude dynamic Docker routes.
See PROMETHEUS.md for the full settings and metric contract. Traffic analytics is an independent collector group (`include_traffic`) that exports bounded country, fixed User-Agent/method/source, status/cache and configured-route aggregates without raw IP/path/full-UA labels. Docker discovery is an independent collector group (`include_docker_vhosts`) and exports only dedicated `rust_proxy_docker_vhost_*` / `rust_proxy_docker_backend_*` families; ordinary vhost/backend families exclude dynamic Docker routes.
## 0.7 renewal and host access
+1 -1
View File
@@ -1,4 +1,4 @@
# Administration API access - 0.25.4
# Administration API access - 0.25.5
## Sign-in boundary
+1 -1
View File
@@ -1,4 +1,4 @@
# Installing and updating RustProxy 0.25.4
# Installing and updating RustProxy 0.25.5
The scripts prepare a Linux/systemd installation. This source archive is **not a
verified production build**: Rust/Cargo, real CA operations, live systemd startup
+1 -1
View File
@@ -19,7 +19,7 @@ The live path is separate from configured file/syslog sinks. Request processing
## User-Agent, country and Traffic analytics
Structured access entries expose `user_agent` and `country` as selectable source fields in addition to the standard entry. User-Agent capture is enabled by default and is bounded before it enters retained logging/analytics state. Country capture is disabled by default because it requires a GeoIP lookup. With country capture disabled, GeoIP is kept off the normal request path unless a configured country access rule or country rate-limit already requires the lookup; in those policy cases the lookup still occurs for enforcement, but the country is not retained in access logs unless capture is enabled.
Structured access entries expose `user_agent` and `country` as selectable source fields in addition to the standard entry. User-Agent capture is enabled by default and is bounded before it enters retained logging/analytics state. Country capture is disabled by default. Normally country resolution uses the local GeoIP database; for a vhost with verified Cloudflare client-IP mode, RustProxy first accepts a valid single `CF-IPCountry` value from a direct Cloudflare edge and falls back to GeoIP of the verified effective client IP. With country capture disabled, country resolution is kept off the normal request path unless a configured country access rule or country rate-limit already requires it; in those policy cases resolution still occurs for enforcement, but the country is not retained in access logs unless capture is enabled.
The authenticated **Traffic** workspace aggregates completed requests per vhost without database writes. The request path uses a bounded non-blocking queue, and high-cardinality dimensions use fixed-capacity approximate top tables. The page shows aggregate requests, 5xx errors, request/response bytes and mean duration plus top countries, User-Agents, effective client IPs, paths, routes, methods, status classes and cache outcomes. Donut charts summarize countries, User-Agents, client IPs, methods, status classes and cache outcomes while the bounded tables retain exact displayed labels/counts from the approximate TOP state. The lists are process-lifetime diagnostics, are not persisted across restart, and may drop analytics samples under overload rather than applying backpressure to proxy traffic. Country rankings are populated only while country capture is enabled. Client-IP rankings use the same verified effective client identity used by ACL/logging after trusted-proxy processing.
+4
View File
@@ -1,5 +1,9 @@
# Deployment and configuration migration history
## 0.25.4 -> 0.25.5
There is no SQLite schema or saved-configuration migration. Existing `cloudflare_client_ip` vhost settings remain opt-in. When enabled and the direct peer is a verified Cloudflare edge, RustProxy now prefers a valid `CF-IPCountry` value for country analytics/logging and country policy evaluation, falling back to the installed GeoIP database when that header is unavailable or invalid.
## 0.25.3 -> 0.25.4
There is no SQLite schema or saved-configuration migration. Schema 8 and the existing 512-row `configuration_history` retention remain unchanged. Version 0.25.4 adds an authenticated restore operation that validates a retained document and saves it as a new revision; existing history is not rewound or overwritten.
+29 -4
View File
@@ -2,8 +2,8 @@
RustProxy exposes Prometheus text exposition 0.0.4 on a dedicated read-only
listener. It is independent from administrator browser sessions and does not
export request paths, query strings, cookies, credentials, backend URLs or ACME
account secrets.
export raw client IPs, request paths, query strings, full User-Agent strings, cookies,
credentials, backend URLs or ACME account secrets.
## Configuration
@@ -18,6 +18,7 @@ account secrets.
"allow_trusted_network_without_token": false,
"include_system": true,
"include_vhosts": true,
"include_traffic": true,
"include_backends": true,
"include_docker_vhosts": true,
"include_cache_domains": true,
@@ -68,6 +69,8 @@ The exporter represents all scalar fields currently sampled by `diagnostics::Vie
| GeoIP | loaded state plus IPv4/IPv6 range counts |
| Cache | hot entries/bytes, global hit ratio and counters, cold entries/bytes and movement/error counters, fill bytes, cold queue bytes/items, active cold read/write bytes, combined transient bytes, configured-domain hot/cold occupancy, hit ratio and decision data |
| Vhosts | desired enable/domain/path/cache settings, per-vhost traffic/status/error/byte counters and cumulative exchange-duration histogram |
| Traffic analytics | independent live collector (`include_traffic`): capture-state gauges, per-vhost request/error/body-byte/duration totals; approximate bounded top countries; fixed User-Agent families; fixed HTTP method families; status classes; cache outcomes; serving source (`web_server`, `reverse_proxy`, `local_response`, `unresolved`); and currently configured route IDs |
| Built-in Web Server | configured Web Service count/info, PHP and directory-listing flags, vhost/route source metadata, plus actual request traffic through the shared vhost counters and Traffic serving-source metrics |
| Redirect/404 hosts | desired enable state, domain/rule counts and local-response status |
| Backends | pool readiness/policy info, bounded backend status, health/drain/backup, active work and configured request-concurrency/queue limits, requests/errors/bytes, weight, separate active/passive failure state, active/passive successes and health-failure totals, unconditional HTTP-5xx/timeout/connect/I/O/slow-response observation counters, DNS/TLS/header-timeout/read-timeout/send-timeout diagnostics, capacity rejection/queue-timeout counters, plus `passive_http_5xx_total`, `passive_timeouts_total`, `passive_connect_errors_total`, `passive_io_errors_total` and `passive_slow_total` aliases, health transitions, last status, current/EWMA latency, last-probe/failure/ejection/recovery timestamps, traffic rate, and retry attempt/success totals for ordinary vhosts |
| Docker vhosts | separate `rust_proxy_docker_vhost_*` and `rust_proxy_docker_backend_*` families: configured agents/assignments/domains, vhost traffic/status/duration plus backend readiness, health, requests/errors/bytes, latency/EWMA, separate active/passive failure state, unconditional observation counters and matching passive-health aliases, transitions and probe results |
@@ -77,8 +80,9 @@ The exporter represents all scalar fields currently sampled by `diagnostics::Vie
| Inventory | listeners/protocol flags, vhost/redirect/404/stream/ACL counts, request capacity and available permits, config revision and restart-required state |
Values unavailable on the running OS are omitted rather than fabricated. Series
labels are bounded by configured IDs/domains, finite states and fixed protocol/status
families. Per-request URLs and arbitrary Host input are never labels.
labels are bounded by configured IDs/domains, finite states, fixed protocol/status/UA/method/source
families, or the finite two-letter country space. Per-request URLs, raw client IPs, full
User-Agent values and arbitrary Host input are never labels.
This is complete for **currently implemented bounded diagnostic views**. It does
not intentionally expose secrets, raw configuration documents, access-log entries
@@ -112,6 +116,27 @@ Vhost rate:
sum by (vhost) (rate(rust_proxy_vhost_requests_total[5m]))
```
Web Server traffic rate:
```promql
sum by (vhost) (rate(rust_proxy_traffic_source_requests_total{source="web_server"}[5m]))
```
Top country snapshot from bounded Traffic analytics:
```promql
sum by (country) (rust_proxy_traffic_country_requests)
```
Country series are populated when **Settings -> Logging -> Capture country** is enabled; User-Agent family series follow **Capture User-Agent**. The exporter never turns those request-path captures on by itself.
Current capture state is exposed as `rust_proxy_traffic_country_capture_enabled` and `rust_proxy_traffic_user_agent_capture_enabled`. Configured-route snapshots use `rust_proxy_traffic_route_requests`; raw URL paths are never exported as labels.
Fixed User-Agent families:
```promql
sum by (family) (rust_proxy_traffic_user_agent_family_requests_total)
```
Vhost p95 exchange lifetime:
```promql
+3 -2
View File
@@ -8,7 +8,8 @@ This is the canonical index for the current project documentation. The authentic
- [ROADMAP](../ROADMAP.md) - planned and completed release scope.
- [CHANGELOG](../CHANGELOG.md) - user/operator-visible changes by release.
- [TODO](TODO.md) - open work only; completed work is not retained as TODO.
- [0.25.4 release audit](V0.25.4_AUDIT.md) - retained configuration restore, CAS/audit behavior and release gates.
- [0.25.5 release audit](V0.25.5_AUDIT.md) - Cloudflare country analytics consistency, Revision History modal styling and release gates.
- [0.25.4 release audit](V0.25.4_AUDIT.md) - historical retained configuration restore, CAS/audit behavior and release gates.
- [0.25.3 release audit](V0.25.3_AUDIT.md) - historical request-method policy, location ACL/country controls, hashed request IDs and configuration-history scope.
- [0.25.2 release audit](V0.25.2_AUDIT.md) - historical live backup, internal symlink and resumed-origin recovery scope.
- [0.25.1 release audit](V0.25.1_AUDIT.md) - historical Traffic/module-split release scope and compiler-regression guards.
@@ -59,7 +60,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.4_AUDIT.md](V0.25.4_AUDIT.md); [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 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.
+2 -2
View File
@@ -339,6 +339,6 @@ place while switching; switching alone never writes configuration. Security is
not a separate sidebar item. Use arrow keys/Home/End in the tab bar.
The Prometheus card controls enabled, bind IP/port, URL path, client CIDRs,
bearer-token environment variable name and optional collectors. Only the variable
name is exported; supply the actual secret to the service separately. See
bearer-token environment variable name and optional collectors, including the bounded
Traffic analytics collector. Only the variable name is exported; supply the actual secret to the service separately. See
PROMETHEUS.md for immediate revocation, restart rules and scrape configuration.
+12 -5
View File
@@ -1,11 +1,18 @@
# Verification report - RustProxy 0.25.4
# Verification report - RustProxy 0.25.5
## Scope
RustProxy 0.25.4 adds restore from the retained manual-configuration history introduced in 0.25.3. Selecting an older retained revision validates that historical document through the normal configuration update pipeline and saves it as the next revision instead of rewinding history. SQLite remains at schema 8 with the existing 512-revision retention limit, so upgrading from 0.25.3 requires no database migration.
RustProxy 0.25.5 is a patch release for dashboard consistency and observability: Revision History used a separate rounded modal style, Cloudflare-backed vhosts resolved Traffic country from local GeoIP without consulting Cloudflare's verified country header, Prometheus did not expose the bounded Traffic dimensions or an explicit built-in Web Server serving-source view, the mobile dashboard rendered the full section list permanently across the top of the page, and the dashboard lacked a compact About surface. The top bar now exposes project About information and uses the full Documentation label. The patch keeps SQLite schema 8 and the existing configuration format.
## Source contracts reviewed
- Revision History now uses the shared dashboard dialog rule with square corners; the dedicated history dialog retains only layout dimensions/overflow and no longer duplicates color, background, border, shadow or backdrop appearance declarations. Calendar/diff sub-panels no longer add local rounded corners.
- Dashboard widths up to 850 px now hide the main section list behind an accessible `aria-expanded` Menu control. Opening shows a two-column section grid; selecting a section, pressing Escape or clicking outside closes it. Desktop navigation keeps the existing sidebar behavior.
- The authenticated top bar exposes an About dialog with author Mateusz Griszczynski, an Open Source notice and the canonical Git repository link; the documentation action is labeled Documentation. The About dialog uses the same square-corner modal styling and adds no new server route.
- Cloudflare client-IP mode still requires the direct transport peer to match the built-in Cloudflare edge CIDRs. After a successful verified `CF-Connecting-IP` override, one valid two-letter `CF-IPCountry` value is normalized and preferred for country policy/logging/Traffic; missing, duplicate, malformed and `XX` values fall back to local GeoIP of the verified effective client IP.
- Prometheus `include_traffic` defaults on and exports bounded per-vhost Traffic totals, country top values, fixed User-Agent families, fixed method families, status classes, cache outcomes, serving source and configured route IDs. Raw effective client IPs, raw request paths and full User-Agent strings remain excluded from exporter labels.
- Traffic serving source is recorded as one fixed value (`web_server`, `reverse_proxy`, `local_response`, `unresolved`) and the exporter adds configured Web Service inventory plus vhost/route source metadata. This makes pure Web Server traffic explicit while preserving the existing shared vhost request/status/byte/duration counters.
- The bundled Grafana dashboard adds a Traffic vhost variable and panels for Web Server request rate, queue drops, countries, User-Agent families, source rates, methods, status classes, cache outcomes, capture-state gauges and configured-route traffic.
- Vhost and location `method_policy` values validate and normalize HTTP methods; host and selected-location policies are cumulative and deny with 405 before upstream work. Allow-list denial includes `Allow`.
- Custom locations can select an `acl_profile_id` and add inline IP ACL plus country access policy. Profile and inline rules are compiled with the route and checked before authentication/backend selection; a location cannot weaken a vhost denial.
- Every observed exchange receives a fresh 64-hex SHA-256 `request_id`, overwrites inbound `X-Request-ID` before forwarding, and reuses the same value for diagnostics/logging. Custom header rules continue to protect `X-Request-ID`.
@@ -37,7 +44,7 @@ RustProxy 0.25.4 adds restore from the retained manual-configuration history int
- Pure Web Server vhosts no longer present backend diagnostics as though an origin pool existed; their Backends summary identifies `Web Server`, while mixed static/proxy vhosts retain backend diagnostics.
- Status payloads expose fixed-cardinality per-vhost metrics independently of backend pools. The Vhosts dashboard uses those counters for Web Server-only request/active/error values, EWMA latency and byte-rate traffic while retaining backend health details for reverse-proxy sources.
- Logging settings are split into Outputs, JSON format and Error log sub-tabs inside one card, matching the Cache subsection layout. `route`, `cache_effective_enabled` and `transform_effective_enabled` are accepted by the same backend validator that saves custom JSON mappings, and the regression test validates then renders those fields.
- Structured logging now exposes `user_agent` and `country`. User-Agent capture defaults on; country capture defaults off and GeoIP resolution is skipped unless capture or an existing country ACL/rate-limit needs it.
- Structured logging exposes `user_agent` and `country`. User-Agent capture defaults on; country capture defaults off. Country resolution is skipped unless capture or an existing country ACL/rate-limit needs it; verified Cloudflare mode prefers trusted `CF-IPCountry`, otherwise local GeoIP is used.
- The authenticated Traffic workspace uses a bounded 2048-sample non-blocking queue, at most 64 vhost buckets and 32-entry approximate top tables for countries, User-Agents, effective client IPs, paths, routes, methods, status classes and cache outcomes. It never writes per-request analytics to SQLite. The Traffic UI is split into Overview, Clients, Requests and Delivery sub-tabs. Countries/methods/status/cache use donut summaries; User-Agent, effective client IP, path, route and request/response byte volume use bounded horizontal bar charts from the same in-memory TOP state.
- Sustained-traffic memory maintenance calls `malloc_trim(0)` every 60 seconds on Linux/glibc to release already-free allocator pages and trims HOT objects only when above the configured waterline and idle past the configured age. It does not periodically reload vhosts, purge active objects or reset bounded upstream keep-alive pools.
- Cache compatibility currently includes vhost configuration, so a cache-affecting vhost save can purge cache during snapshot publication; replacing the compiled snapshot also retires its upstream clients after outstanding references drain. These mechanisms explain why a manual vhost save can visibly reduce RSS without implying that reloads are required for steady-state operation.
@@ -51,11 +58,11 @@ RustProxy 0.25.4 adds restore from the retained manual-configuration history int
## Implemented automated coverage
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. `src/traffic/tests.rs` includes bounded-table and effective-client-IP view 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 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 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. `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 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.
A compiler-enabled external build subsequently exposed one module-privacy error in the new history split (`CONFIG_HISTORY_CAPACITY` was private to `db::history`) and one unused `country_resolved` assignment in the location GeoIP branch. The source package now exposes the capacity to its parent module with `pub(super)`, references it through `history::CONFIG_HISTORY_CAPACITY`, and removes the dead assignment. This workspace still has no Rust/Cargo toolchain, so compiler confirmation of the corrected tree remains a release-host gate.
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. This workspace still has no Rust/Cargo toolchain, so compiler confirmation of the 0.25.5 tree remains a release-host gate.
### Modular source-tree verification
+27
View File
@@ -0,0 +1,27 @@
# RustProxy 0.25.5 release audit
RustProxy 0.25.5 is a patch release for Traffic country consistency behind Cloudflare, broader bounded Prometheus/Grafana observability, dashboard Revision History styling, compact mobile navigation and top-bar project information. SQLite remains at schema 8 and no saved-configuration migration is introduced.
## Implemented changes
- `cloudflare_client_ip` remains opt-in per vhost and still accepts `CF-Connecting-IP` only when the direct transport peer belongs to the built-in Cloudflare edge CIDRs.
- A trusted Cloudflare request may additionally provide one `CF-IPCountry` header. RustProxy accepts only a two-letter ASCII country code, normalizes it to uppercase, rejects duplicates and treats `XX`/non-country values as unresolved.
- When the Cloudflare client-IP override succeeds, the trusted Cloudflare country is preferred for country policy evaluation, retained country logging and bounded Traffic country analytics. Missing or invalid country values fall back to the local GeoIP lookup of the verified effective client IP.
- Requests that do not use verified Cloudflare client-IP mode keep the previous GeoIP behavior. Untrusted clients cannot make `CF-IPCountry` authoritative by sending it directly.
- Revision History now participates in the same shared square-corner `dialog` styling as vhost, logs, audit and certificate dialogs. Its duplicate color/background/border/backdrop/shadow declarations and local rounded corners were removed.
- At dashboard widths up to 850 px, the main navigation is collapsed behind a square-corner Menu control instead of rendering every section across the page header. It expands as a two-column list and closes after section selection, Escape or an outside click; desktop navigation remains unchanged.
- The authenticated dashboard top bar now includes an About dialog identifying author Mateusz Griszczynski, the project as Open Source, and the canonical source repository. The former short Docs action is labeled Documentation; no new public administration route is added.
- Prometheus now has an independent `include_traffic` collector (default on) for bounded Traffic analytics. It exports per-vhost totals plus countries, fixed User-Agent families, fixed HTTP method families, status classes, cache outcomes, configured routes and a fixed serving-source dimension (`web_server`, `reverse_proxy`, `local_response`, `unresolved`).
- Raw client IPs, request paths and full User-Agent strings remain available only to the authenticated Traffic/logging surfaces and are intentionally never emitted as Prometheus labels. Country values are restricted to two-letter uppercase codes; route labels are restricted to currently configured route identifiers.
- Built-in Web Server observability now includes configured service inventory, PHP/directory-listing flags, vhost/route source metadata and request traffic by serving source. Pure Web Server vhosts continue to use the same vhost-wide request/status/byte/duration counters as proxy-backed vhosts.
- The bundled Grafana dashboard adds a Traffic-vhost filter plus Web Server request rate, Traffic queue drops, top countries, User-Agent families, serving-source rates, method rates, status classes, cache outcomes, capture-state gauges and configured-route traffic.
## Regression coverage
- `src/forwarding/tests.rs` checks that Cloudflare country is accepted only from a Cloudflare peer, normalized, rejected for `XX`, malformed values and duplicate headers.
- Source-contract checks verify the 0.25.5 release identity, shared Revision History modal rule, absence of the old history-only backdrop/radius declarations, the Cloudflare country preference/fallback path, collapsed mobile navigation behavior, About dialog metadata/link, and the Documentation top-bar label.
- Traffic aggregation now also keeps fixed User-Agent-family, method-family and serving-source counters for Prometheus while preserving the existing raw bounded top tables used by the authenticated Traffic UI.
## Verification gate
Run the repository checks included with the release archive. Static/source-contract, language, documentation, packaging, shell, JavaScript and Docker Agent checks may run without Rust where supported. A compiler-enabled release host must still run `cargo fmt --all`, `cargo check --locked --all-targets` and `cargo test --locked --all-targets` before production acceptance.
+4 -1
View File
@@ -13,7 +13,10 @@ use built-in HTML pages unless a configured custom error page replaces them.
Cloudflare mode trusts `CF-Connecting-IP` only when the **direct peer** is inside the
compiled Cloudflare edge CIDRs. An arbitrary client sending that header directly does
not become trusted. The verified address becomes the effective client IP for ACLs,
rate limiting, country lookup and forwarded metadata.
rate limiting and forwarded metadata. On the same verified Cloudflare hop, a single
two-letter `CF-IPCountry` value is accepted as the country source for country policy,
logging and Traffic analytics. Missing, duplicate, invalid and Cloudflare `XX` values
fall back to the local GeoIP lookup of that verified effective client IP.
Vhost listener editing is now selection-only. Listener addresses/IPs and transport
settings live under Settings > Listeners; a vhost selects one or more named endpoints.
+8 -2
View File
@@ -4,7 +4,7 @@
RustProxy Prometheus exporter. It covers global traffic, status classes, vhost
latency, backend health/latency, a dedicated Docker-vhost section, hot/cold/transient
cache memory, Linux process and cgroup diagnostics, HTTP/2/HTTP/3/TLS/WebSocket
counters, Layer-4 streams, structured-log sink health and configuration inventory.
counters, Layer-4 streams, structured-log sink health, configuration inventory and bounded Traffic analytics.
The **Docker vhosts** row uses only the dedicated `rust_proxy_docker_*` metric
families and includes configured/enabled counts, request/error rates, p95 exchange
@@ -12,8 +12,14 @@ latency, backend health/EWMA latency/active work, traffic and probe age. Dashboa
variables **Docker agent** and **Docker service** filter only this section, which keeps
multi-agent installations readable without changing the ordinary vhost panels.
The **Traffic analytics and Web Server** row uses the `rust_proxy_traffic_*` families.
The **Traffic vhost** variable filters that row and exposes Web Server request rate,
serving-source rate, top countries, fixed User-Agent families, HTTP methods, status
classes, cache outcomes, capture-state gauges and configured-route traffic. Raw IP addresses, request paths and full User-Agent strings
are intentionally absent from Prometheus labels.
Import the JSON in Grafana and select a Prometheus datasource when prompted. The
dashboard expects RustProxy 0.18.3 metric names and a scrape interval around 15s.
dashboard expects RustProxy 0.25.5 metric names and a scrape interval around 15s.
Collectors can be disabled in RustProxy settings; panels for disabled collectors
will simply show no series.
+525 -3
View File
@@ -2843,6 +2843,12 @@
"expr": "rust_proxy_acl_profiles_configured",
"legendFormat": "ACL profiles",
"range": true
},
{
"refId": "G",
"expr": "rust_proxy_web_services_configured",
"legendFormat": "web services",
"range": true
}
]
},
@@ -3589,6 +3595,493 @@
],
"title": "Certificate renewal status",
"type": "timeseries"
},
{
"id": 72,
"type": "row",
"title": "Traffic analytics and Web Server",
"collapsed": false,
"gridPos": {
"h": 1,
"w": 24,
"x": 0,
"y": 167
},
"panels": []
},
{
"id": 73,
"type": "stat",
"title": "Web Server request rate",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"h": 7,
"w": 6,
"x": 0,
"y": 168
},
"fieldConfig": {
"defaults": {
"unit": "reqps",
"custom": {}
},
"overrides": []
},
"options": {
"reduceOptions": {
"values": false,
"calcs": [
"lastNotNull"
],
"fields": ""
},
"orientation": "auto",
"textMode": "auto",
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto"
},
"targets": [
{
"refId": "A",
"expr": "sum(rate(rust_proxy_traffic_source_requests_total{vhost=~\"$traffic_vhost\",source=\"web_server\"}[$__rate_interval]))",
"legendFormat": "web server",
"range": true
}
]
},
{
"id": 74,
"type": "stat",
"title": "Traffic samples dropped",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"h": 7,
"w": 6,
"x": 6,
"y": 168
},
"fieldConfig": {
"defaults": {
"unit": "short",
"custom": {}
},
"overrides": []
},
"options": {
"reduceOptions": {
"values": false,
"calcs": [
"lastNotNull"
],
"fields": ""
},
"orientation": "auto",
"textMode": "auto",
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto"
},
"targets": [
{
"refId": "A",
"expr": "rust_proxy_traffic_queue_dropped_total",
"legendFormat": "dropped",
"range": true
}
]
},
{
"id": 75,
"type": "bargauge",
"title": "Top countries",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"h": 7,
"w": 6,
"x": 12,
"y": 168
},
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0,
"custom": {}
},
"overrides": []
},
"options": {
"orientation": "horizontal",
"displayMode": "basic",
"showUnfilled": true,
"reduceOptions": {
"values": false,
"calcs": [
"lastNotNull"
],
"fields": ""
}
},
"targets": [
{
"refId": "A",
"expr": "sum by (country) (rust_proxy_traffic_country_requests{vhost=~\"$traffic_vhost\"})",
"legendFormat": "{{country}}",
"range": false,
"instant": true
}
],
"description": "Bounded Traffic country analytics. Requires country capture in Settings -> Logging; verified Cloudflare uses trusted CF-IPCountry with GeoIP fallback."
},
{
"id": 76,
"type": "bargauge",
"title": "User-Agent families",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"h": 7,
"w": 6,
"x": 18,
"y": 168
},
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0,
"custom": {}
},
"overrides": []
},
"options": {
"orientation": "horizontal",
"displayMode": "basic",
"showUnfilled": true,
"reduceOptions": {
"values": false,
"calcs": [
"lastNotNull"
],
"fields": ""
}
},
"targets": [
{
"refId": "A",
"expr": "sum by (family) (rust_proxy_traffic_user_agent_family_requests_total{vhost=~\"$traffic_vhost\"})",
"legendFormat": "{{family}}",
"range": false,
"instant": true
}
],
"description": "Fixed-cardinality User-Agent families. Full raw User-Agent values are intentionally not exported to Prometheus."
},
{
"id": 77,
"type": "timeseries",
"title": "Requests by serving source",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"h": 7,
"w": 12,
"x": 0,
"y": 175
},
"fieldConfig": {
"defaults": {
"unit": "reqps",
"custom": {
"drawStyle": "line",
"lineInterpolation": "linear",
"lineWidth": 1,
"fillOpacity": 10,
"showPoints": "never",
"spanNulls": false,
"axisPlacement": "auto"
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"calcs": []
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"refId": "A",
"expr": "sum by (source) (rate(rust_proxy_traffic_source_requests_total{vhost=~\"$traffic_vhost\"}[$__rate_interval]))",
"legendFormat": "{{source}}",
"range": true
}
]
},
{
"id": 78,
"type": "timeseries",
"title": "Requests by HTTP method",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"h": 7,
"w": 12,
"x": 12,
"y": 175
},
"fieldConfig": {
"defaults": {
"unit": "reqps",
"custom": {
"drawStyle": "line",
"lineInterpolation": "linear",
"lineWidth": 1,
"fillOpacity": 10,
"showPoints": "never",
"spanNulls": false,
"axisPlacement": "auto"
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"calcs": []
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"refId": "A",
"expr": "sum by (method) (rate(rust_proxy_traffic_method_requests_total{vhost=~\"$traffic_vhost\"}[$__rate_interval]))",
"legendFormat": "{{method}}",
"range": true
}
]
},
{
"id": 79,
"type": "timeseries",
"title": "Traffic status classes",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"h": 7,
"w": 12,
"x": 0,
"y": 182
},
"fieldConfig": {
"defaults": {
"unit": "reqps",
"custom": {
"drawStyle": "line",
"lineInterpolation": "linear",
"lineWidth": 1,
"fillOpacity": 10,
"showPoints": "never",
"spanNulls": false,
"axisPlacement": "auto"
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"calcs": []
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"refId": "A",
"expr": "sum by (class) (rate(rust_proxy_traffic_status_class_requests_total{vhost=~\"$traffic_vhost\"}[$__rate_interval]))",
"legendFormat": "{{class}}",
"range": true
}
]
},
{
"id": 80,
"type": "timeseries",
"title": "Traffic cache outcomes",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"h": 7,
"w": 12,
"x": 12,
"y": 182
},
"fieldConfig": {
"defaults": {
"unit": "reqps",
"custom": {
"drawStyle": "line",
"lineInterpolation": "linear",
"lineWidth": 1,
"fillOpacity": 10,
"showPoints": "never",
"spanNulls": false,
"axisPlacement": "auto"
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom",
"calcs": []
},
"tooltip": {
"mode": "multi",
"sort": "none"
}
},
"targets": [
{
"refId": "A",
"expr": "sum by (cache) (rate(rust_proxy_traffic_cache_requests_total{vhost=~\"$traffic_vhost\"}[$__rate_interval]))",
"legendFormat": "{{cache}}",
"range": true
}
]
},
{
"id": 81,
"type": "stat",
"title": "Traffic capture state",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"h": 5,
"w": 12,
"x": 0,
"y": 189
},
"fieldConfig": {
"defaults": {
"unit": "short",
"custom": {},
"min": 0,
"max": 1
},
"overrides": []
},
"options": {
"reduceOptions": {
"values": false,
"calcs": [
"lastNotNull"
],
"fields": ""
},
"orientation": "auto",
"textMode": "auto",
"colorMode": "value",
"graphMode": "area",
"justifyMode": "auto"
},
"targets": [
{
"expr": "rust_proxy_traffic_country_capture_enabled",
"legendFormat": "country capture",
"refId": "A",
"range": false,
"instant": true
},
{
"expr": "rust_proxy_traffic_user_agent_capture_enabled",
"legendFormat": "User-Agent capture",
"refId": "B",
"range": false,
"instant": true
}
],
"description": "Shows whether Traffic country and User-Agent capture are enabled. Country/UA dimension series are populated only when the matching capture setting is enabled."
},
{
"id": 82,
"type": "bargauge",
"title": "Top configured routes",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"gridPos": {
"h": 5,
"w": 12,
"x": 12,
"y": 189
},
"fieldConfig": {
"defaults": {
"unit": "short",
"min": 0,
"custom": {}
},
"overrides": []
},
"options": {
"orientation": "horizontal",
"displayMode": "basic",
"showUnfilled": true,
"reduceOptions": {
"values": false,
"calcs": [
"lastNotNull"
],
"fields": ""
}
},
"targets": [
{
"expr": "sum by (route) (rust_proxy_traffic_route_requests{vhost=~\"$traffic_vhost\"})",
"legendFormat": "{{route}}",
"refId": "A",
"range": false,
"instant": true
}
],
"description": "Approximate bounded Traffic request counts by currently configured route ID. Raw request paths are intentionally not Prometheus labels."
}
],
"refresh": "15s",
@@ -3608,6 +4101,34 @@
"hide": 0,
"refresh": 1
},
{
"name": "traffic_vhost",
"label": "Traffic vhost",
"type": "query",
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"query": {
"query": "label_values(rust_proxy_traffic_requests_total, vhost)",
"refId": "StandardVariableQuery"
},
"definition": "label_values(rust_proxy_traffic_requests_total, vhost)",
"current": {
"selected": false,
"text": "All",
"value": "$__all"
},
"hide": 0,
"includeAll": true,
"allValue": ".*",
"multi": true,
"options": [],
"refresh": 1,
"regex": "",
"skipUrlSync": false,
"sort": 1
},
{
"name": "docker_agent",
"label": "Docker agent",
@@ -3672,8 +4193,9 @@
},
"timepicker": {},
"timezone": "browser",
"title": "RustProxy 0.25.4 - Operations",
"title": "RustProxy 0.25.5 - Operations",
"uid": "rustproxy-0170",
"version": 2,
"weekStart": ""
"version": 4,
"weekStart": "",
"description": "RustProxy 0.25.5 operations dashboard including bounded Traffic analytics, country and User-Agent-family visibility, and built-in Web Server traffic."
}
+10 -2
View File
@@ -7,6 +7,7 @@
},
"translations": {
"overview": "Overview",
"mainMenu": "Menu",
"routing": "Legacy routes",
"configuration": "Configuration",
"manualConfig": "Manual config",
@@ -813,7 +814,7 @@
"locationPoolEligible": "Include in the default load-balancing pool",
"locationId": "Location ID",
"vhIpAccess": "Vhost IP access list",
"apiDocs": "Docs",
"apiDocs": "Documentation",
"auth.invalid_request": "Invalid request. Check the supplied fields.",
"locationProxyHeaders": "Proxy request headers (to backend)",
"locationResponseHeaders": "Response headers (to client)",
@@ -1946,6 +1947,13 @@
"configHistoryFailed": "Could not load revision history.",
"configHistoryRestoreAction": "Restore revision",
"configHistoryRestoreConfirm": "Restore revision {revision}? It will be validated and saved as a new revision after current revision {current}. The existing history will be preserved.",
"configHistoryRestored": "Revision {revision} restored as new revision {new_revision}."
"configHistoryRestored": "Revision {revision} restored as new revision {new_revision}.",
"about": "About",
"aboutTitle": "About RustProxy",
"aboutAuthor": "Author",
"aboutLicense": "License",
"aboutLicenseValue": "Open Source",
"aboutOpenSourceText": "RustProxy is an open-source project. Its source code is publicly available for inspection, development and community contribution.",
"aboutRepository": "Source repository"
}
}
+10 -2
View File
@@ -7,6 +7,7 @@
},
"translations": {
"overview": "Przegląd",
"mainMenu": "Menu",
"routing": "Starsze trasy",
"configuration": "Konfiguracja",
"manualConfig": "Konfiguracja ręczna",
@@ -813,7 +814,7 @@
"locationPoolEligible": "Uwzględnij w domyślnej puli backendów",
"locationId": "Identyfikator lokalizacji",
"vhIpAccess": "Dostęp do vhosta według IP",
"apiDocs": "Docs",
"apiDocs": "Dokumentacja",
"auth.invalid_request": "Nieprawidłowe żądanie. Sprawdź podane pola.",
"locationProxyHeaders": "Nagłówki żądania proxy (do backendu)",
"locationResponseHeaders": "Nagłówki odpowiedzi (do klienta)",
@@ -1946,6 +1947,13 @@
"configHistoryFailed": "Nie udało się wczytać historii rewizji.",
"configHistoryRestoreAction": "Przywróć rewizję",
"configHistoryRestoreConfirm": "Przywrócić rewizję {revision}? Zostanie zwalidowana i zapisana jako nowa rewizja po bieżącej rewizji {current}. Dotychczasowa historia zostanie zachowana.",
"configHistoryRestored": "Rewizja {revision} została przywrócona jako nowa rewizja {new_revision}."
"configHistoryRestored": "Rewizja {revision} została przywrócona jako nowa rewizja {new_revision}.",
"about": "O projekcie",
"aboutTitle": "O RustProxy",
"aboutAuthor": "Autor",
"aboutLicense": "Licencja",
"aboutLicenseValue": "Open Source",
"aboutOpenSourceText": "RustProxy jest projektem open source. Kod źródłowy jest publicznie dostępny do wglądu, rozwoju i współtworzenia.",
"aboutRepository": "Repozytorium źródłowe"
}
}
+1 -1
View File
@@ -88,7 +88,7 @@ def add_file(archive: zipfile.ZipFile, rel: Path, data: bytes, timestamp: tuple[
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('output', nargs='?', type=Path, default=ROOT.parent / 'RustProxy-0.25.4.zip')
parser.add_argument('output', nargs='?', type=Path, default=ROOT.parent / 'RustProxy-0.25.5.zip')
output = parser.parse_args().output.resolve()
paths = files()
checksum = ROOT / 'SHA256SUMS'
+37 -13
View File
@@ -24,7 +24,7 @@ def read_docker_agent_source() -> str:
preflight(root)
manifest = tomllib.loads((root / "Cargo.toml").read_text())
assert manifest["package"]["version"] == "0.25.4"
assert manifest["package"]["version"] == "0.25.5"
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.25.4"' in lock
assert 'name = "rust-proxy"\nversion = "0.25.5"' 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.25.4",
"CHANGELOG.md": "## 0.25.4",
"ROADMAP.md": "## 0.25.4",
"AGENTS.md": "Current source version: 0.25.4.",
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.25.4",
"README.md": "# RustProxy 0.25.5",
"CHANGELOG.md": "## 0.25.5",
"ROADMAP.md": "## 0.25.5",
"AGENTS.md": "Current source version: 0.25.5.",
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.25.5",
}.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"
@@ -134,6 +134,13 @@ assert "COPY . ." in dockerfile
assert "data" in (root / ".dockerignore").read_text().splitlines()
css = (root / "src/admin/web/style.css").read_text()
assert ".logging-grid fieldset" not in css, "logging sinks must reuse shared card styling"
assert "dialog.config-history-dialog {" in css, "Revision History must use the shared dialog appearance rule"
assert ".config-history-dialog::backdrop" not in css, "Revision History must not duplicate the shared dialog backdrop"
history_dialog_css = re.search(r"^\.config-history-dialog\s*\{([^}]*)\}", css, re.M).group(1)
assert "border-radius:" not in history_dialog_css, "Revision History dialog must stay square-cornered"
for selector in ["config-history-month", "config-history-day", "config-history-change"]:
block = re.search(rf"^\.{selector}\s*\{{([^}}]*)\}}", css, re.M).group(1)
assert "border-radius:" not in block, f"{selector} must stay square-cornered"
assert ".vhost-chart-line" in css and ".host-chip-editor" in css
assert 'class="vhost-dialog logging-format-dialog"' in html
assert 'data-panel="streams"' in html and 'id="panel-streams"' in html
@@ -224,7 +231,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.25.4"', '"/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.25.5"', '"/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
@@ -648,13 +655,13 @@ diagnostics_source = rust_source(root, 'src/diagnostics.rs') + rust_source(root,
for marker in ['process_rss_anon_bytes', 'process_rss_file_bytes', 'process_rss_shmem_bytes', 'process_data_bytes', 'process_swap_bytes', 'RssAnon', 'RssFile', 'RssShmem', 'VmData', 'VmSwap']:
assert marker in diagnostics_source, marker
prometheus_source = rust_source(root, 'src/prometheus.rs')
for marker in ['allow_trusted_network_without_token', 'include_docker_vhosts', 'include_streams', 'include_logging', 'rust_proxy_docker_vhost_{name}', 'rust_proxy_docker_backend_{name}', 'rust_proxy_geoip_loaded', 'not_after_timestamp_seconds', 'ewma_latency_seconds', 'passive_failures', 'active_health_failures_total', 'passive_health_failures_total', 'passive_http_5xx_total', 'passive_timeouts_total', 'passive_connect_errors_total', 'passive_io_errors_total', 'passive_slow_total', 'http_5xx_total', 'timeouts_total', 'connect_errors_total', 'io_errors_total', 'slow_responses_total', 'rust_proxy_log_sink_queue_dropped_total', 'rust_proxy_not_found_hosts_configured']:
for marker in ['allow_trusted_network_without_token', 'include_traffic', 'include_docker_vhosts', 'include_streams', 'include_logging', 'rust_proxy_docker_vhost_{name}', 'rust_proxy_docker_backend_{name}', 'rust_proxy_geoip_loaded', 'not_after_timestamp_seconds', 'ewma_latency_seconds', 'passive_failures', 'active_health_failures_total', 'passive_health_failures_total', 'passive_http_5xx_total', 'passive_timeouts_total', 'passive_connect_errors_total', 'passive_io_errors_total', 'passive_slow_total', 'http_5xx_total', 'timeouts_total', 'connect_errors_total', 'io_errors_total', 'slow_responses_total', 'rust_proxy_log_sink_queue_dropped_total', 'rust_proxy_not_found_hosts_configured', 'rust_proxy_traffic_country_requests', 'rust_proxy_traffic_user_agent_family_requests_total', 'rust_proxy_traffic_source_requests_total', 'rust_proxy_traffic_country_capture_enabled', 'rust_proxy_traffic_user_agent_capture_enabled', 'rust_proxy_traffic_route_requests', 'rust_proxy_web_services_configured', 'rust_proxy_web_service_info', 'rust_proxy_vhost_source_info']:
assert marker in prometheus_source, marker
assert 'if docker_targets.contains_key(&pool.id)' in prometheus_source
assert 'if docker_targets.contains_key(id)' in prometheus_source
assert 'rust_proxy_docker_vhost_exchange_duration_seconds' in prometheus_source
settings_source = (root / 'src/admin/web/settings.js').read_text()
assert 'prom-trusted-no-token' in settings_source and 'include_docker_vhosts' in settings_source and 'include_streams' in settings_source and 'include_logging' in settings_source
assert 'prom-trusted-no-token' in settings_source and 'include_traffic' in settings_source and 'prom-traffic' in settings_source and 'include_docker_vhosts' in settings_source and 'include_streams' in settings_source and 'include_logging' in settings_source
for marker in ['cache-memory-breakdown', 'cache-transient-breakdown', 'cache-cold-storage', 'cache-transient-sparkline', 'cache-fill-sparkline', 'cache-queue-sparkline', 'cache-io-sparkline', 'cache-cold-sparkline', 'metric-info', 'process-rss-total', 'memory-cache-hot', 'memory-cache-transient', 'memory-transform', 'memory-transform-inflight', 'memory-non-cache-rss', 'process-rss-anon', 'process-rss-file', 'process-data-swap', 'memoryAccountingHint']:
assert marker in html, marker
for marker in ['validate_h3', 'H3Tunnel', 'h3_client_to_backend', 'backend_to_h3', 'rfc9220_bootstrap_uses_extended_connect_without_h1_proof_headers']:
@@ -713,8 +720,12 @@ for marker in ['dpkg-scanpackages', 'apt-ftparchive', 'Packages.gz', 'RUSTPROXY_
grafana = json.loads((root / 'grafana/rustproxy-overview.json').read_text())
assert grafana['uid'] == 'rustproxy-0170' and grafana['schemaVersion'] >= 39
expressions = '\n'.join(target.get('expr','') for panel in grafana['panels'] for target in panel.get('targets',[]))
for marker in ['rust_proxy_process_rss_bytes', 'rust_proxy_cache_transient_bytes', 'rust_proxy_backend_latency_seconds', 'rust_proxy_stream_active', 'rust_proxy_log_sink_queue_dropped_total']:
for marker in ['rust_proxy_process_rss_bytes', 'rust_proxy_cache_transient_bytes', 'rust_proxy_backend_latency_seconds', 'rust_proxy_stream_active', 'rust_proxy_log_sink_queue_dropped_total', 'rust_proxy_traffic_source_requests_total', 'rust_proxy_traffic_country_requests', 'rust_proxy_traffic_user_agent_family_requests_total', 'rust_proxy_traffic_country_capture_enabled', 'rust_proxy_traffic_user_agent_capture_enabled', 'rust_proxy_traffic_route_requests']:
assert marker in expressions, marker
assert any(variable.get('name') == 'traffic_vhost' for variable in grafana['templating']['list'])
assert any(panel.get('title') == 'Traffic analytics and Web Server' for panel in grafana['panels'])
assert any(panel.get('title') == 'Traffic capture state' for panel in grafana['panels'])
assert any(panel.get('title') == 'Top configured routes' for panel in grafana['panels'])
# 0.19.0 compression/protocol/dashboard UX regressions.
compression_source = rust_source(root, "src/compression.rs")
metrics_source = rust_source(root, "src/metrics.rs")
@@ -844,6 +855,12 @@ for marker in ['/api/traffic', 'trafficTopCountries', 'trafficTopUserAgents', 't
traffic_source = rust_source(root, "src/traffic.rs")
for marker in ['QUEUE_CAPACITY: usize = 2048', 'TOP_CAPACITY: usize = 32', 'MAX_VHOSTS: usize = 64', 'self.tx.try_send(sample)', 'client_ips: TopTable']:
assert marker in traffic_source, marker
forwarding_source = rust_source(root, "src/forwarding.rs")
for marker in ['pub fn cloudflare_country', 'headers.get_all("cf-ipcountry")', 'country != "XX"']:
assert marker in forwarding_source, marker
forward_entry_source = rust_source(root, "src/proxy/forward_entry.rs")
for marker in ['cloudflare_country = crate::forwarding::cloudflare_country', '.or_else(|| state.geoip.country(context.client_ip))']:
assert marker in forward_entry_source, marker
assert 'if (envelope) render();' in (root / "src/admin/web/web-server.js").read_text()
assert 'pub capture_user_agent: bool' in logging_source and 'pub capture_country: bool' in logging_source
assert 'spawn_blocking(crate::diagnostics::trim_allocator)' in rust_source(root, 'src/runtime.rs')
@@ -862,6 +879,13 @@ assert 'classList.toggle("error", Boolean(value))' in login_js
assert 'class="auth-page auth-loading"' in (root / 'src/admin/web/login.html').read_text() and 'const reveal = () => document.body.classList.remove("auth-loading")' in login_js
assert '"/docker-vhosts"' in login_js and 'settingsTabs = new Set' in login_js and '`/settings?tab=${encodeURIComponent(tab)}`' in login_js
assert 'class="app-loading"' in html and 'document.documentElement.classList.remove("app-loading")' in app_js
assert 'id="mobile-menu-toggle"' in html and 'id="main-navigation"' in html and 'aria-controls="main-navigation"' in html
assert '.sidebar.mobile-nav-open .main-navigation' in style_source and 'display: none;' in style_source and 'grid-template-columns: repeat(2, minmax(0, 1fr))' in style_source
assert 'function setMobileMenu(open)' in app_js and 'setPanel(button.dataset.panel); setMobileMenu(false);' in app_js and 'event.key === "Escape"' in app_js
assert 'id="about-open"' in html and 'id="about-dialog"' in html and 'Mateusz Griszczynski' in html
assert 'https://git.linuxiarz.pl/gru/RustProxy' in html and 'data-i18n="documentation" id="api-docs"' in html
assert '$("about-open").addEventListener("click"' in app_js and '$("about-close").addEventListener("click"' in app_js
assert '.about-dialog {' in style_source and '.about-meta {' in style_source and '.top-actions {\n width: 100%;\n flex-wrap: wrap;' in style_source
assert 'function withFormSubmitState(form, submitter, task)' in app_js and 'button.textContent = t("saving")' in app_js
assert 'function fieldError(target, message)' in app_js and 'function showFieldError(error, form = null)' in app_js
assert 'function authReturnDestination()' in app_js and 'pendingAuthDestination || authReturnDestination()' in app_js
@@ -1168,7 +1192,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.25.4"' in openapi
assert '"RuntimeInformation"' in openapi and '"version": "0.25.5"' in openapi
# 0.24.1 compiler and hostname-cardinality regressions.
tls_0241 = rust_source(root, "src/tls.rs")
@@ -1213,5 +1237,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.25.4 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.25.5 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, Debian/Grafana/Prometheus/404-host/custom-location/path/cache/dashboard/Nagios UI/source contracts, stopped legacy-renewal removal, session/admin/transport contracts, deterministic package source and embedded SQLite schema-v8 migrations.")
print("NOT VERIFIED: Rust parsing, types, compilation, linking or runtime behavior. Run ./dev.sh test.")
+1 -1
View File
@@ -97,7 +97,7 @@ def digest(path: Path) -> str:
def fetch_https(url: str, max_bytes: int) -> bytes:
if urllib.parse.urlparse(url).scheme != 'https':
raise ValueError('GeoIP downloads require HTTPS.')
request = urllib.request.Request(url, headers={'User-Agent': 'RustProxy-installer/0.25.4'})
request = urllib.request.Request(url, headers={'User-Agent': 'RustProxy-installer/0.25.5'})
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.')
+6 -5
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Rust-independent documentation/Reader consistency checks for the 0.25.4 release."""
"""Rust-independent documentation/Reader consistency checks for the 0.25.5 release."""
from pathlib import Path
import re
from rust_source import rust_source
@@ -14,6 +14,7 @@ required = [
DOCS / "README.md",
DOCS / "WEB_SERVER.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",
@@ -27,7 +28,7 @@ for path in required:
assert path.is_file(), f"missing required documentation: {path.relative_to(ROOT)}"
index = (DOCS / "README.md").read_text(encoding="utf-8")
for marker in ["Functional documentation", "Development and testing", "Technical documentation", "Historical or version-labelled context", "TODO.md", "V0.25.4_AUDIT.md", "V0.25.3_AUDIT.md", "V0.25.2_AUDIT.md", "V0.25.1_AUDIT.md", "V0.25.0_AUDIT.md", "CHANGELOG_TECHNICAL_ARCHIVE.md", "ROADMAP_HISTORY.md", "/docs"]:
for marker in ["Functional documentation", "Development and testing", "Technical documentation", "Historical or version-labelled context", "TODO.md", "V0.25.5_AUDIT.md", "V0.25.4_AUDIT.md", "V0.25.3_AUDIT.md", "V0.25.2_AUDIT.md", "V0.25.1_AUDIT.md", "V0.25.0_AUDIT.md", "CHANGELOG_TECHNICAL_ARCHIVE.md", "ROADMAP_HISTORY.md", "/docs"]:
assert marker in index, marker
for document in sorted(DOCS.glob("*.md")):
if document.name == "README.md" or (document.name.startswith("V") and document.name.endswith("_AUDIT.md")):
@@ -54,13 +55,13 @@ 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.25.4")
assert readme.startswith("# RustProxy 0.25.5")
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.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.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") and "## 0.25.4" in changelog[:1000] and "## 0.25.3" in changelog[:2200] and "## 0.25.2" in changelog[:3200]
assert changelog.startswith("# Changelog\n") and "## 0.25.5" in changelog[:1000] and "## 0.25.4" in changelog[:1800] and "## 0.25.3" in changelog[:3000]
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}"
+3 -3
View File
@@ -73,12 +73,12 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
flat = module.parent.with_suffix(".rs")
if flat.is_file():
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.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"]:
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.25.4 source tree: missing {path}")
raise ValueError(f"Incomplete RustProxy 0.25.5 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.25.4 entry point; extract the complete package, not individual files.")
raise ValueError("src/lib.rs is not the 0.25.5 entry point; extract the complete package, not individual files.")
return messages
+11 -5
View File
@@ -123,7 +123,7 @@ def upgrade_smoke(tmp: Path) -> None:
assert backup.is_dir()
assert (backup / "src/obsolete.rs").is_file()
assert not (target / "src/obsolete.rs").exists()
assert 'version = "0.25.4"' in (target / "Cargo.toml").read_text(encoding="utf-8")
assert 'version = "0.25.5"' 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.25.4"
assert manifest["to_version"] == "0.25.5"
assert manifest["removed_obsolete_runtime_state"] is True
assert not legacy.exists()
assert not (backup / "legacy-data").exists()
@@ -545,7 +545,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.25.4"' in openapi_0241
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.25.5"' 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
@@ -949,6 +949,12 @@ def source_contract_smoke() -> None:
style_source = (ROOT / 'src/admin/web/style.css').read_text(encoding='utf-8')
app_source = read_app_js(ROOT)
assert 'restoreConfigRevision' in app_source and 'configHistoryRestoreConfirm' in app_source
assert 'function setMobileMenu(open)' in app_source and 'setPanel(button.dataset.panel); setMobileMenu(false);' in app_source
assert 'id="mobile-menu-toggle"' in html and 'id="main-navigation"' in html
assert '.sidebar.mobile-nav-open .main-navigation' in style_source
assert 'id="about-open"' in html and 'id="about-dialog"' in html and 'Mateusz Griszczynski' in html
assert 'https://git.linuxiarz.pl/gru/RustProxy' in html and 'data-i18n="documentation" id="api-docs"' in html
assert '$("about-open").addEventListener("click"' in app_source and '.about-dialog {' in style_source
assert 'id="config-history-restore"' in html
vhosts_ui = read_vhosts_js(ROOT)
for marker in ['pub enum AppliedEncoding', 'AppliedEncoding::Gzip', 'AppliedEncoding::Brotli', 'AppliedEncoding::Zstd', 'AppliedEncoding::Deflate']:
@@ -996,7 +1002,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.25.4"', '"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.25.5"', '"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"']:
@@ -1171,7 +1177,7 @@ def main() -> None:
source_contract_smoke()
container_contract_smoke()
debian_grafana_contract_smoke()
print("PASS: 0.25.4 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.25.5 SQLite/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, Debian packaging, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.")
if __name__ == "__main__":
+1 -1
View File
@@ -85,7 +85,7 @@ def download(destination: Path) -> None:
"""TLS verification uses Python's default trust store; never executes a shell."""
started = time.monotonic()
opener = build_opener(HttpsRedirects())
request = Request(DOWNLOAD_URL, headers={"User-Agent": "RustProxy-build/0.25.4"})
request = Request(DOWNLOAD_URL, headers={"User-Agent": "RustProxy-build/0.25.5"})
with opener.open(request, timeout=30) as response, destination.open("wb") as output:
length = response.headers.get("Content-Length")
if length and int(length) > MAX_ARCHIVE:
+3 -3
View File
@@ -46,7 +46,7 @@ def upgrade(source: Path, target: Path, *, stopped: bool, data_dir: Path | None
for name in MANAGED:
if (target / name).is_symlink():
raise ValueError(f"Managed target {name} is a symlink; refusing to replace it.")
backup = target / ".upgrade-backup" / ("before-0.25.4-" + uuid.uuid4().hex[:12])
backup = target / ".upgrade-backup" / ("before-0.25.5-" + 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.25.4", "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.25.5", "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.25.4. Previous managed source files: {backup}")
print(f"Installed RustProxy 0.25.5. 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")
+2 -1
View File
@@ -96,7 +96,7 @@ need(web,
'HeaderValue::from_static("no-store")',
"builtin_error_response(StatusCode::FORBIDDEN, &method)",
"empty_index_can_go_directly_to_directory_listing",
"RustProxy <b>0.25.4</b>",
"RustProxy <b>0.25.5</b>",
"traversal_and_symlink_escape_are_not_served",
"try_files_rewrites_cache_rules_headers_and_downloads_work",
"precompressed_sidecars_keep_mime_validators_and_range_semantics",
@@ -178,6 +178,7 @@ assert "Source" in openapi.get("components", {}).get("schemas", {})
assert "client_ips" in openapi["components"]["schemas"]["TrafficCounters"]["properties"]
assert (ROOT / "docs/WEB_SERVER.md").is_file()
assert (ROOT / "docs/PHP_FPM.md").is_file()
assert (ROOT / "docs/V0.25.5_AUDIT.md").is_file()
assert (ROOT / "docs/V0.25.4_AUDIT.md").is_file()
assert (ROOT / "docs/V0.25.2_AUDIT.md").is_file()
assert (ROOT / "docs/V0.25.0_AUDIT.md").is_file()
+8 -1
View File
@@ -50,7 +50,14 @@ mod tests {
routing_path("/static/js//events.js").unwrap(),
"/static/js/events.js"
);
for path in ["/%2e%2e/secret", "/a/%2E%2E/b", "/a%2fb", "/a%5cb", "/bad%", "/bad%2"] {
for path in [
"/%2e%2e/secret",
"/a/%2E%2E/b",
"/a%2fb",
"/a%5cb",
"/bad%",
"/bad%2",
] {
assert!(routing_path(path).is_err(), "{path}");
}
assert_eq!(routing_path("//admin///users").unwrap(), "/admin/users");
+12 -3
View File
@@ -72,8 +72,17 @@ impl Manager {
id: format!("{:032x}", rand::random::<u128>()),
certificate_id: cert.id.clone(),
status: "running",
kind: if dry_run { "renewal-dry-run" } else { "renewal" },
log_directory: certbot_log_directory(&directory, &issuer.provider, &issuer.environment, &cert.id),
kind: if dry_run {
"renewal-dry-run"
} else {
"renewal"
},
log_directory: certbot_log_directory(
&directory,
&issuer.provider,
&issuer.environment,
&cert.id,
),
provider: issuer.provider.clone(),
domains,
environment: issuer.environment.clone(),
@@ -109,7 +118,7 @@ impl Manager {
.await
.map_err(|_| ApiError(StatusCode::CONFLICT, "acme.secret_missing".into()))?;
request["credential_ini_b64"] = Value::String(
base64::engine::general_purpose::STANDARD.encode(credential.as_slice())
base64::engine::general_purpose::STANDARD.encode(credential.as_slice()),
);
}
let raw = Zeroizing::new(request.to_string());
+124 -29
View File
@@ -29,8 +29,17 @@ pub struct Job {
pub certificate: Option<Certificate>,
}
pub(crate) fn certbot_log_directory(directory: &std::path::Path, provider: &str, environment: &str, certificate_id: &str) -> String {
let suffix = if provider == "rfc2136" { String::new() } else { format!("-{provider}") };
pub(crate) fn certbot_log_directory(
directory: &std::path::Path,
provider: &str,
environment: &str,
certificate_id: &str,
) -> String {
let suffix = if provider == "rfc2136" {
String::new()
} else {
format!("-{provider}")
};
directory
.join("acme")
.join(format!("{environment}-{certificate_id}{suffix}"))
@@ -38,26 +47,57 @@ pub(crate) fn certbot_log_directory(directory: &std::path::Path, provider: &str,
.to_string_lossy()
.into_owned()
}
fn provider_credential_bytes(request: &Value, provider: &str) -> Result<Zeroizing<Vec<u8>>, ApiError> {
fn provider_credential_bytes(
request: &Value,
provider: &str,
) -> Result<Zeroizing<Vec<u8>>, ApiError> {
fn credential<'a>(request: &'a Value, name: &str) -> Result<&'a str, ApiError> {
let invalid = || ApiError(StatusCode::BAD_REQUEST, "acme.invalid_api_credential".into());
let value = request.get(name).and_then(Value::as_str).ok_or_else(invalid)?;
let invalid = || {
ApiError(
StatusCode::BAD_REQUEST,
"acme.invalid_api_credential".into(),
)
};
let value = request
.get(name)
.and_then(Value::as_str)
.ok_or_else(invalid)?;
let valid = (8..=4096).contains(&value.len())
&& value.bytes().all(|byte| {
byte.is_ascii_alphanumeric()
|| matches!(byte, b'_' | b'.' | b'/' | b'+' | b'-' | b'=')
});
if valid { Ok(value) } else { Err(invalid()) }
if valid {
Ok(value)
} else {
Err(invalid())
}
}
let bytes = match provider {
"cloudflare" => format!("dns_cloudflare_api_token = {}\n", credential(request, "api_token")?).into_bytes(),
"cloudflare" => format!(
"dns_cloudflare_api_token = {}\n",
credential(request, "api_token")?
)
.into_bytes(),
"ovh" => {
if request.get("endpoint").is_some_and(|value| !value.is_string()) {
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_ovh_endpoint".into()));
if request
.get("endpoint")
.is_some_and(|value| !value.is_string())
{
return Err(ApiError(
StatusCode::BAD_REQUEST,
"acme.invalid_ovh_endpoint".into(),
));
}
let endpoint = request.get("endpoint").and_then(Value::as_str).unwrap_or("ovh-eu");
let endpoint = request
.get("endpoint")
.and_then(Value::as_str)
.unwrap_or("ovh-eu");
if !matches!(endpoint, "ovh-eu" | "ovh-ca") {
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_ovh_endpoint".into()));
return Err(ApiError(
StatusCode::BAD_REQUEST,
"acme.invalid_ovh_endpoint".into(),
));
}
format!(
"dns_ovh_endpoint = {}\ndns_ovh_application_key = {}\ndns_ovh_application_secret = {}\ndns_ovh_consumer_key = {}\n",
@@ -73,15 +113,32 @@ fn provider_credential_bytes(request: &Value, provider: &str) -> Result<Zeroizin
.get("dns_server")
.and_then(Value::as_str)
.and_then(|value| value.parse::<std::net::IpAddr>().ok())
.ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "acme.invalid_dns_server".into()))?;
if request.get("dns_port").is_some_and(|value| value.as_u64().is_none()) {
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_limits".into()));
.ok_or_else(|| {
ApiError(StatusCode::BAD_REQUEST, "acme.invalid_dns_server".into())
})?;
if request
.get("dns_port")
.is_some_and(|value| value.as_u64().is_none())
{
return Err(ApiError(
StatusCode::BAD_REQUEST,
"acme.invalid_limits".into(),
));
}
let dns_port = request.get("dns_port").and_then(Value::as_u64).unwrap_or(53);
let dns_port = request
.get("dns_port")
.and_then(Value::as_u64)
.unwrap_or(53);
if !(1..=65535).contains(&dns_port) {
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_limits".into()));
return Err(ApiError(
StatusCode::BAD_REQUEST,
"acme.invalid_limits".into(),
));
}
let raw_name = request.get("key_name").and_then(Value::as_str).unwrap_or("");
let raw_name = request
.get("key_name")
.and_then(Value::as_str)
.unwrap_or("");
let key_name = raw_name.to_ascii_lowercase();
let key_name = key_name.trim_end_matches('.');
let valid_name = !key_name.is_empty()
@@ -95,43 +152,81 @@ fn provider_credential_bytes(request: &Value, provider: &str) -> Result<Zeroizin
})
});
if !valid_name {
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_domain".into()));
return Err(ApiError(
StatusCode::BAD_REQUEST,
"acme.invalid_domain".into(),
));
}
if request.get("key_algorithm").is_some_and(|value| !value.is_string()) {
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_key_algorithm".into()));
if request
.get("key_algorithm")
.is_some_and(|value| !value.is_string())
{
return Err(ApiError(
StatusCode::BAD_REQUEST,
"acme.invalid_key_algorithm".into(),
));
}
let key_algorithm = request
.get("key_algorithm")
.and_then(Value::as_str)
.unwrap_or("HMAC-SHA256");
if !matches!(key_algorithm, "HMAC-SHA256" | "HMAC-SHA384" | "HMAC-SHA512") {
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_key_algorithm".into()));
return Err(ApiError(
StatusCode::BAD_REQUEST,
"acme.invalid_key_algorithm".into(),
));
}
let key_secret = request.get("key_secret").and_then(Value::as_str).ok_or_else(|| ApiError(StatusCode::BAD_REQUEST, "acme.invalid_api_credential".into()))?;
let key_secret = request
.get("key_secret")
.and_then(Value::as_str)
.ok_or_else(|| {
ApiError(
StatusCode::BAD_REQUEST,
"acme.invalid_api_credential".into(),
)
})?;
let decoded = Zeroizing::new(
base64::engine::general_purpose::STANDARD
.decode(key_secret)
.map_err(|_| ApiError(StatusCode::BAD_REQUEST, "acme.invalid_key_secret".into()))?,
.map_err(|_| {
ApiError(StatusCode::BAD_REQUEST, "acme.invalid_key_secret".into())
})?,
);
if key_secret.len() > 1024 || !(16..=512).contains(&decoded.len()) {
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_key_secret".into()));
return Err(ApiError(
StatusCode::BAD_REQUEST,
"acme.invalid_key_secret".into(),
));
}
if request.get("sign_query").is_some_and(|value| !value.is_boolean()) {
return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_sign_query".into()));
if request
.get("sign_query")
.is_some_and(|value| !value.is_boolean())
{
return Err(ApiError(
StatusCode::BAD_REQUEST,
"acme.invalid_sign_query".into(),
));
}
let sign_query = request.get("sign_query").and_then(Value::as_bool).unwrap_or(false);
let sign_query = request
.get("sign_query")
.and_then(Value::as_bool)
.unwrap_or(false);
format!(
"dns_rfc2136_server = {dns_server}\ndns_rfc2136_port = {dns_port}\ndns_rfc2136_name = {key_name}.\ndns_rfc2136_secret = {key_secret}\ndns_rfc2136_algorithm = {key_algorithm}\ndns_rfc2136_sign_query = {sign_query}\n"
)
.into_bytes()
}
"http01" => Vec::new(),
_ => return Err(ApiError(StatusCode::BAD_REQUEST, "acme.invalid_provider".into())),
_ => {
return Err(ApiError(
StatusCode::BAD_REQUEST,
"acme.invalid_provider".into(),
))
}
};
Ok(Zeroizing::new(bytes))
}
pub struct Manager {
slot: Arc<Semaphore>,
job: Mutex<Option<Job>>,
+3 -3
View File
@@ -1,8 +1,8 @@
fn valid_certificate_api_token(value: &str) -> bool {
(32..=128).contains(&value.len())
&& value.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~')
})
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~'))
}
fn valid_certificate_api_key_id(value: &str) -> bool {
+25 -14
View File
@@ -169,8 +169,7 @@ async fn certificate_deploy(
}
let administration_restart_required = config.administration.tls
&& config.administration.certificate_id.as_deref()
== Some(certificate_id.as_str());
&& config.administration.certificate_id.as_deref() == Some(certificate_id.as_str());
let result = state
.update_as(
Update {
@@ -196,8 +195,12 @@ async fn certificate_deploy(
})
.await
{
Err(join_error) => tracing::warn!(%join_error, "external certificate rollback task failed"),
Ok(Err(rollback_error)) => tracing::warn!(%rollback_error, "external certificate rollback failed"),
Err(join_error) => {
tracing::warn!(%join_error, "external certificate rollback task failed")
}
Ok(Err(rollback_error)) => {
tracing::warn!(%rollback_error, "external certificate rollback failed")
}
Ok(Ok(())) => {}
}
}
@@ -227,8 +230,12 @@ async fn certificate_deploy(
})
.await
{
Err(join_error) => tracing::warn!(%join_error, "previous external certificate cleanup task failed"),
Ok(Err(cleanup_error)) => tracing::warn!(%cleanup_error, "previous external certificate cleanup failed"),
Err(join_error) => {
tracing::warn!(%join_error, "previous external certificate cleanup task failed")
}
Ok(Err(cleanup_error)) => {
tracing::warn!(%cleanup_error, "previous external certificate cleanup failed")
}
Ok(Ok(())) => {}
}
}
@@ -271,12 +278,13 @@ async fn certificate_reload(
) -> Result<Json<Value>, ApiError> {
let revision = state.reload_certificates().await?;
let snapshot = state.config.load_full();
let admin_certificate_restart_required = snapshot
let admin_certificate_restart_required = snapshot.envelope.config.administration.tls
&& snapshot
.envelope
.config
.administration
.tls
&& snapshot.envelope.config.administration.certificate_id.is_some();
.certificate_id
.is_some();
state
.store
.audit(
@@ -295,23 +303,26 @@ async fn certificate_reload(
})))
}
async fn certificate_reload_one(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
Path(certificate_id): Path<String>,
) -> Result<Json<Value>, ApiError> {
if !crate::config::valid_id(&certificate_id) {
return Err(ApiError(StatusCode::NOT_FOUND, "certificate not found".into()));
return Err(ApiError(
StatusCode::NOT_FOUND,
"certificate not found".into(),
));
}
let revision = state.reload_certificate(&certificate_id).await?;
let snapshot = state.config.load_full();
let admin_certificate_restart_required = snapshot
let admin_certificate_restart_required = snapshot.envelope.config.administration.tls
&& snapshot
.envelope
.config
.administration
.tls
&& snapshot.envelope.config.administration.certificate_id.as_deref()
.certificate_id
.as_deref()
== Some(certificate_id.as_str());
state
.store
+448 -67
View File
@@ -12,73 +12,454 @@ use std::sync::OnceLock;
// document a direct compiler input, so a clean or partially removed target directory
// cannot leave the crate pointing at a missing OUT_DIR artifact.
static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
("README.md", "project", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md"))),
("ROADMAP.md", "project", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ROADMAP.md"))),
("CHANGELOG.md", "project", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/CHANGELOG.md"))),
("docs/ACME_PROVIDERS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/ACME_PROVIDERS.md"))),
("docs/ACME_RFC2136.md", "historical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/ACME_RFC2136.md"))),
("docs/API.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/API.md"))),
("docs/API_ACCESS.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/API_ACCESS.md"))),
("docs/ARCHITECTURE.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/ARCHITECTURE.md"))),
("docs/CACHE.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/CACHE.md"))),
("docs/CACHE_DIAGNOSTICS.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/CACHE_DIAGNOSTICS.md"))),
("docs/CHANGELOG_TECHNICAL_ARCHIVE.md", "historical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/CHANGELOG_TECHNICAL_ARCHIVE.md"))),
("docs/CONTAINER.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/CONTAINER.md"))),
("docs/CUSTOM_LOCATIONS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/CUSTOM_LOCATIONS.md"))),
("docs/DOCKER_DISCOVERY.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/DOCKER_DISCOVERY.md"))),
("docs/ERROR_LOGGING.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/ERROR_LOGGING.md"))),
("docs/HTTP3.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/HTTP3.md"))),
("docs/INSTALL.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/INSTALL.md"))),
("docs/INTEGRATIONS_040.md", "historical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/INTEGRATIONS_040.md"))),
("docs/LIVE_LOGS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/LIVE_LOGS.md"))),
("docs/MANAGEMENT.md", "historical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/MANAGEMENT.md"))),
("docs/MIGRATION.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/MIGRATION.md"))),
("docs/MOCK_AUTH.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/MOCK_AUTH.md"))),
("docs/MOCK_SERVER.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/MOCK_SERVER.md"))),
("docs/NOT_FOUND_HOSTS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/NOT_FOUND_HOSTS.md"))),
("docs/NPM_MIGRATION.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/NPM_MIGRATION.md"))),
("docs/PHP_FPM.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/PHP_FPM.md"))),
("docs/PROMETHEUS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/PROMETHEUS.md"))),
("docs/README.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/README.md"))),
("docs/REDIRECTS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/REDIRECTS.md"))),
("docs/REFERENCES.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/REFERENCES.md"))),
("docs/RENEWAL.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/RENEWAL.md"))),
("docs/ROADMAP_HISTORY.md", "historical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/ROADMAP_HISTORY.md"))),
("docs/SECRET_STORE_BACKUP.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/SECRET_STORE_BACKUP.md"))),
("docs/SECURITY.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/SECURITY.md"))),
("docs/SECURITY_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/SECURITY_AUDIT.md"))),
("docs/SETTINGS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/SETTINGS.md"))),
("docs/STREAMS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/STREAMS.md"))),
("docs/TEST_REPORT.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/TEST_REPORT.md"))),
("docs/TODO.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/TODO.md"))),
("docs/TRANSFORM.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/TRANSFORM.md"))),
("docs/TRANSPORT.md", "technical", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/TRANSPORT.md"))),
("docs/V0.21.0_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.21.0_AUDIT.md"))),
("docs/V0.22.0_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.22.0_AUDIT.md"))),
("docs/V0.22.1_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.22.1_AUDIT.md"))),
("docs/V0.22.2_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.22.2_AUDIT.md"))),
("docs/V0.22.3_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.22.3_AUDIT.md"))),
("docs/V0.23.0_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.23.0_AUDIT.md"))),
("docs/V0.23.1_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.23.1_AUDIT.md"))),
("docs/V0.23.2_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.23.2_AUDIT.md"))),
("docs/V0.23.3_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.23.3_AUDIT.md"))),
("docs/V0.24.0_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.0_AUDIT.md"))),
("docs/V0.24.1_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.1_AUDIT.md"))),
("docs/V0.24.3_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.3_AUDIT.md"))),
("docs/V0.24.4_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.4_AUDIT.md"))),
("docs/V0.24.5_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.5_AUDIT.md"))),
("docs/V0.24.6_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.6_AUDIT.md"))),
("docs/V0.24.7_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.7_AUDIT.md"))),
("docs/V0.24.8_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.8_AUDIT.md"))),
("docs/V0.25.0_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.0_AUDIT.md"))),
("docs/V0.25.1_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.1_AUDIT.md"))),
("docs/V0.25.4_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.4_AUDIT.md"))),
("docs/V0.25.3_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.3_AUDIT.md"))),
("docs/V0.25.2_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.2_AUDIT.md"))),
("docs/V0.25.0_SECURITY_PREPROD_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.0_SECURITY_PREPROD_AUDIT.md"))),
("docs/VHOSTS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/VHOSTS.md"))),
("docs/VHOST_ACCESS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/VHOST_ACCESS.md"))),
("docs/WEB_SERVER.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/WEB_SERVER.md"))),
(
"README.md",
"project",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/README.md")),
),
(
"ROADMAP.md",
"project",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ROADMAP.md")),
),
(
"CHANGELOG.md",
"project",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/CHANGELOG.md")),
),
(
"docs/ACME_PROVIDERS.md",
"guides",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/ACME_PROVIDERS.md"
)),
),
(
"docs/ACME_RFC2136.md",
"historical",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/ACME_RFC2136.md")),
),
(
"docs/API.md",
"guides",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/API.md")),
),
(
"docs/API_ACCESS.md",
"technical",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/API_ACCESS.md")),
),
(
"docs/ARCHITECTURE.md",
"technical",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/ARCHITECTURE.md")),
),
(
"docs/CACHE.md",
"guides",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/CACHE.md")),
),
(
"docs/CACHE_DIAGNOSTICS.md",
"technical",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/CACHE_DIAGNOSTICS.md"
)),
),
(
"docs/CHANGELOG_TECHNICAL_ARCHIVE.md",
"historical",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/CHANGELOG_TECHNICAL_ARCHIVE.md"
)),
),
(
"docs/CONTAINER.md",
"guides",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/CONTAINER.md")),
),
(
"docs/CUSTOM_LOCATIONS.md",
"guides",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/CUSTOM_LOCATIONS.md"
)),
),
(
"docs/DOCKER_DISCOVERY.md",
"guides",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/DOCKER_DISCOVERY.md"
)),
),
(
"docs/ERROR_LOGGING.md",
"technical",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/ERROR_LOGGING.md"
)),
),
(
"docs/HTTP3.md",
"technical",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/HTTP3.md")),
),
(
"docs/INSTALL.md",
"guides",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/INSTALL.md")),
),
(
"docs/INTEGRATIONS_040.md",
"historical",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/INTEGRATIONS_040.md"
)),
),
(
"docs/LIVE_LOGS.md",
"guides",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/LIVE_LOGS.md")),
),
(
"docs/MANAGEMENT.md",
"historical",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/MANAGEMENT.md")),
),
(
"docs/MIGRATION.md",
"guides",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/MIGRATION.md")),
),
(
"docs/MOCK_AUTH.md",
"technical",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/MOCK_AUTH.md")),
),
(
"docs/MOCK_SERVER.md",
"technical",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/MOCK_SERVER.md")),
),
(
"docs/NOT_FOUND_HOSTS.md",
"guides",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/NOT_FOUND_HOSTS.md"
)),
),
(
"docs/NPM_MIGRATION.md",
"guides",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/NPM_MIGRATION.md"
)),
),
(
"docs/PHP_FPM.md",
"guides",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/PHP_FPM.md")),
),
(
"docs/PROMETHEUS.md",
"guides",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/PROMETHEUS.md")),
),
(
"docs/README.md",
"guides",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/README.md")),
),
(
"docs/REDIRECTS.md",
"guides",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/REDIRECTS.md")),
),
(
"docs/REFERENCES.md",
"technical",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/REFERENCES.md")),
),
(
"docs/RENEWAL.md",
"guides",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/RENEWAL.md")),
),
(
"docs/ROADMAP_HISTORY.md",
"historical",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/ROADMAP_HISTORY.md"
)),
),
(
"docs/SECRET_STORE_BACKUP.md",
"guides",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/SECRET_STORE_BACKUP.md"
)),
),
(
"docs/SECURITY.md",
"technical",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/SECURITY.md")),
),
(
"docs/SECURITY_AUDIT.md",
"audits",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/SECURITY_AUDIT.md"
)),
),
(
"docs/SETTINGS.md",
"guides",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/SETTINGS.md")),
),
(
"docs/STREAMS.md",
"guides",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/STREAMS.md")),
),
(
"docs/TEST_REPORT.md",
"guides",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/TEST_REPORT.md")),
),
(
"docs/TODO.md",
"guides",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/TODO.md")),
),
(
"docs/TRANSFORM.md",
"guides",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/TRANSFORM.md")),
),
(
"docs/TRANSPORT.md",
"technical",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/TRANSPORT.md")),
),
(
"docs/V0.21.0_AUDIT.md",
"audits",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.21.0_AUDIT.md"
)),
),
(
"docs/V0.22.0_AUDIT.md",
"audits",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.22.0_AUDIT.md"
)),
),
(
"docs/V0.22.1_AUDIT.md",
"audits",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.22.1_AUDIT.md"
)),
),
(
"docs/V0.22.2_AUDIT.md",
"audits",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.22.2_AUDIT.md"
)),
),
(
"docs/V0.22.3_AUDIT.md",
"audits",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.22.3_AUDIT.md"
)),
),
(
"docs/V0.23.0_AUDIT.md",
"audits",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.23.0_AUDIT.md"
)),
),
(
"docs/V0.23.1_AUDIT.md",
"audits",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.23.1_AUDIT.md"
)),
),
(
"docs/V0.23.2_AUDIT.md",
"audits",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.23.2_AUDIT.md"
)),
),
(
"docs/V0.23.3_AUDIT.md",
"audits",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.23.3_AUDIT.md"
)),
),
(
"docs/V0.24.0_AUDIT.md",
"audits",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.24.0_AUDIT.md"
)),
),
(
"docs/V0.24.1_AUDIT.md",
"audits",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.24.1_AUDIT.md"
)),
),
(
"docs/V0.24.3_AUDIT.md",
"audits",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.24.3_AUDIT.md"
)),
),
(
"docs/V0.24.4_AUDIT.md",
"audits",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.24.4_AUDIT.md"
)),
),
(
"docs/V0.24.5_AUDIT.md",
"audits",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.24.5_AUDIT.md"
)),
),
(
"docs/V0.24.6_AUDIT.md",
"audits",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.24.6_AUDIT.md"
)),
),
(
"docs/V0.24.7_AUDIT.md",
"audits",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.24.7_AUDIT.md"
)),
),
(
"docs/V0.24.8_AUDIT.md",
"audits",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.24.8_AUDIT.md"
)),
),
(
"docs/V0.25.0_AUDIT.md",
"audits",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.25.0_AUDIT.md"
)),
),
(
"docs/V0.25.1_AUDIT.md",
"audits",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.25.1_AUDIT.md"
)),
),
(
"docs/V0.25.5_AUDIT.md",
"audits",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.25.5_AUDIT.md"
)),
),
(
"docs/V0.25.4_AUDIT.md",
"audits",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.25.4_AUDIT.md"
)),
),
(
"docs/V0.25.3_AUDIT.md",
"audits",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.25.3_AUDIT.md"
)),
),
(
"docs/V0.25.2_AUDIT.md",
"audits",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.25.2_AUDIT.md"
)),
),
(
"docs/V0.25.0_SECURITY_PREPROD_AUDIT.md",
"audits",
include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/docs/V0.25.0_SECURITY_PREPROD_AUDIT.md"
)),
),
(
"docs/VHOSTS.md",
"guides",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/VHOSTS.md")),
),
(
"docs/VHOST_ACCESS.md",
"guides",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/VHOST_ACCESS.md")),
),
(
"docs/WEB_SERVER.md",
"guides",
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/WEB_SERVER.md")),
),
];
pub(super) async fn index() -> Html<&'static str> {
+15 -6
View File
@@ -188,14 +188,12 @@ async fn logs(
})
.take(limit)
.collect();
Ok(Json(
json!({
Ok(Json(json!({
"entries": entries,
"capacity": crate::logging::HISTORY_CAPACITY,
"window": crate::logging::LIVE_WINDOW,
"dropped": state.logs.dropped.load(Ordering::Relaxed)
}),
))
})))
}
async fn live_logs_stream(
@@ -208,7 +206,12 @@ async fn live_logs_stream(
.viewer_slots
.clone()
.try_acquire_owned()
.map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "events.too_many_viewers".into()))?;
.map_err(|_| {
ApiError(
StatusCode::TOO_MANY_REQUESTS,
"events.too_many_viewers".into(),
)
})?;
let receiver = state.logs.subscribe();
let next_auth_check = tokio::time::Instant::now() + Duration::from_secs(10);
let stream = futures_util::stream::unfold(
@@ -344,7 +347,13 @@ async fn traffic_status(
) -> Result<Json<Value>, ApiError> {
let snapshot = state.config.load_full();
if query.vhost.as_ref().is_some_and(|id| {
id != "*" && !snapshot.envelope.config.vhosts.iter().any(|vhost| &vhost.id == id)
id != "*"
&& !snapshot
.envelope
.config
.vhosts
.iter()
.any(|vhost| &vhost.id == id)
}) {
return Err(ApiError(
StatusCode::NOT_FOUND,
+28 -2
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.25.4",
"version": "0.25.5",
"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": [
@@ -489,7 +489,7 @@
"cloudflare_client_ip": {
"type": "boolean",
"default": false,
"description": "Use a verified Cloudflare connecting-IP header only when the immediate peer is in the configured trusted Cloudflare ranges."
"description": "Use verified Cloudflare client-IP and country headers only when the immediate peer is in the built-in Cloudflare edge ranges; country falls back to local GeoIP when unavailable or invalid."
},
"timing_headers": {
"type": "boolean",
@@ -2305,6 +2305,11 @@
"type": "boolean",
"default": true
},
"include_traffic": {
"type": "boolean",
"default": true,
"description": "Export bounded Traffic analytics aggregates. Raw client IPs, request paths and full User-Agent strings are intentionally excluded."
},
"include_backends": {
"type": "boolean",
"default": true
@@ -4584,6 +4589,13 @@
"$ref": "#/components/schemas/TrafficTopEntry"
}
},
"user_agent_families": {
"type": "array",
"description": "Fixed-cardinality User-Agent families used by Prometheus; raw User-Agent top values remain available only in the authenticated Traffic API/UI.",
"items": {
"$ref": "#/components/schemas/TrafficTopEntry"
}
},
"client_ips": {
"type": "array",
"items": {
@@ -4596,6 +4608,20 @@
"$ref": "#/components/schemas/TrafficTopEntry"
}
},
"method_families": {
"type": "array",
"description": "Fixed-cardinality common HTTP method families with all extension methods folded into OTHER.",
"items": {
"$ref": "#/components/schemas/TrafficTopEntry"
}
},
"sources": {
"type": "array",
"description": "Fixed serving-source families: reverse_proxy, web_server, local_response or unresolved.",
"items": {
"$ref": "#/components/schemas/TrafficTopEntry"
}
},
"statuses": {
"type": "array",
"items": {
+28 -17
View File
@@ -66,7 +66,6 @@ async fn cache_purge(
))
}
async fn cache_invalidate(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
@@ -123,11 +122,18 @@ async fn cache_ban_delete(
Path(id): Path<u64>,
) -> Result<Json<Value>, ApiError> {
if !state.cache.remove_ban(id) {
return Err(ApiError(StatusCode::NOT_FOUND, "unknown cache BAN rule".into()));
return Err(ApiError(
StatusCode::NOT_FOUND,
"unknown cache BAN rule".into(),
));
}
state
.store
.audit(&session.username, "cache.ban.remove", &format!("ban_id={id}"))
.audit(
&session.username,
"cache.ban.remove",
&format!("ban_id={id}"),
)
.await
.map_err(auth::internal)?;
Ok(Json(json!({"removed": id, "bans": state.cache.bans()})))
@@ -164,7 +170,10 @@ async fn kill_tunnel(
));
}
let Some(tunnel) = state.tunnel_registry.kill(id) else {
return Err(ApiError(StatusCode::NOT_FOUND, "unknown active tunnel".into()));
return Err(ApiError(
StatusCode::NOT_FOUND,
"unknown active tunnel".into(),
));
};
state
.store
@@ -207,10 +216,12 @@ async fn cache_cold_cleanup_job(
State(state): State<Arc<App>>,
Path(id): Path<u64>,
) -> Result<Json<Value>, ApiError> {
let job = state
.cache
.cold_cleanup_job(id)
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "unknown cold-cache cleanup job".into()))?;
let job = state.cache.cold_cleanup_job(id).ok_or_else(|| {
ApiError(
StatusCode::NOT_FOUND,
"unknown cold-cache cleanup job".into(),
)
})?;
Ok(Json(json!({"job": job})))
}
#[derive(Deserialize)]
@@ -268,7 +279,6 @@ async fn certificate_upload(
Ok(Json(json!({"certificate":certificate, "staged":true})))
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CertificateApiRotate {
@@ -339,12 +349,7 @@ async fn certificate_api_rotate(
let token_hash = auth::secret_hash(&token);
let credential = state
.store
.upsert_certificate_api_credential(
&key_id,
&token_hash,
&comment,
&session.username,
)
.upsert_certificate_api_credential(&key_id, &token_hash, &comment, &session.username)
.await
.map_err(|error| {
if error.to_string().contains("credential limit reached") {
@@ -371,7 +376,10 @@ async fn certificate_api_revoke(
Path(key_id): Path<String>,
) -> Result<Json<Value>, ApiError> {
if !valid_certificate_api_key_id(&key_id) {
return Err(ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()));
return Err(ApiError(
StatusCode::NOT_FOUND,
"certificate_api.not_found".into(),
));
}
let revoked = state
.store
@@ -379,7 +387,10 @@ async fn certificate_api_revoke(
.await
.map_err(auth::internal)?;
if !revoked {
return Err(ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()));
return Err(ApiError(
StatusCode::NOT_FOUND,
"certificate_api.not_found".into(),
));
}
Ok(Json(json!({"revoked": true, "key_id": key_id})))
}
+8 -4
View File
@@ -36,7 +36,8 @@ fn admin_app_javascript() -> &'static str {
include_str!("web/app/config-history.js"),
include_str!("web/app/runtime.js"),
include_str!("web/app/settings.js"),
].join("\n")
]
.join("\n")
});
ASSET.as_str()
}
@@ -49,7 +50,8 @@ fn admin_vhosts_javascript() -> &'static str {
include_str!("web/vhosts/editor.js"),
include_str!("web/vhosts/render.js"),
"})();",
].join("\n")
]
.join("\n")
});
ASSET.as_str()
}
@@ -449,8 +451,10 @@ fn certificate_api_operation(method: &Method, path: &str) -> bool {
if method != Method::POST {
return false;
}
matches!(path, "/api/certificates/deploy" | "/api/certificates/reload")
|| path
matches!(
path,
"/api/certificates/deploy" | "/api/certificates/reload"
) || path
.strip_prefix("/api/certificates/")
.and_then(|tail| tail.strip_suffix("/reload"))
.is_some_and(crate::config::valid_id)
+4 -1
View File
@@ -45,7 +45,10 @@ async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next)
Ok(None) => None,
Ok(Some((key_id, token))) => {
if peer.is_some_and(|peer| !peer.0.ip().is_loopback()) && scheme != "https" {
return secure(reject(StatusCode::FORBIDDEN, "certificate_api.https_required"));
return secure(reject(
StatusCode::FORBIDDEN,
"certificate_api.https_required",
));
}
let token_hash = auth::secret_hash(&token);
match state
+14 -4
View File
@@ -158,8 +158,8 @@ fn status_payload(state: &App) -> Value {
payload["transform_resources"] = json!(&transform_resources);
payload["geoip"] = json!(state.geoip.view());
let system = state.diagnostics.view();
let cache_ram_bytes = (cache_view.hot_allocated_bytes as u64)
.saturating_add(cache_view.transient_bytes);
let cache_ram_bytes =
(cache_view.hot_allocated_bytes as u64).saturating_add(cache_view.transient_bytes);
let non_cache_rss_bytes = system
.process_rss_bytes
.map(|rss| rss.saturating_sub(cache_ram_bytes));
@@ -359,7 +359,12 @@ async fn config_history(
.configuration_revision(revision)
.await
.map_err(auth::internal)?
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "configuration revision not found".into()))?;
.ok_or_else(|| {
ApiError(
StatusCode::NOT_FOUND,
"configuration revision not found".into(),
)
})?;
return Ok(Json(json!({"revision": detail})));
}
let limit = query.limit.unwrap_or(512).clamp(1, 512);
@@ -393,7 +398,12 @@ async fn restore_config_history(
.configuration_revision(input.revision)
.await
.map_err(auth::internal)?
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "configuration revision not found".into()))?;
.ok_or_else(|| {
ApiError(
StatusCode::NOT_FOUND,
"configuration revision not found".into(),
)
})?;
state
.restore_as(
Update {
+13 -2
View File
@@ -390,6 +390,8 @@ function stopTunnelRefresh() { if (tunnelRefreshTimer) clearInterval(tunnelRefre
function startTunnelRefresh() { stopTunnelRefresh(); tunnelRefreshTimer = setInterval(() => { if ($("tunnels-dialog")?.open) void loadTunnels(); }, 2000); }
$("login-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; authMessage(""); try { await withFormSubmitState(form, event.submitter, async () => { const username = $("username").value.trim(); const password = $("password").value; if (setupRequired) { if (password !== $("password-confirm").value) throw fieldError("password-confirm", t("passwordMismatch")); await api("/api/auth/setup", { method: "POST", body: JSON.stringify({ token: $("setup-token").value.trim(), username, password }) }); setupRequired = false; syncAuthForm(); } const info = await api("/api/auth/login", { method: "POST", body: JSON.stringify({ username, password }) }); await activate(info); clearTimeout(toastTimer); hideToast(); }); } catch (e) { if (e.code === "auth.already_initialized") { setupRequired = false; syncAuthForm(); } $("password").value = ""; $("password-confirm").value = ""; authMessage(e.message); } });
$("logout").addEventListener("click", async () => { try { await api("/api/auth/logout", { method: "POST" }); lock(); toast(t("locked")); } catch (e) { if (e.status === 401) lock(); toast(e.message, true); } });
$("about-open").addEventListener("click", () => { const dialog = $("about-dialog"); if (!dialog.open) dialog.showModal(); });
$("about-close").addEventListener("click", () => $("about-dialog").close());
$("language").addEventListener("change", () => { language = $("language").value; try { localStorage.setItem("rp-language", language); } catch { } translate(); });
$("ui-theme").addEventListener("change", async () => {
const select = $("ui-theme"), previousTheme = adminPreferences?.theme || document.documentElement.dataset.theme || "dark", nextTheme = normalizeAdminTheme(select.value);
@@ -398,8 +400,17 @@ $("ui-theme").addEventListener("change", async () => {
catch (error) { applyAdminTheme(previousTheme); if (error.status !== 401) toast(error.message, true); }
finally { select.disabled = false; }
});
document.querySelectorAll(".nav").forEach(button => button.addEventListener("click", () => setPanel(button.dataset.panel)));
window.addEventListener("popstate", () => setPanel(panelFromLocation(), false));
const mobileMenuToggle = $("mobile-menu-toggle"), dashboardSidebar = document.querySelector(".sidebar");
function setMobileMenu(open) {
if (!mobileMenuToggle || !dashboardSidebar) return;
dashboardSidebar.classList.toggle("mobile-nav-open", open);
mobileMenuToggle.setAttribute("aria-expanded", String(open));
}
mobileMenuToggle?.addEventListener("click", event => { event.stopPropagation(); setMobileMenu(!dashboardSidebar.classList.contains("mobile-nav-open")); });
document.querySelectorAll(".nav").forEach(button => button.addEventListener("click", () => { setPanel(button.dataset.panel); setMobileMenu(false); }));
document.addEventListener("click", event => { if (dashboardSidebar?.classList.contains("mobile-nav-open") && !dashboardSidebar.contains(event.target)) setMobileMenu(false); });
document.addEventListener("keydown", event => { if (event.key === "Escape" && dashboardSidebar?.classList.contains("mobile-nav-open")) { setMobileMenu(false); mobileMenuToggle?.focus(); } });
window.addEventListener("popstate", () => { setPanel(panelFromLocation(), false); setMobileMenu(false); });
$("host-policy-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { const hosts = [...new Set($("policy-hosts").value.split(",").map(h => h.trim().toLowerCase()).filter(Boolean))]; const acl = { default: $("policy-default").value, rules: parseRules($("policy-rules").value, $("policy-rules")) }; const next = structuredClone(envelope.config); next.host_policies = (next.host_policies || []).map(p => ({ ...p, hosts: p.hosts.filter(h => !hosts.includes(h)) })).filter(p => p.hosts.length); next.host_policies.push({ hosts, acl }); if (await saveSection("host-policies", next.host_policies)) { form.reset(); if ($("security-policy-editor")) $("security-policy-editor").open = false; } }); } catch (e) { toast(e.message, true); } });
$("password-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; try { await withFormSubmitState(form, event.submitter, async () => { if ($("new-password").value !== $("new-password-confirm").value) throw fieldError("new-password-confirm", t("passwordMismatch")); await api("/api/auth/password", { method: "POST", body: JSON.stringify({ current_password: $("current-password").value, new_password: $("new-password").value }) }); lock(); toast(t("passwordChanged")); }); } catch (e) { toast(e.message, true); } });
$("revoke-sessions").addEventListener("click", async () => { if (!(await appConfirm(t("confirmRevoke"), { danger: true }))) return; try { await api("/api/auth/logout-all", { method: "POST" }); lock(); toast(t("locked")); } catch (e) { toast(e.message, true); } });
+1 -1
View File
@@ -6,7 +6,7 @@ function renderSettings() {
if (!settingsDirty.has("response-form")) {
const server = config.server_header || {}, compression = config.compression || {}, diagnostics = config.diagnostic_headers || {};
$("expose-request-id").checked = !!diagnostics.request_id;
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.25.4";
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.25.5";
for (const name of ["gzip", "br", "zstd", "deflate"]) $(`compress-${name}`).checked = !!compression[name];
$("compress-min").value = compression.min_bytes ?? 1024; syncServerMode();
}
+1
View File
@@ -176,6 +176,7 @@ main {
background: #101923;
color: #fff
}
.docs-tabs {
display: flex;
gap: 8px;
+13 -5
View File
@@ -1,5 +1,6 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -10,10 +11,12 @@
<script defer src="/docs/swagger-ui-bundle.js"></script>
<script defer src="/docs/init.js"></script>
</head>
<body>
<header class="docs-bar"><a class="docs-name" href="/">RustProxy</a>
<h1 data-i18n="docsTitle">Documentation</h1>
<a href="/" data-i18n="docsPanel">Back to panel</a><button type="button" id="docs-logout" data-i18n="docsLogout">Sign out</button>
<a href="/" data-i18n="docsPanel">Back to panel</a><button type="button" id="docs-logout"
data-i18n="docsLogout">Sign out</button>
</header>
<main>
<section class="docs-session">
@@ -22,14 +25,16 @@
</section>
<p id="docs-error" role="alert"></p>
<nav class="docs-tabs" aria-label="Documentation sections">
<button type="button" id="docs-tab-reader" class="active" data-docs-tab="reader" data-i18n="docsReader">Reader</button>
<button type="button" id="docs-tab-reader" class="active" data-docs-tab="reader"
data-i18n="docsReader">Reader</button>
<button type="button" id="docs-tab-api" data-docs-tab="api" data-i18n="docsApi">API</button>
</nav>
<section id="docs-reader-pane" class="docs-pane">
<div class="reader-layout">
<aside class="reader-sidebar">
<label for="docs-reader-search" data-i18n="docsReaderSearch">Search documentation</label>
<input id="docs-reader-search" type="search" autocomplete="off" data-i18n-placeholder="docsReaderSearchPlaceholder" placeholder="Search title or path">
<input id="docs-reader-search" type="search" autocomplete="off"
data-i18n-placeholder="docsReaderSearchPlaceholder" placeholder="Search title or path">
<div id="docs-reader-list" class="reader-list"></div>
</aside>
<article id="docs-reader-content" class="markdown-reader" aria-live="polite"></article>
@@ -37,12 +42,15 @@
</section>
<section id="docs-api-pane" class="docs-pane" hidden>
<section class="docs-api-controls">
<label><input type="checkbox" id="docs-writes"><span data-i18n="docsWrites">Enable write operations</span></label>
<p data-i18n="docsReadOnly">Read-only execution by default. SSE streams and login/setup are not executed here.</p>
<label><input type="checkbox" id="docs-writes"><span data-i18n="docsWrites">Enable write
operations</span></label>
<p data-i18n="docsReadOnly">Read-only execution by default. SSE streams and login/setup are not executed
here.</p>
</section>
<div id="swagger-ui"></div>
<p><a href="/docs/licenses.txt" data-i18n="docsLicenses">Swagger UI licenses</a></p>
</section>
</main>
</body>
</html>
+493 -164
View File
@@ -29,12 +29,16 @@
<aside class="sidebar">
<a class="brand" href="/" aria-label="RustProxy"><span
class="brand-mark">r<span>/</span>p</span><span>RustProxy</span></a>
<button aria-controls="main-navigation" aria-expanded="false" class="mobile-menu-toggle"
data-i18n-aria="mainMenu" id="mobile-menu-toggle" type="button"><span aria-hidden="true"
class="mobile-menu-icon"></span><span data-i18n="mainMenu">Menu</span></button>
<div class="edition">INDEPENDENT BY DESIGN</div>
<nav aria-label="Main navigation">
<nav aria-label="Main navigation" class="main-navigation" id="main-navigation">
<button class="nav active" data-panel="overview"><span>01</span><b
data-i18n="overview">Overview</b></button>
<button class="nav" data-panel="vhosts"><span>02</span><b data-i18n="vhosts">Vhosts</b></button>
<button class="nav" data-panel="web-server"><span>03</span><b data-i18n="webServer">Web Server</b></button>
<button class="nav" data-panel="web-server"><span>03</span><b data-i18n="webServer">Web
Server</b></button>
<button class="nav" data-panel="docker-vhosts"><span>04</span><b data-i18n="docker-vhosts">Docker
vhosts</b></button>
<button class="nav" data-panel="redirects"><span>05</span><b
@@ -58,11 +62,13 @@
<main>
<header class="topbar"><span class="breadcrumb">CONTROL ROOM <span>/</span> <b
id="page-label">Overview</b></span>
<div class="top-actions"><a class="quiet" href="/docs" data-i18n="apiDocs" id="api-docs" hidden="">API
docs</a><button class="quiet" data-i18n="liveLogs" hidden="" id="logs-open" type="button">Live
<div class="top-actions"><a class="quiet" href="/docs" data-i18n="documentation" id="api-docs"
hidden="">Documentation</a><button class="quiet" data-i18n="liveLogs" hidden="" id="logs-open"
type="button">Live
access logs</button><select aria-label="Language" class="language-select"
data-i18n-aria="language" id="language"></select><button class="quiet" data-i18n="logout"
hidden="" id="logout">Lock</button></div>
data-i18n-aria="language" id="language"></select><button class="quiet" data-i18n="about"
id="about-open" type="button">About</button><button class="quiet" data-i18n="logout" hidden=""
id="logout">Lock</button></div>
</header>
<section class="login-section" hidden="" id="login">
<div class="eyebrow" data-i18n="firstSlice">THE FIRST WORKING SLICE</div>
@@ -300,21 +306,22 @@
<div class="mini-live-chart">
<div class="mini-live-chart-head"><span data-i18n="cacheHotMemoryHistory">HOT RAM
usage / limit</span><b id="cache-chart-value">-</b></div>
<svg aria-label="HOT cache RAM usage history"
data-i18n-aria="cacheHotMemoryHistory" role="img" viewbox="0 0 300 60">
<svg aria-label="HOT cache RAM usage history" data-i18n-aria="cacheHotMemoryHistory"
role="img" viewbox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line cache-hot-line" id="cache-memory-sparkline"
points="0,55 300,55"></polyline>
</svg>
</div>
<div class="mini-live-chart">
<div class="mini-live-chart-head"><span data-i18n="cacheTransientMemoryHistory">Transient
<div class="mini-live-chart-head"><span
data-i18n="cacheTransientMemoryHistory">Transient
RAM usage / limit</span><b id="cache-transient-chart-value">-</b></div>
<svg aria-label="Transient cache RAM usage history"
data-i18n-aria="cacheTransientMemoryHistory" role="img" viewbox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
<polyline class="diagnostic-line cache-transient-total-line" id="cache-transient-sparkline"
points="0,55 300,55"></polyline>
<polyline class="diagnostic-line cache-transient-total-line"
id="cache-transient-sparkline" points="0,55 300,55"></polyline>
<polyline class="diagnostic-line cache-fill-line" id="cache-fill-sparkline"
points="0,55 300,55"></polyline>
<polyline class="diagnostic-line cache-queue-line" id="cache-queue-sparkline"
@@ -323,13 +330,15 @@
points="0,55 300,55"></polyline>
</svg>
<div class="mini-chart-legend" aria-hidden="true"><span
class="cache-transient-total-key" data-i18n="cacheTransientShort">TOTAL</span><span
class="cache-fill-key" data-i18n="cacheFillShort">fill</span><span
class="cache-queue-key" data-i18n="cacheQueueShort">cold queue</span><span
class="cache-io-key" data-i18n="cacheIoShort">cold I/O</span></div>
class="cache-transient-total-key"
data-i18n="cacheTransientShort">TOTAL</span><span class="cache-fill-key"
data-i18n="cacheFillShort">fill</span><span class="cache-queue-key"
data-i18n="cacheQueueShort">cold queue</span><span class="cache-io-key"
data-i18n="cacheIoShort">cold I/O</span></div>
</div>
<div class="mini-live-chart">
<div class="mini-live-chart-head"><span data-i18n="cacheColdStorageHistory">COLD storage
<div class="mini-live-chart-head"><span data-i18n="cacheColdStorageHistory">COLD
storage
usage / limit</span><b id="cache-cold-chart-value">-</b></div>
<svg aria-label="COLD storage usage history"
data-i18n-aria="cacheColdStorageHistory" role="img" viewbox="0 0 300 60">
@@ -345,9 +354,15 @@
<h3 data-i18n="processDiagnostics">Process</h3>
<div class="mini-heading-actions">
<details class="metric-info">
<summary data-i18n-aria="memoryAccountingInfo" aria-label="Memory accounting information">i</summary>
<summary data-i18n-aria="memoryAccountingInfo"
aria-label="Memory accounting information">i</summary>
<div class="metric-info-popover">
<p class="fine" data-i18n="memoryAccountingHint">RSS is the real process footprint. HOT/transient cache values are RustProxy-accounted allocations; transform memory is a conservative active reservation. Non-cache RSS is RSS minus accounted cache and includes transforms, connections, TLS, logging, Tokio/runtime state, allocator overhead and other buffers, so these rows are not all additive.</p>
<p class="fine" data-i18n="memoryAccountingHint">RSS is the real process
footprint. HOT/transient cache values are RustProxy-accounted
allocations; transform memory is a conservative active reservation.
Non-cache RSS is RSS minus accounted cache and includes transforms,
connections, TLS, logging, Tokio/runtime state, allocator overhead and
other buffers, so these rows are not all additive.</p>
</div>
</details>
<span class="meta" id="system-sample-age">-</span>
@@ -484,14 +499,19 @@
<div class="network-interface-charts" id="network-interface-charts"></div>
</div>
<div class="mini-live-chart">
<div class="mini-live-chart-head"><span
data-i18n="responseLatencyHistory">Response time history</span><span
class="mini-live-chart-actions"><details class="metric-info">
<summary data-i18n-aria="responseLatencyInfo" aria-label="Response time information">i</summary>
<div class="mini-live-chart-head"><span data-i18n="responseLatencyHistory">Response
time history</span><span class="mini-live-chart-actions">
<details class="metric-info">
<summary data-i18n-aria="responseLatencyInfo"
aria-label="Response time information">i</summary>
<div class="metric-info-popover">
<p class="fine" data-i18n="responseLatencyHint">The value is the cumulative mean duration of completed HTTP exchanges since process start. The chart shows successive samples of that mean; it is not a percentile or the latency of one request.</p>
<p class="fine" data-i18n="responseLatencyHint">The value is the
cumulative mean duration of completed HTTP exchanges since
process start. The chart shows successive samples of that mean;
it is not a percentile or the latency of one request.</p>
</div>
</details><b id="latency-chart-value">-</b></span></div>
</details><b id="latency-chart-value">-</b>
</span></div>
<svg aria-label="Mean response latency history"
data-i18n-aria="responseLatencyHistory" role="img" viewbox="0 0 300 60">
<path class="chart-grid" d="M0 15H300 M0 30H300 M0 45H300"></path>
@@ -552,15 +572,20 @@
</section>
<section class="panel" hidden="" id="panel-web-server">
<p class="notice fine" data-i18n="webServerIntro">Create reusable local Web Services here, then attach them to a vhost or Custom location.</p>
<p class="notice fine" data-i18n="webServerIntro">Create reusable local Web Services here, then
attach them to a vhost or Custom location.</p>
<div class="vhost-layout">
<article class="card">
<div class="card-heading">
<h2 data-i18n="webServiceList">Web Services</h2>
<div class="vhost-list-tools">
<label class="sr-only" data-i18n="webServiceSearch" for="web-service-search">Search Web Services</label>
<input autocomplete="off" data-i18n-aria="webServiceSearch" data-i18n-placeholder="webServiceSearchPlaceholder" id="web-service-search" type="search" />
<button class="outline" data-i18n="webServiceNew" id="web-service-new" type="button">New Web Service</button>
<label class="sr-only" data-i18n="webServiceSearch" for="web-service-search">Search
Web Services</label>
<input autocomplete="off" data-i18n-aria="webServiceSearch"
data-i18n-placeholder="webServiceSearchPlaceholder" id="web-service-search"
type="search" />
<button class="outline" data-i18n="webServiceNew" id="web-service-new"
type="button">New Web Service</button>
</div>
</div>
<div class="entity-list" id="web-service-list"></div>
@@ -959,57 +984,152 @@
</div>
</section>
<section class="panel" hidden="" id="panel-traffic">
<div class="cache-toolbar card"><label><span data-i18n="logVhost">Vhost</span><select id="traffic-scope">
<div class="cache-toolbar card"><label><span data-i18n="logVhost">Vhost</span><select
id="traffic-scope">
<option data-i18n="logAllVhosts" value="*">All vhosts</option>
</select></label><button class="quiet" data-i18n="cacheRefresh" id="traffic-refresh" type="button">Refresh</button>
</select></label><button class="quiet" data-i18n="cacheRefresh" id="traffic-refresh"
type="button">Refresh</button>
<button class="quiet" data-i18n="settings" id="traffic-settings" type="button">Settings</button>
<div class="cache-toolbar-status"><span class="fine" id="traffic-panel-state" role="status"></span>
<label class="check cache-live-toggle"><input checked="" id="traffic-live" type="checkbox" />
<span data-i18n="cacheLive">Live (3 s)</span></label></div>
<div class="cache-toolbar-status"><span class="fine" id="traffic-panel-state"
role="status"></span>
<label class="check cache-live-toggle"><input checked="" id="traffic-live"
type="checkbox" />
<span data-i18n="cacheLive">Live (3 s)</span></label>
</div>
<nav aria-label="Traffic analytics" class="settings-tabs sub-tabs traffic-tabs" id="traffic-tabs" role="tablist">
<button aria-selected="true" class="active" data-i18n="trafficTabOverview" data-traffic-tab="overview" role="tab" type="button">Overview</button>
<button aria-selected="false" data-i18n="trafficTabClients" data-traffic-tab="clients" role="tab" type="button">Clients</button>
<button aria-selected="false" data-i18n="trafficTabRequests" data-traffic-tab="requests" role="tab" type="button">Requests</button>
<button aria-selected="false" data-i18n="trafficTabDelivery" data-traffic-tab="delivery" role="tab" type="button">Delivery</button>
</div>
<nav aria-label="Traffic analytics" class="settings-tabs sub-tabs traffic-tabs" id="traffic-tabs"
role="tablist">
<button aria-selected="true" class="active" data-i18n="trafficTabOverview"
data-traffic-tab="overview" role="tab" type="button">Overview</button>
<button aria-selected="false" data-i18n="trafficTabClients" data-traffic-tab="clients"
role="tab" type="button">Clients</button>
<button aria-selected="false" data-i18n="trafficTabRequests" data-traffic-tab="requests"
role="tab" type="button">Requests</button>
<button aria-selected="false" data-i18n="trafficTabDelivery" data-traffic-tab="delivery"
role="tab" type="button">Delivery</button>
</nav>
<div data-traffic-page="overview">
<div class="stats-grid cache-stats">
<article class="stat"><span data-i18n="requests">Requests</span><strong id="traffic-requests">-</strong><small data-i18n="trafficSinceStart">Since process start</small></article>
<article class="stat"><span data-i18n="errors">Errors</span><strong id="traffic-errors">-</strong><small id="traffic-error-ratio">-</small></article>
<article class="stat"><span data-i18n="trafficResponseBytes">Response bytes</span><strong id="traffic-response-bytes">-</strong><small id="traffic-request-bytes">-</small></article>
<article class="stat"><span data-i18n="trafficMeanLatency">Mean latency</span><strong id="traffic-latency">-</strong><small id="traffic-dropped">-</small></article>
<article class="stat"><span data-i18n="requests">Requests</span><strong
id="traffic-requests">-</strong><small data-i18n="trafficSinceStart">Since process
start</small></article>
<article class="stat"><span data-i18n="errors">Errors</span><strong
id="traffic-errors">-</strong><small id="traffic-error-ratio">-</small></article>
<article class="stat"><span data-i18n="trafficResponseBytes">Response bytes</span><strong
id="traffic-response-bytes">-</strong><small id="traffic-request-bytes">-</small>
</article>
<article class="stat"><span data-i18n="trafficMeanLatency">Mean latency</span><strong
id="traffic-latency">-</strong><small id="traffic-dropped">-</small></article>
</div>
<article class="card">
<div class="card-heading"><div><span class="small-label" data-i18n="trafficBoundedLabel">BOUNDED / ASYNC</span><h2 data-i18n="trafficDimensionsTitle">Traffic dimensions</h2></div></div>
<p class="fine" data-i18n="trafficApproxHelp">Top lists are approximate and bounded so high-cardinality traffic cannot grow proxy memory without limit.</p>
<div class="card-heading">
<div><span class="small-label" data-i18n="trafficBoundedLabel">BOUNDED / ASYNC</span>
<h2 data-i18n="trafficDimensionsTitle">Traffic dimensions</h2>
</div>
</div>
<p class="fine" data-i18n="trafficApproxHelp">Top lists are approximate and bounded so
high-cardinality traffic cannot grow proxy memory without limit.</p>
<p class="fine" id="traffic-capture-status"></p>
</article>
<div class="traffic-chart-grid traffic-overview-charts">
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficMethods">Methods</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-methods" role="img"><span id="traffic-chart-methods-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-methods-legend"></ol></div></article>
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficStatusClasses">Status classes</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-statuses" role="img"><span id="traffic-chart-statuses-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-statuses-legend"></ol></div></article>
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficCacheOutcomes">Cache outcomes</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-cache" role="img"><span id="traffic-chart-cache-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-cache-legend"></ol></div></article>
<article class="card traffic-chart-card">
<h3 class="subheading" data-i18n="trafficMethods">Methods</h3>
<div class="traffic-donut-layout">
<div class="traffic-donut" id="traffic-chart-methods" role="img"><span
id="traffic-chart-methods-total">-</span></div>
<ol class="traffic-chart-legend" id="traffic-chart-methods-legend"></ol>
</div>
</article>
<article class="card traffic-chart-card">
<h3 class="subheading" data-i18n="trafficStatusClasses">Status classes</h3>
<div class="traffic-donut-layout">
<div class="traffic-donut" id="traffic-chart-statuses" role="img"><span
id="traffic-chart-statuses-total">-</span></div>
<ol class="traffic-chart-legend" id="traffic-chart-statuses-legend"></ol>
</div>
</article>
<article class="card traffic-chart-card">
<h3 class="subheading" data-i18n="trafficCacheOutcomes">Cache outcomes</h3>
<div class="traffic-donut-layout">
<div class="traffic-donut" id="traffic-chart-cache" role="img"><span
id="traffic-chart-cache-total">-</span></div>
<ol class="traffic-chart-legend" id="traffic-chart-cache-legend"></ol>
</div>
</article>
</div>
</div>
<div data-traffic-page="clients" hidden>
<div class="traffic-chart-grid traffic-clients-grid">
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficTopCountries">Top countries</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-countries" role="img"><span id="traffic-chart-countries-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-countries-legend"></ol></div><p class="empty" id="traffic-chart-countries-empty" data-i18n="trafficNoCountryData">Country collection is disabled or no GeoIP data is available.</p></article>
<article class="card traffic-chart-card traffic-wide-chart"><h3 class="subheading" data-i18n="trafficTopUserAgents">Top User-Agents</h3><div class="traffic-bar-chart" id="traffic-chart-user-agents" role="img"></div><p class="empty" id="traffic-chart-user-agents-empty" data-i18n="trafficNoUserAgentData">No User-Agent data collected.</p></article>
<article class="card traffic-chart-card traffic-wide-chart"><h3 class="subheading" data-i18n="trafficTopClientIps">Top client IPs</h3><div class="traffic-bar-chart" id="traffic-chart-client-ips" role="img"></div><p class="empty" id="traffic-chart-client-ips-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
<article class="card traffic-chart-card">
<h3 class="subheading" data-i18n="trafficTopCountries">Top countries</h3>
<div class="traffic-donut-layout">
<div class="traffic-donut" id="traffic-chart-countries" role="img"><span
id="traffic-chart-countries-total">-</span></div>
<ol class="traffic-chart-legend" id="traffic-chart-countries-legend"></ol>
</div>
<p class="empty" id="traffic-chart-countries-empty" data-i18n="trafficNoCountryData">
Country collection is disabled or no GeoIP data is available.</p>
</article>
<article class="card traffic-chart-card traffic-wide-chart">
<h3 class="subheading" data-i18n="trafficTopUserAgents">Top User-Agents</h3>
<div class="traffic-bar-chart" id="traffic-chart-user-agents" role="img"></div>
<p class="empty" id="traffic-chart-user-agents-empty"
data-i18n="trafficNoUserAgentData">No User-Agent data collected.</p>
</article>
<article class="card traffic-chart-card traffic-wide-chart">
<h3 class="subheading" data-i18n="trafficTopClientIps">Top client IPs</h3>
<div class="traffic-bar-chart" id="traffic-chart-client-ips" role="img"></div>
<p class="empty" id="traffic-chart-client-ips-empty" data-i18n="trafficNoData">No
traffic data yet.</p>
</article>
</div>
</div>
<div data-traffic-page="requests" hidden>
<div class="traffic-chart-grid traffic-request-grid">
<article class="card traffic-chart-card traffic-wide-chart"><h3 class="subheading" data-i18n="trafficTopPaths">Top paths</h3><div class="traffic-bar-chart" id="traffic-chart-paths" role="img"></div><p class="empty" id="traffic-chart-paths-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
<article class="card traffic-chart-card traffic-wide-chart"><h3 class="subheading" data-i18n="trafficTopRoutes">Top routes</h3><div class="traffic-bar-chart" id="traffic-chart-routes" role="img"></div><p class="empty" id="traffic-chart-routes-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficMethods">Methods</h3><div class="traffic-donut-layout"><div class="traffic-donut traffic-donut-mirror" id="traffic-chart-methods-detail" role="img"><span id="traffic-chart-methods-detail-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-methods-detail-legend"></ol></div></article>
<article class="card traffic-chart-card traffic-wide-chart">
<h3 class="subheading" data-i18n="trafficTopPaths">Top paths</h3>
<div class="traffic-bar-chart" id="traffic-chart-paths" role="img"></div>
<p class="empty" id="traffic-chart-paths-empty" data-i18n="trafficNoData">No traffic
data yet.</p>
</article>
<article class="card traffic-chart-card traffic-wide-chart">
<h3 class="subheading" data-i18n="trafficTopRoutes">Top routes</h3>
<div class="traffic-bar-chart" id="traffic-chart-routes" role="img"></div>
<p class="empty" id="traffic-chart-routes-empty" data-i18n="trafficNoData">No traffic
data yet.</p>
</article>
<article class="card traffic-chart-card">
<h3 class="subheading" data-i18n="trafficMethods">Methods</h3>
<div class="traffic-donut-layout">
<div class="traffic-donut traffic-donut-mirror" id="traffic-chart-methods-detail"
role="img"><span id="traffic-chart-methods-detail-total">-</span></div>
<ol class="traffic-chart-legend" id="traffic-chart-methods-detail-legend"></ol>
</div>
</article>
</div>
</div>
<div data-traffic-page="delivery" hidden>
<div class="traffic-chart-grid traffic-delivery-grid">
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficStatusClasses">Status classes</h3><div class="traffic-donut-layout"><div class="traffic-donut traffic-donut-mirror" id="traffic-chart-statuses-detail" role="img"><span id="traffic-chart-statuses-detail-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-statuses-detail-legend"></ol></div></article>
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficCacheOutcomes">Cache outcomes</h3><div class="traffic-donut-layout"><div class="traffic-donut traffic-donut-mirror" id="traffic-chart-cache-detail" role="img"><span id="traffic-chart-cache-detail-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-cache-detail-legend"></ol></div></article>
<article class="card traffic-chart-card traffic-wide-chart"><h3 class="subheading" data-i18n="trafficVolume">Traffic volume</h3><div class="traffic-bar-chart" id="traffic-chart-volume" role="img"></div></article>
<article class="card traffic-chart-card">
<h3 class="subheading" data-i18n="trafficStatusClasses">Status classes</h3>
<div class="traffic-donut-layout">
<div class="traffic-donut traffic-donut-mirror" id="traffic-chart-statuses-detail"
role="img"><span id="traffic-chart-statuses-detail-total">-</span></div>
<ol class="traffic-chart-legend" id="traffic-chart-statuses-detail-legend"></ol>
</div>
</article>
<article class="card traffic-chart-card">
<h3 class="subheading" data-i18n="trafficCacheOutcomes">Cache outcomes</h3>
<div class="traffic-donut-layout">
<div class="traffic-donut traffic-donut-mirror" id="traffic-chart-cache-detail"
role="img"><span id="traffic-chart-cache-detail-total">-</span></div>
<ol class="traffic-chart-legend" id="traffic-chart-cache-detail-legend"></ol>
</div>
</article>
<article class="card traffic-chart-card traffic-wide-chart">
<h3 class="subheading" data-i18n="trafficVolume">Traffic volume</h3>
<div class="traffic-bar-chart" id="traffic-chart-volume" role="img"></div>
</article>
</div>
</div>
</section>
@@ -1241,10 +1361,12 @@
id="tp-skipped">-</strong><small id="tp-skip-top">-</small></article>
<article class="stat"><span data-i18n="transformErrors">Body/transport errors</span><strong
id="tp-errors">-</strong><small id="tp-duration">-</small></article>
<article class="stat"><span data-i18n="transformMemoryUsage">Transform RAM reservation</span><strong
id="tp-memory">-</strong><small id="tp-memory-detail">-</small></article>
<article class="stat"><span data-i18n="transformConcurrencyUsage">Active transform slots</span><strong
id="tp-inflight">-</strong><small id="tp-inflight-detail">-</small></article>
<article class="stat"><span data-i18n="transformMemoryUsage">Transform RAM
reservation</span><strong id="tp-memory">-</strong><small
id="tp-memory-detail">-</small></article>
<article class="stat"><span data-i18n="transformConcurrencyUsage">Active transform
slots</span><strong id="tp-inflight">-</strong><small id="tp-inflight-detail">-</small>
</article>
</div>
<div class="diagnostics-grid transform-diagnostics">
<article class="card">
@@ -1472,7 +1594,7 @@
<option data-i18n="serverCustom" value="custom">Replace / add custom value</option>
</select>
<label data-i18n="serverValue" for="server-value">Custom Server value</label><input
id="server-value" maxlength="256" placeholder="RustProxy/v0.25.4"
id="server-value" maxlength="256" placeholder="RustProxy/v0.25.5"
spellcheck="false" />
<h3 class="subheading" data-i18n="diagnosticHeaders">Diagnostic headers</h3>
<div class="switch-grid">
@@ -1559,14 +1681,22 @@
<div><label data-i18n="cacheEvictionLimit" for="cache-eviction-limit">LRU
removals per admission</label><input id="cache-eviction-limit"
max="4096" min="1" required="" type="number" /></div>
<div><label data-i18n="cacheAutoTrimAfter" for="cache-auto-trim-after">Idle age before HOT trim (seconds)</label><input id="cache-auto-trim-after" max="86400" min="5" required="" type="number" /></div>
<div><label data-i18n="cacheAutoTrimTarget" for="cache-auto-trim-target">HOT trim target (%)</label><input id="cache-auto-trim-target" max="95" min="10" required="" type="number" /></div>
<div><label data-i18n="cacheAutoTrimAfter" for="cache-auto-trim-after">Idle age
before HOT trim (seconds)</label><input id="cache-auto-trim-after"
max="86400" min="5" required="" type="number" /></div>
<div><label data-i18n="cacheAutoTrimTarget" for="cache-auto-trim-target">HOT
trim target (%)</label><input id="cache-auto-trim-target" max="95"
min="10" required="" type="number" /></div>
<div><label data-i18n="cacheMaxTtl" for="cache-ttl">Maximum TTL
(seconds)</label><input id="cache-ttl" max="604800" min="1" required=""
type="number" /></div>
</div>
<label class="check"><input id="cache-auto-trim-enabled" type="checkbox" /><span data-i18n="cacheAutoTrimEnabled">Automatically release idle HOT cache memory</span></label>
<p class="fine" data-i18n="cacheAutoTrimHelp">When HOT memory is above the target, only entries idle for the configured age are removed in bounded passes. Active objects are not purged.</p>
<label class="check"><input id="cache-auto-trim-enabled" type="checkbox" /><span
data-i18n="cacheAutoTrimEnabled">Automatically release idle HOT cache
memory</span></label>
<p class="fine" data-i18n="cacheAutoTrimHelp">When HOT memory is above the target,
only entries idle for the configured age are removed in bounded passes. Active
objects are not purged.</p>
<label data-i18n="cacheDefaultTtl" for="cache-default-ttl">Fallback TTL for public
responses (0 = off)</label><input id="cache-default-ttl" max="604800" min="0"
required="" type="number" />
@@ -1636,7 +1766,10 @@
<h3 data-i18n="cacheColdPresetTitle">COLD policy preset</h3>
<span class="pill" id="cache-cold-preset-selected">-</span>
</div>
<p class="fine" data-i18n="cacheColdPresetHelp">The recommended policy enables idle/LRU/shutdown demotion, uses a queue sized from transient RAM and conservative GC timings. Disk path, disk budget and object limit are left unchanged.</p>
<p class="fine" data-i18n="cacheColdPresetHelp">The recommended policy enables
idle/LRU/shutdown demotion, uses a queue sized from transient RAM and
conservative GC timings. Disk path, disk budget and object limit are left
unchanged.</p>
<div class="form-actions">
<button class="outline" data-cache-cold-preset="recommended"
data-i18n="cachePresetRecommended" type="button">Recommended</button>
@@ -1738,7 +1871,8 @@
</div>
<p class="fine" data-i18n="transformResourcePresetHelp">Presets tune the temporary
memory ceiling, maximum transformable response size and global concurrent
transformation limit. Content types and extension allow-lists are left unchanged.</p>
transformation limit. Content types and extension allow-lists are left
unchanged.</p>
<div class="form-actions">
<button class="outline" data-i18n="transformPresetConservative"
data-transform-preset="conservative" type="button">RAM saver</button>
@@ -1753,10 +1887,12 @@
</div>
<p class="fine" id="transform-memory-warning" hidden></p>
<div class="field-grid">
<div><label data-i18n="transformMemoryBudget" for="transform-memory-budget">Temporary
<div><label data-i18n="transformMemoryBudget"
for="transform-memory-budget">Temporary
transform memory budget (MiB)</label><input id="transform-memory-budget"
max="2048" min="1" required="" step="1" type="number" /></div>
<div><label data-i18n="transformMaxBody" for="transform-max-body">Maximum transformable
<div><label data-i18n="transformMaxBody" for="transform-max-body">Maximum
transformable
response (KiB)</label><input id="transform-max-body" min="1" max="32768"
required="" type="number" /></div>
<div><label data-i18n="transformMaxInflight" for="transform-max-inflight">Maximum
@@ -1842,8 +1978,8 @@
<h2 data-i18n="loggingTitle">Structured access logging</h2>
</div><span class="pill live" data-i18n="hotApply">LIVE APPLY</span>
</div>
<nav aria-label="Logging settings" class="settings-tabs sub-tabs" id="logging-settings-tabs"
role="tablist">
<nav aria-label="Logging settings" class="settings-tabs sub-tabs"
id="logging-settings-tabs" role="tablist">
<button aria-selected="true" data-i18n="loggingTabOutputs"
data-logging-settings-tab="outputs" type="button">Outputs</button>
<button aria-selected="false" data-i18n="loggingTabFormat"
@@ -1853,34 +1989,47 @@
</nav>
<form class="logging-settings-subform" id="logging-form">
<div data-logging-settings-page="outputs">
<p class="fine" data-i18n="loggingHelp">Live log viewing stays in memory. External sinks
<p class="fine" data-i18n="loggingHelp">Live log viewing stays in memory.
External sinks
run on a bounded background queue and never block proxy request I/O.</p>
<article class="card soft-card">
<h3 class="subheading" data-i18n="loggingCaptureTitle">Captured request dimensions</h3>
<label class="check"><input id="log-capture-user-agent" type="checkbox" /><span data-i18n="loggingCaptureUserAgent">Capture User-Agent</span></label>
<label class="check"><input id="log-capture-country" type="checkbox" /><span data-i18n="loggingCaptureCountry">Capture country from GeoIP</span></label>
<p class="fine" data-i18n="loggingCaptureCountryHelp">Country lookup is optional and disabled by default. Enable it only when country logs/statistics are needed; country ACL/rate-limit rules still resolve GeoIP when required.</p>
<h3 class="subheading" data-i18n="loggingCaptureTitle">Captured request
dimensions</h3>
<label class="check"><input id="log-capture-user-agent"
type="checkbox" /><span data-i18n="loggingCaptureUserAgent">Capture
User-Agent</span></label>
<label class="check"><input id="log-capture-country" type="checkbox" /><span
data-i18n="loggingCaptureCountry">Capture country from
GeoIP</span></label>
<p class="fine" data-i18n="loggingCaptureCountryHelp">Country lookup is
optional and disabled by default. Enable it only when country
logs/statistics are needed; country ACL/rate-limit rules still resolve
GeoIP when required.</p>
</article>
<div class="logging-grid">
<article class="card">
<h3 class="subheading" data-i18n="loggingFile">JSON file</h3>
<label class="check"><input id="log-file-enabled" type="checkbox" /><span
<label class="check"><input id="log-file-enabled"
type="checkbox" /><span
data-i18n="enabled">Enabled</span></label>
<label class="check"><input id="log-file-per-vhost" type="checkbox" /><span
data-i18n="loggingPerVhost">Separate file per vhost</span></label>
<label><span data-i18n="loggingPath">File path</span><input id="log-file-path"
maxlength="1024"
<label class="check"><input id="log-file-per-vhost"
type="checkbox" /><span data-i18n="loggingPerVhost">Separate
file per vhost</span></label>
<label><span data-i18n="loggingPath">File path</span><input
id="log-file-path" maxlength="1024"
placeholder="/var/log/rustproxy/access.jsonl" /></label>
<label><span data-i18n="loggingDirectory">Per-vhost directory</span><input
id="log-file-directory" maxlength="1024"
<label><span data-i18n="loggingDirectory">Per-vhost
directory</span><input id="log-file-directory" maxlength="1024"
placeholder="/var/log/rustproxy/vhosts" /></label>
<p class="fine" data-i18n="loggingPerVhostHelp">When enabled, each vhost writes
<p class="fine" data-i18n="loggingPerVhostHelp">When enabled, each vhost
writes
to its
own JSONL file. Unmatched traffic uses _unmatched.jsonl.</p>
</article>
<article class="card">
<h3 class="subheading">Logstash</h3>
<label class="check"><input id="log-logstash-enabled" type="checkbox" /><span
<label class="check"><input id="log-logstash-enabled"
type="checkbox" /><span
data-i18n="enabled">Enabled</span></label>
<label><span data-i18n="loggingAddress">TCP address</span><input
id="log-logstash-address" maxlength="128"
@@ -1888,34 +2037,41 @@
</article>
<article class="card">
<h3 class="subheading">Loki</h3>
<label class="check"><input id="log-loki-enabled" type="checkbox" /><span
<label class="check"><input id="log-loki-enabled"
type="checkbox" /><span
data-i18n="enabled">Enabled</span></label>
<label><span data-i18n="loggingPushUrl">Push URL</span><input id="log-loki-url"
maxlength="2048"
<label><span data-i18n="loggingPushUrl">Push URL</span><input
id="log-loki-url" maxlength="2048"
placeholder="http://127.0.0.1:3100/loki/api/v1/push" /></label>
<label><span data-i18n="loggingTokenEnv">Bearer token environment
variable</span><input id="log-loki-token" maxlength="128"
placeholder="LOKI_TOKEN" /></label>
<label><span data-i18n="loggingLabels">Labels (JSON object)</span><textarea
id="log-loki-labels" rows="4" spellcheck="false"></textarea></label>
<label><span data-i18n="loggingLabels">Labels (JSON
object)</span><textarea id="log-loki-labels" rows="4"
spellcheck="false"></textarea></label>
</article>
</div>
<p class="fine" id="logging-status" role="status"></p>
</div>
<div data-logging-settings-page="format" hidden>
<h3 class="subheading" data-i18n="loggingFormat">Configurable JSON format</h3>
<p class="fine" data-i18n="loggingFormatHelp">Map output names to source fields. Leave
<p class="fine" data-i18n="loggingFormatHelp">Map output names to source fields.
Leave
mapping empty for the complete standard entry.</p>
<div class="logging-format-actions"><label><span data-i18n="loggingPreset">Format
<div class="logging-format-actions"><label><span
data-i18n="loggingPreset">Format
preset</span><select id="log-format-preset">
<option data-i18n="loggingPresetCustom" value="custom">Custom</option>
<option data-i18n="loggingPresetCustom" value="custom">Custom
</option>
<option data-i18n="loggingPresetStandard" value="standard">RustProxy
standard
</option>
<option data-i18n="loggingPresetNginx" value="nginx">Nginx-style JSON
<option data-i18n="loggingPresetNginx" value="nginx">Nginx-style
JSON
</option>
<option data-i18n="loggingPresetEcs" value="ecs">ECS-style</option>
<option data-i18n="loggingPresetMinimal" value="minimal">Minimal</option>
<option data-i18n="loggingPresetMinimal" value="minimal">Minimal
</option>
</select></label>
<div class="logging-builder-action"><span class="small-label"
data-i18n="loggingBuilder">Visual field builder</span><button
@@ -1924,7 +2080,8 @@
</div>
<details>
<summary data-i18n="loggingAdvancedJson">Advanced JSON mapping</summary>
<div class="field-grid"><label><span data-i18n="loggingFields">Field mapping (JSON
<div class="field-grid"><label><span data-i18n="loggingFields">Field mapping
(JSON
object)</span><textarea id="log-format-fields" rows="7"
spellcheck="false"></textarea></label>
<label><span data-i18n="loggingStatic">Static fields (JSON
@@ -1937,7 +2094,8 @@
type="submit">Validate, save
and apply</button></div>
</form>
<form class="logging-settings-subform" data-logging-settings-page="errors" hidden id="error-log-form">
<form class="logging-settings-subform" data-logging-settings-page="errors" hidden
id="error-log-form">
<h3 class="subheading" data-i18n="errorLogTitle">Error file</h3>
<label class="check"><input id="error-log-enabled" type="checkbox" /><span
data-i18n="enabled">Enabled</span></label>
@@ -1986,6 +2144,8 @@
data-i18n="systemDiagnostics"></span></label>
<label class="check"><input checked="" id="prom-vhosts" type="checkbox" /><span
data-i18n="vhosts"></span></label>
<label class="check"><input checked="" id="prom-traffic" type="checkbox" /><span
data-i18n="traffic"></span></label>
<label class="check"><input checked="" id="prom-backends" type="checkbox" /><span
data-i18n="vhBackendPool"></span></label>
<label class="check"><input checked="" id="prom-docker-vhosts"
@@ -2123,7 +2283,11 @@
<div class="card-heading">
<div><span class="small-label">DESIRED STATE / SQLITE</span>
<h2 data-i18n="manualConfig">Manual config</h2>
</div><div class="manual-config-tools"><button class="quiet" data-i18n="configHistory" id="config-history-open" type="button">Revision history</button><button class="outline" data-i18n="export" id="export-config" type="button">Export JSON</button></div>
</div>
<div class="manual-config-tools"><button class="quiet" data-i18n="configHistory"
id="config-history-open" type="button">Revision history</button><button
class="outline" data-i18n="export" id="export-config" type="button">Export
JSON</button></div>
</div>
<p class="fine" data-i18n="configHelp">A valid save creates one new revision. Invalid or
stale changes leave the running configuration untouched.</p>
@@ -2218,7 +2382,7 @@
</section>
</div>
</section>
<footer class="page-footer"><span>RustProxy <b>0.25.4</b></span><span id="footer-status">API - /
<footer class="page-footer"><span>RustProxy <b>0.25.5</b></span><span id="footer-status">API - /
dropped: 0</span></footer>
</div>
</main>
@@ -2359,21 +2523,31 @@
<div><span class="small-label" data-i18n="configHistoryRetention">SQLITE / LAST 512 REVISIONS</span>
<h2 data-i18n="configHistory" id="config-history-title">Revision history</h2>
</div>
<div class="window-tools"><button class="quiet" data-i18n="reload" id="config-history-reload" type="button">Reload</button><button class="outline" data-i18n="close" id="config-history-close" type="button">Close</button></div>
<div class="window-tools"><button class="quiet" data-i18n="reload" id="config-history-reload"
type="button">Reload</button><button class="outline" data-i18n="close" id="config-history-close"
type="button">Close</button></div>
</div>
<div class="config-history-layout">
<section class="card config-history-browser">
<p class="fine" data-i18n="configHistoryHelp">Select a marked day to inspect the saved configuration revisions and their changes.</p>
<p class="fine" data-i18n="configHistoryHelp">Select a marked day to inspect the saved configuration
revisions and their changes.</p>
<p class="fine" id="config-history-summary" role="status"></p>
<div class="config-history-months" id="config-history-calendar"></div>
</section>
<aside class="card config-history-detail">
<div class="config-history-detail-head">
<div><span class="small-label" data-i18n="configHistoryChanges">CHANGES</span><h3 id="config-history-detail-title">-</h3></div>
<div class="config-history-detail-actions"><span class="fine" id="config-history-detail-meta"></span><button class="outline" data-i18n="configHistoryRestoreAction" id="config-history-restore" type="button" hidden disabled>Restore revision</button></div>
<div><span class="small-label" data-i18n="configHistoryChanges">CHANGES</span>
<h3 id="config-history-detail-title">-</h3>
</div>
<div class="config-history-detail-actions"><span class="fine"
id="config-history-detail-meta"></span><button class="outline"
data-i18n="configHistoryRestoreAction" id="config-history-restore" type="button" hidden
disabled>Restore revision</button></div>
</div>
<div class="config-history-revisions" id="config-history-revisions"></div>
<div class="config-history-diff" id="config-history-diff"><p class="empty" data-i18n="configHistorySelectDay">Select a day.</p></div>
<div class="config-history-diff" id="config-history-diff">
<p class="empty" data-i18n="configHistorySelectDay">Select a day.</p>
</div>
</aside>
</div>
</dialog>
@@ -2500,79 +2674,174 @@
<option data-i18n="webServicePresetCustom" value="custom">Custom</option>
<option data-i18n="webServicePresetStatic" value="static">Static site</option>
<option data-i18n="webServicePresetSpa" value="spa">SPA</option>
<option data-i18n="webServicePresetPhpGeneric" value="php_generic">PHP generic</option>
<option data-i18n="webServicePresetPhpGeneric" value="php_generic">PHP generic
</option>
<option value="wordpress">WordPress</option>
<option value="drupal">Drupal</option>
<option value="laravel">Laravel</option>
<option value="symfony">Symfony</option>
<option value="joomla">Joomla</option>
<option value="prestashop">PrestaShop</option>
<option data-i18n="webServicePresetDownloads" value="downloads">Downloads / file repository</option>
<option data-i18n="webServicePresetDownloads" value="downloads">Downloads / file
repository</option>
</select></label>
<div class="vh-field"><span class="small-label" data-i18n="webServicePresetAction">Preset action</span><button class="outline" data-i18n="webServicePresetApply" id="web-service-preset-apply" type="button">Apply preset</button></div>
<label class="check"><input id="web-service-hardening-attacks" type="checkbox" /><span data-i18n="webServiceHardeningAttacks">Block common attack probes</span></label>
<label class="check"><input id="web-service-hardening-headers" type="checkbox" /><span data-i18n="webServiceHardeningHeaders">Strip unnecessary fingerprint headers</span></label>
<p class="fine vh-wide" data-i18n="webServicePresetHelp" id="web-service-preset-summary">Presets fill normal Web Service settings and remain fully editable. Document root, service ID/name and PHP-FPM endpoint are not replaced.</p>
<div class="vh-field"><span class="small-label" data-i18n="webServicePresetAction">Preset
action</span><button class="outline" data-i18n="webServicePresetApply"
id="web-service-preset-apply" type="button">Apply preset</button></div>
<label class="check"><input id="web-service-hardening-attacks" type="checkbox" /><span
data-i18n="webServiceHardeningAttacks">Block common attack probes</span></label>
<label class="check"><input id="web-service-hardening-headers" type="checkbox" /><span
data-i18n="webServiceHardeningHeaders">Strip unnecessary fingerprint
headers</span></label>
<p class="fine vh-wide" data-i18n="webServicePresetHelp" id="web-service-preset-summary">
Presets fill normal Web Service settings and remain fully editable. Document root,
service ID/name and PHP-FPM endpoint are not replaced.</p>
</div>
</fieldset>
<label><span data-i18n="webServiceId">Service ID</span><input id="web-service-id" maxlength="64" pattern="[A-Za-z0-9_-]{1,64}" required="" /></label>
<label><span data-i18n="webServiceName">Name</span><input id="web-service-name" maxlength="96" required="" /></label>
<label class="vh-wide"><span data-i18n="webServiceRoot">Document root</span><input id="web-service-root" maxlength="4096" placeholder="/var/www/main" required="" /></label>
<label class="vh-wide"><span data-i18n="webServiceIndex">Index files</span><textarea id="web-service-index" maxlength="4096" rows="5" spellcheck="false"></textarea><small class="fine" data-i18n="webServiceIndexHelp">Optional ordered index list. index.php may be used when PHP-FPM is enabled.</small></label>
<label class="check"><input id="web-service-listing" type="checkbox" /><span data-i18n="webServiceDirectoryListing">Directory listing</span></label>
<label><span data-i18n="webServiceId">Service ID</span><input id="web-service-id" maxlength="64"
pattern="[A-Za-z0-9_-]{1,64}" required="" /></label>
<label><span data-i18n="webServiceName">Name</span><input id="web-service-name" maxlength="96"
required="" /></label>
<label class="vh-wide"><span data-i18n="webServiceRoot">Document root</span><input
id="web-service-root" maxlength="4096" placeholder="/var/www/main" required="" /></label>
<label class="vh-wide"><span data-i18n="webServiceIndex">Index files</span><textarea
id="web-service-index" maxlength="4096" rows="5" spellcheck="false"></textarea><small
class="fine" data-i18n="webServiceIndexHelp">Optional ordered index list. index.php may be
used when PHP-FPM is enabled.</small></label>
<label class="check"><input id="web-service-listing" type="checkbox" /><span
data-i18n="webServiceDirectoryListing">Directory listing</span></label>
</div>
<div class="vh-grid web-service-page" data-web-service-page="routing" hidden>
<label class="vh-wide"><span data-i18n="webServiceTryFiles">try_files</span><textarea id="web-service-try-files" maxlength="8192" rows="7" spellcheck="false" placeholder="$uri&#10;$uri/&#10;/index.html"></textarea><small class="fine" data-i18n="webServiceTryFilesHelp">One candidate per line. Supports $uri, $query_string and a final =404/other error status.</small></label>
<label class="vh-wide"><span data-i18n="webServiceRewrites">Rewrite rules</span><textarea id="web-service-rewrites" maxlength="32768" rows="8" spellcheck="false" placeholder="^/old/(.*)$ => 301:/new/$1&#10;^/app$ => /index.html"></textarea><small class="fine" data-i18n="webServiceRewritesHelp">One regex rule per line. Use pattern =&gt; /target for an internal rewrite or pattern =&gt; 301:/target for a redirect.</small></label>
<label class="vh-wide"><span data-i18n="webServiceTryFiles">try_files</span><textarea
id="web-service-try-files" maxlength="8192" rows="7" spellcheck="false"
placeholder="$uri&#10;$uri/&#10;/index.html"></textarea><small class="fine"
data-i18n="webServiceTryFilesHelp">One candidate per line. Supports $uri, $query_string and
a final =404/other error status.</small></label>
<label class="vh-wide"><span data-i18n="webServiceRewrites">Rewrite rules</span><textarea
id="web-service-rewrites" maxlength="32768" rows="8" spellcheck="false"
placeholder="^/old/(.*)$ => 301:/new/$1&#10;^/app$ => /index.html"></textarea><small
class="fine" data-i18n="webServiceRewritesHelp">One regex rule per line. Use pattern =&gt;
/target for an internal rewrite or pattern =&gt; 301:/target for a redirect.</small></label>
</div>
<div class="vh-grid web-service-page" data-web-service-page="htaccess" hidden>
<label class="check"><input id="web-service-htaccess-enabled" type="checkbox" /><span data-i18n="webServiceHtaccessEnabled">Enable .htaccess compatibility</span></label>
<label><span data-i18n="webServiceHtaccessFilename">Configuration file</span><input id="web-service-htaccess-filename" maxlength="64" value=".htaccess" /></label>
<label class="check"><input id="web-service-htaccess-recursive" type="checkbox" checked /><span data-i18n="webServiceHtaccessRecursive">Scan subdirectories</span></label>
<label class="check"><input id="web-service-htaccess-strict" type="checkbox" /><span data-i18n="webServiceHtaccessStrict">Strict unsupported-directive mode</span></label>
<div class="vh-wide"><button class="outline" id="web-service-htaccess-help" data-i18n="webServiceHtaccessDocs" type="button">Compatibility documentation</button></div>
<p class="fine vh-wide" data-i18n="webServiceHtaccessHelp">RustProxy compiles a bounded Apache-compatible subset when the Web Service is applied. Files are not parsed on every request; apply the Web Service again after editing .htaccess on disk.</p>
<label class="check"><input id="web-service-htaccess-enabled" type="checkbox" /><span
data-i18n="webServiceHtaccessEnabled">Enable .htaccess compatibility</span></label>
<label><span data-i18n="webServiceHtaccessFilename">Configuration file</span><input
id="web-service-htaccess-filename" maxlength="64" value=".htaccess" /></label>
<label class="check"><input id="web-service-htaccess-recursive" type="checkbox" checked /><span
data-i18n="webServiceHtaccessRecursive">Scan subdirectories</span></label>
<label class="check"><input id="web-service-htaccess-strict" type="checkbox" /><span
data-i18n="webServiceHtaccessStrict">Strict unsupported-directive mode</span></label>
<div class="vh-wide"><button class="outline" id="web-service-htaccess-help"
data-i18n="webServiceHtaccessDocs" type="button">Compatibility documentation</button></div>
<p class="fine vh-wide" data-i18n="webServiceHtaccessHelp">RustProxy compiles a bounded
Apache-compatible subset when the Web Service is applied. Files are not parsed on every request;
apply the Web Service again after editing .htaccess on disk.</p>
</div>
<div class="vh-grid web-service-page" data-web-service-page="mime" hidden>
<label class="vh-wide"><span data-i18n="webServiceMimeOverrides">MIME overrides</span><textarea id="web-service-mime-types" maxlength="16384" rows="8" spellcheck="false" placeholder="wasm = application/wasm&#10;webp = image/webp"></textarea><small class="fine" data-i18n="webServiceMimeHelp">Optional extension-to-Content-Type overrides, one per line. Built-in mappings are used for all other known extensions.</small></label>
<label><span data-i18n="webServiceDefaultMime">Default MIME type</span><input id="web-service-default-mime" maxlength="256" value="application/octet-stream" required="" /></label>
<label class="vh-wide"><span data-i18n="webServiceMimeOverrides">MIME overrides</span><textarea
id="web-service-mime-types" maxlength="16384" rows="8" spellcheck="false"
placeholder="wasm = application/wasm&#10;webp = image/webp"></textarea><small class="fine"
data-i18n="webServiceMimeHelp">Optional extension-to-Content-Type overrides, one per line.
Built-in mappings are used for all other known extensions.</small></label>
<label><span data-i18n="webServiceDefaultMime">Default MIME type</span><input
id="web-service-default-mime" maxlength="256" value="application/octet-stream"
required="" /></label>
</div>
<div class="vh-grid web-service-page" data-web-service-page="cache" hidden>
<label><span data-i18n="webServiceCacheControl">Default Cache-Control</span><input id="web-service-cache-control" maxlength="1024" placeholder="public, max-age=3600" /></label>
<label class="vh-wide"><span data-i18n="webServiceCacheRules">Cache-Control rules</span><textarea id="web-service-cache-rules" maxlength="32768" rows="7" spellcheck="false" placeholder="ext:html = no-cache&#10;mime:image/ = public, max-age=604800"></textarea><small class="fine" data-i18n="webServiceCacheRulesHelp">Specific rules are evaluated in order before the default. Match ext:css or mime:image/.</small></label>
<label class="vh-wide"><span data-i18n="webServiceResponseHeaders">Response headers</span><textarea id="web-service-response-headers" maxlength="32768" rows="7" spellcheck="false" placeholder="X-Content-Type-Options = nosniff"></textarea><small class="fine" data-i18n="webServiceResponseHeadersHelp">Fixed headers for this service. Vhost/location response-header rules are applied later and can override them.</small></label>
<p class="fine vh-wide" data-i18n="webServiceCacheHelp">Local files use filesystem/OS page cache; this controls browser/proxy caching and does not create a second RustProxy object cache.</p>
<label><span data-i18n="webServiceCacheControl">Default Cache-Control</span><input
id="web-service-cache-control" maxlength="1024"
placeholder="public, max-age=3600" /></label>
<label class="vh-wide"><span data-i18n="webServiceCacheRules">Cache-Control rules</span><textarea
id="web-service-cache-rules" maxlength="32768" rows="7" spellcheck="false"
placeholder="ext:html = no-cache&#10;mime:image/ = public, max-age=604800"></textarea><small
class="fine" data-i18n="webServiceCacheRulesHelp">Specific rules are evaluated in order
before the default. Match ext:css or mime:image/.</small></label>
<label class="vh-wide"><span data-i18n="webServiceResponseHeaders">Response headers</span><textarea
id="web-service-response-headers" maxlength="32768" rows="7" spellcheck="false"
placeholder="X-Content-Type-Options = nosniff"></textarea><small class="fine"
data-i18n="webServiceResponseHeadersHelp">Fixed headers for this service. Vhost/location
response-header rules are applied later and can override them.</small></label>
<p class="fine vh-wide" data-i18n="webServiceCacheHelp">Local files use filesystem/OS page cache;
this controls browser/proxy caching and does not create a second RustProxy object cache.</p>
</div>
<div class="vh-grid web-service-page" data-web-service-page="delivery" hidden>
<label class="check"><input id="web-service-precompressed" type="checkbox" /><span data-i18n="webServicePrecompressed">Serve precompressed sidecars</span></label>
<label class="check"><input id="web-service-precompressed-br" type="checkbox" /><span data-i18n="webServicePrecompressedBr">Brotli (.br)</span></label>
<label class="check"><input id="web-service-precompressed-gzip" type="checkbox" /><span data-i18n="webServicePrecompressedGzip">Gzip (.gz)</span></label>
<label class="vh-wide"><span data-i18n="webServiceDownloadExtensions">Download extensions</span><textarea id="web-service-download-extensions" maxlength="4096" rows="5" spellcheck="false" placeholder="zip&#10;pdf&#10;tar"></textarea><small class="fine" data-i18n="webServiceDownloadExtensionsHelp">Files with these extensions receive Content-Disposition: attachment.</small></label>
<p class="fine vh-wide" data-i18n="webServiceRangeHelp">Static and precompressed representations keep validators, single-range requests, 206/416 and streaming delivery.</p>
<label class="check"><input id="web-service-precompressed" type="checkbox" /><span
data-i18n="webServicePrecompressed">Serve precompressed sidecars</span></label>
<label class="check"><input id="web-service-precompressed-br" type="checkbox" /><span
data-i18n="webServicePrecompressedBr">Brotli (.br)</span></label>
<label class="check"><input id="web-service-precompressed-gzip" type="checkbox" /><span
data-i18n="webServicePrecompressedGzip">Gzip (.gz)</span></label>
<label class="vh-wide"><span data-i18n="webServiceDownloadExtensions">Download
extensions</span><textarea id="web-service-download-extensions" maxlength="4096" rows="5"
spellcheck="false" placeholder="zip&#10;pdf&#10;tar"></textarea><small class="fine"
data-i18n="webServiceDownloadExtensionsHelp">Files with these extensions receive
Content-Disposition: attachment.</small></label>
<p class="fine vh-wide" data-i18n="webServiceRangeHelp">Static and precompressed representations
keep validators, single-range requests, 206/416 and streaming delivery.</p>
</div>
<div class="vh-grid web-service-page" data-web-service-page="php" hidden>
<label class="check"><input id="web-service-php-enabled" type="checkbox" /><span data-i18n="webServicePhpEnabled">Enable PHP-FPM / FastCGI</span></label>
<label class="vh-wide"><span data-i18n="webServicePhpEndpoint">PHP-FPM endpoint</span><input id="web-service-php-endpoint" maxlength="512" placeholder="unix:/run/php/php-fpm.sock" /><small class="fine" data-i18n="webServicePhpEndpointHelp">Unix: unix:/run/php/php-fpm.sock. TCP: tcp:127.0.0.1:9000 or tcp:[::1]:9000. TCP is intentionally limited to loopback addresses.</small></label>
<label><span data-i18n="webServicePhpExtensions">PHP extensions</span><input id="web-service-php-extensions" maxlength="512" placeholder="php" /></label>
<label class="check"><input id="web-service-php-pass-authorization" type="checkbox" /><span data-i18n="webServicePhpPassAuthorization">Forward Authorization header</span></label>
<label><span data-i18n="webServicePhpConnectTimeout">Connect timeout (ms)</span><input id="web-service-php-connect-timeout" min="50" max="60000" step="1" type="number" /></label>
<label><span data-i18n="webServicePhpRequestTimeout">Total FastCGI request timeout (ms)</span><input id="web-service-php-request-timeout" min="100" max="3600000" step="1" type="number" /></label>
<label><span data-i18n="webServicePhpConcurrency">Max concurrent PHP requests</span><input id="web-service-php-concurrency" min="1" max="4096" step="1" type="number" /></label>
<label><span data-i18n="webServicePhpBodyLimit">PHP request body limit (bytes, 0 = unlimited)</span><input id="web-service-php-body-limit" min="0" max="8589934592" step="1" type="number" /></label>
<label><span data-i18n="webServicePhpHeaderLimit">Max FastCGI response header bytes</span><input id="web-service-php-header-limit" min="4096" max="1048576" step="1" type="number" /></label>
<label><span data-i18n="webServicePhpStderrLimit">Max PHP-FPM stderr log bytes</span><input id="web-service-php-stderr-limit" min="0" max="65536" step="1" type="number" /></label>
<label class="vh-wide"><span data-i18n="webServicePhpCustomParams">Custom FastCGI parameters</span><textarea id="web-service-php-custom-params" maxlength="65536" rows="6" spellcheck="false" placeholder="APP_ENV = production&#10;APP_DEBUG = 0"></textarea><small class="fine" data-i18n="webServicePhpCustomParamsHelp">One NAME = value entry per line. Static trusted values only; core CGI, HTTP_* and PHP_* control names are reserved.</small></label>
<label class="vh-wide"><span data-i18n="webServicePhpValues">PHP INI overrides (PHP_VALUE)</span><textarea id="web-service-php-values" maxlength="65536" rows="6" spellcheck="false" placeholder="memory_limit = 256M&#10;upload_max_filesize = 64M&#10;post_max_size = 64M"></textarea><small class="fine" data-i18n="webServicePhpValuesHelp">One php.ini directive = value per line. RustProxy sends the validated set as PHP_VALUE; PHP_ADMIN_VALUE is never accepted here.</small></label>
<div class="vh-wide form-actions"><button class="outline" data-i18n="webServicePhpDocsButton" id="web-service-php-help" type="button">FastCGI syntax and safety</button></div>
<p class="fine vh-wide" data-i18n="webServicePhpHelp">RustProxy speaks FastCGI directly to a long-running PHP-FPM pool. PHP is not spawned per request and PHP source is never served when PHP handling is selected.</p>
<label class="check"><input id="web-service-php-enabled" type="checkbox" /><span
data-i18n="webServicePhpEnabled">Enable PHP-FPM / FastCGI</span></label>
<label class="vh-wide"><span data-i18n="webServicePhpEndpoint">PHP-FPM endpoint</span><input
id="web-service-php-endpoint" maxlength="512"
placeholder="unix:/run/php/php-fpm.sock" /><small class="fine"
data-i18n="webServicePhpEndpointHelp">Unix: unix:/run/php/php-fpm.sock. TCP:
tcp:127.0.0.1:9000 or tcp:[::1]:9000. TCP is intentionally limited to loopback
addresses.</small></label>
<label><span data-i18n="webServicePhpExtensions">PHP extensions</span><input
id="web-service-php-extensions" maxlength="512" placeholder="php" /></label>
<label class="check"><input id="web-service-php-pass-authorization" type="checkbox" /><span
data-i18n="webServicePhpPassAuthorization">Forward Authorization header</span></label>
<label><span data-i18n="webServicePhpConnectTimeout">Connect timeout (ms)</span><input
id="web-service-php-connect-timeout" min="50" max="60000" step="1" type="number" /></label>
<label><span data-i18n="webServicePhpRequestTimeout">Total FastCGI request timeout (ms)</span><input
id="web-service-php-request-timeout" min="100" max="3600000" step="1"
type="number" /></label>
<label><span data-i18n="webServicePhpConcurrency">Max concurrent PHP requests</span><input
id="web-service-php-concurrency" min="1" max="4096" step="1" type="number" /></label>
<label><span data-i18n="webServicePhpBodyLimit">PHP request body limit (bytes, 0 =
unlimited)</span><input id="web-service-php-body-limit" min="0" max="8589934592" step="1"
type="number" /></label>
<label><span data-i18n="webServicePhpHeaderLimit">Max FastCGI response header bytes</span><input
id="web-service-php-header-limit" min="4096" max="1048576" step="1" type="number" /></label>
<label><span data-i18n="webServicePhpStderrLimit">Max PHP-FPM stderr log bytes</span><input
id="web-service-php-stderr-limit" min="0" max="65536" step="1" type="number" /></label>
<label class="vh-wide"><span data-i18n="webServicePhpCustomParams">Custom FastCGI
parameters</span><textarea id="web-service-php-custom-params" maxlength="65536" rows="6"
spellcheck="false" placeholder="APP_ENV = production&#10;APP_DEBUG = 0"></textarea><small
class="fine" data-i18n="webServicePhpCustomParamsHelp">One NAME = value entry per line.
Static trusted values only; core CGI, HTTP_* and PHP_* control names are
reserved.</small></label>
<label class="vh-wide"><span data-i18n="webServicePhpValues">PHP INI overrides
(PHP_VALUE)</span><textarea id="web-service-php-values" maxlength="65536" rows="6"
spellcheck="false"
placeholder="memory_limit = 256M&#10;upload_max_filesize = 64M&#10;post_max_size = 64M"></textarea><small
class="fine" data-i18n="webServicePhpValuesHelp">One php.ini directive = value per line.
RustProxy sends the validated set as PHP_VALUE; PHP_ADMIN_VALUE is never accepted
here.</small></label>
<div class="vh-wide form-actions"><button class="outline" data-i18n="webServicePhpDocsButton"
id="web-service-php-help" type="button">FastCGI syntax and safety</button></div>
<p class="fine vh-wide" data-i18n="webServicePhpHelp">RustProxy speaks FastCGI directly to a
long-running PHP-FPM pool. PHP is not spawned per request and PHP source is never served when
PHP handling is selected.</p>
</div>
<div class="vh-grid web-service-page" data-web-service-page="security" hidden>
<label><span data-i18n="webServiceSymlinkPolicy">Symlink policy</span><select id="web-service-symlink-policy"><option data-i18n="webServiceSymlinkDeny" value="deny">Deny</option><option data-i18n="webServiceSymlinkInsideRoot" value="inside_root">Allow only inside document root (Linux)</option></select></label>
<label><span data-i18n="webServiceSymlinkPolicy">Symlink policy</span><select
id="web-service-symlink-policy">
<option data-i18n="webServiceSymlinkDeny" value="deny">Deny</option>
<option data-i18n="webServiceSymlinkInsideRoot" value="inside_root">Allow only inside
document root (Linux)</option>
</select></label>
<div class="vh-wide">
<span class="small-label" data-i18n="webServiceHardeningTitle">Request/response hardening</span>
<p class="fine" data-i18n="webServiceHardeningSecurityHelp">Hardening switches are shown beside the preset selector on General. They remain independent settings after a preset is applied.</p>
<p class="fine" data-i18n="webServiceHardeningSecurityHelp">Hardening switches are shown beside
the preset selector on General. They remain independent settings after a preset is applied.
</p>
</div>
<p class="fine vh-wide" data-i18n="webServiceSafety">Document root is a strict filesystem boundary. The default denies symlinks. Linux can optionally follow only targets that resolve inside the root; PHP-FPM currently requires the deny policy.</p>
<p class="fine vh-wide" data-i18n="webServiceSafety">Document root is a strict filesystem boundary.
The default denies symlinks. Linux can optionally follow only targets that resolve inside the
root; PHP-FPM currently requires the deny policy.</p>
</div>
</div>
<div class="form-actions">
@@ -2587,22 +2856,41 @@
<button class="outline" data-i18n="close" id="htaccess-help-close" type="button">Close</button>
</div>
<article class="card location-help-content">
<p data-i18n="webServiceHtaccessDocsIntro">This is a migration-oriented compatibility layer, not a complete Apache runtime. Supported directives are compiled into RustProxy Web Server rules.</p>
<p data-i18n="webServiceHtaccessDocsIntro">This is a migration-oriented compatibility layer, not a complete
Apache runtime. Supported directives are compiled into RustProxy Web Server rules.</p>
<h3 data-i18n="webServiceHtaccessDocsSupportedTitle">Supported subset</h3>
<p><code>RewriteEngine</code>, <code>RewriteBase</code>, <code>RewriteCond</code>, <code>RewriteRule</code>, <code>DirectoryIndex</code>, <code>Options +/-Indexes</code>, <code>AddType</code>, <code>AddHandler</code> (PHP extensions), <code>Header set/add/unset</code>, <code>ExpiresActive</code>, <code>ExpiresDefault</code>, <code>ExpiresByType</code>, <code>Require all granted/denied</code>.</p>
<p><code>RewriteEngine</code>, <code>RewriteBase</code>, <code>RewriteCond</code>, <code>RewriteRule</code>,
<code>DirectoryIndex</code>, <code>Options +/-Indexes</code>, <code>AddType</code>,
<code>AddHandler</code> (PHP extensions), <code>Header set/add/unset</code>, <code>ExpiresActive</code>,
<code>ExpiresDefault</code>, <code>ExpiresByType</code>, <code>Require all granted/denied</code>.
</p>
<h3 data-i18n="webServiceHtaccessDocsRewriteTitle">Rewrite compatibility</h3>
<p data-i18n="webServiceHtaccessDocsRewrite">Rewrite conditions support REQUEST_FILENAME file tests (-f, -d, -s), REQUEST_URI, REQUEST_METHOD, QUERY_STRING, HTTP_HOST, HTTPS, DOCUMENT_ROOT, THE_REQUEST and HTTP headers. Flags: L, END, R, R=301/302/307/308, QSA, NC, F, OR. PT/NE are accepted as no-ops. E= is accepted as a compatibility no-op because request HTTP headers already reach PHP-FPM.</p>
<p data-i18n="webServiceHtaccessDocsRewrite">Rewrite conditions support REQUEST_FILENAME file tests (-f, -d,
-s), REQUEST_URI, REQUEST_METHOD, QUERY_STRING, HTTP_HOST, HTTPS, DOCUMENT_ROOT, THE_REQUEST and HTTP
headers. Flags: L, END, R, R=301/302/307/308, QSA, NC, F, OR. PT/NE are accepted as no-ops. E= is
accepted as a compatibility no-op because request HTTP headers already reach PHP-FPM.</p>
<pre><code>RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.php [L]</code></pre>
<h3 data-i18n="webServiceHtaccessDocsPhpTitle">PHP-FPM</h3>
<p data-i18n="webServiceHtaccessDocsPhp">.htaccess may add PHP extensions with AddHandler, but the PHP-FPM endpoint, timeouts and concurrency remain controlled by the Web Service PHP-FPM tab. Apache SetHandler proxy socket syntax is intentionally not accepted.</p>
<p data-i18n="webServiceHtaccessDocsPhp">.htaccess may add PHP extensions with AddHandler, but the PHP-FPM
endpoint, timeouts and concurrency remain controlled by the Web Service PHP-FPM tab. Apache SetHandler
proxy socket syntax is intentionally not accepted.</p>
<h3 data-i18n="webServiceHtaccessDocsReloadTitle">Loading and limits</h3>
<p data-i18n="webServiceHtaccessDocsReload">Files are read and compiled on Validate, save and apply. RustProxy does not parse .htaccess per request. Re-apply after changing a file. The scan is bounded to 128 files, depth 16, 128 KiB per file and 1 MiB total.</p>
<p data-i18n="webServiceHtaccessDocsReload">Files are read and compiled on Validate, save and apply.
RustProxy does not parse .htaccess per request. Re-apply after changing a file. The scan is bounded to
128 files, depth 16, 128 KiB per file and 1 MiB total.</p>
<h3 data-i18n="webServiceHtaccessDocsUnsupportedTitle">Not Apache-complete</h3>
<p data-i18n="webServiceHtaccessDocsUnsupported">ErrorDocument, SetEnvIf, Redirect/RedirectMatch and FallbackResource are not implemented in this compatibility layer. General Files/FilesMatch, unsupported authentication/authorization directives, non-PHP handlers and SetHandler are rejected even when Strict mode is off because silently ignoring them could weaken access control or expose source. .htaccess/.htpasswd files are always blocked from HTTP delivery. Other unsupported non-security directives are logged and ignored unless Strict mode is enabled.</p>
<p class="fine" data-i18n="webServiceHtaccessDocsSecurity">Vhost/location ACL, authentication, error pages and response rules still remain authoritative. .htaccess cannot escape the document root, select an arbitrary FastCGI socket or override protected framing headers.</p>
<p data-i18n="webServiceHtaccessDocsUnsupported">ErrorDocument, SetEnvIf, Redirect/RedirectMatch and
FallbackResource are not implemented in this compatibility layer. General Files/FilesMatch, unsupported
authentication/authorization directives, non-PHP handlers and SetHandler are rejected even when Strict
mode is off because silently ignoring them could weaken access control or expose source.
.htaccess/.htpasswd files are always blocked from HTTP delivery. Other unsupported non-security
directives are logged and ignored unless Strict mode is enabled.</p>
<p class="fine" data-i18n="webServiceHtaccessDocsSecurity">Vhost/location ACL, authentication, error pages
and response rules still remain authoritative. .htaccess cannot escape the document root, select an
arbitrary FastCGI socket or override protected framing headers.</p>
</article>
</dialog>
<dialog aria-labelledby="php-help-title" class="vhost-dialog location-help-dialog" id="php-help-dialog">
@@ -2611,26 +2899,40 @@ RewriteRule . /index.php [L]</code></pre>
<button class="outline" data-i18n="close" id="php-help-close" type="button">Close</button>
</div>
<article class="card location-help-content">
<p data-i18n="webServicePhpDocsIntro">RustProxy connects directly to an existing PHP-FPM pool. Prefer a Unix socket on the same host. TCP is accepted only on loopback and is not a public/network FastCGI transport.</p>
<p data-i18n="webServicePhpDocsIntro">RustProxy connects directly to an existing PHP-FPM pool. Prefer a Unix
socket on the same host. TCP is accepted only on loopback and is not a public/network FastCGI transport.
</p>
<h3 data-i18n="webServicePhpDocsEndpointTitle">Endpoint syntax</h3>
<pre><code>unix:/run/php/php-fpm.sock
tcp:127.0.0.1:9000
tcp:[::1]:9000</code></pre>
<p class="fine" data-i18n="webServicePhpDocsEndpoint">For TCP, include the tcp: prefix, a literal loopback IP and port. Hostnames and non-loopback addresses are rejected. For Unix sockets, RustProxy must have permission to connect to the socket.</p>
<p class="fine" data-i18n="webServicePhpDocsEndpoint">For TCP, include the tcp: prefix, a literal loopback
IP and port. Hostnames and non-loopback addresses are rejected. For Unix sockets, RustProxy must have
permission to connect to the socket.</p>
<h3 data-i18n="webServicePhpDocsCustomTitle">Custom FastCGI parameters</h3>
<pre><code>APP_ENV = production
APP_DEBUG = 0
TENANT = main</code></pre>
<p data-i18n="webServicePhpDocsCustom">Use one NAME = value entry per line. Names are normalized to uppercase and may contain A-Z, 0-9 and underscore. These are static trusted operator values. RustProxy rejects core CGI names, HTTP_* names, PHP_VALUE and PHP_ADMIN_VALUE so they cannot override request routing, script selection or HTTP-derived parameters.</p>
<p data-i18n="webServicePhpDocsCustom">Use one NAME = value entry per line. Names are normalized to
uppercase and may contain A-Z, 0-9 and underscore. These are static trusted operator values. RustProxy
rejects core CGI names, HTTP_* names, PHP_VALUE and PHP_ADMIN_VALUE so they cannot override request
routing, script selection or HTTP-derived parameters.</p>
<h3 data-i18n="webServicePhpDocsValuesTitle">PHP INI overrides</h3>
<pre><code>memory_limit = 256M
upload_max_filesize = 64M
post_max_size = 64M
max_execution_time = 30</code></pre>
<p data-i18n="webServicePhpDocsValues">These lines are combined into the FastCGI PHP_VALUE parameter. PHP-FPM/PHP still decides which directives are changeable at this scope. Newlines/control characters are rejected and PHP_ADMIN_VALUE is intentionally unavailable.</p>
<p data-i18n="webServicePhpDocsValues">These lines are combined into the FastCGI PHP_VALUE parameter.
PHP-FPM/PHP still decides which directives are changeable at this scope. Newlines/control characters are
rejected and PHP_ADMIN_VALUE is intentionally unavailable.</p>
<h3 data-i18n="webServicePhpDocsLimitsTitle">Limits and headers</h3>
<p data-i18n="webServicePhpDocsLimits">Request timeout covers the FastCGI exchange. The body limit is enforced while streaming and returns 413 when exceeded; 0 disables the RustProxy-side body limit. Response-header and stderr limits bound memory/diagnostic data. Authorization forwarding can be disabled for applications that do not need HTTP_AUTHORIZATION.</p>
<p class="fine" data-i18n="webServicePhpDocsSecurity">Do not put secrets in custom parameters or PHP_VALUE unless you accept storing them in normal Web Service configuration. Use a dedicated non-root PHP-FPM pool identity and keep PHP-FPM off public networks.</p>
<p data-i18n="webServicePhpDocsLimits">Request timeout covers the FastCGI exchange. The body limit is
enforced while streaming and returns 413 when exceeded; 0 disables the RustProxy-side body limit.
Response-header and stderr limits bound memory/diagnostic data. Authorization forwarding can be disabled
for applications that do not need HTTP_AUTHORIZATION.</p>
<p class="fine" data-i18n="webServicePhpDocsSecurity">Do not put secrets in custom parameters or PHP_VALUE
unless you accept storing them in normal Web Service configuration. Use a dedicated non-root PHP-FPM
pool identity and keep PHP-FPM off public networks.</p>
</article>
</dialog>
<dialog aria-labelledby="vhost-editor-title" class="vhost-dialog" id="vhost-dialog">
@@ -2783,6 +3085,33 @@ max_execution_time = 30</code></pre>
<p class="fine" data-i18n="streamRestartHelp">Changing a stream listener requires a RustProxy restart.</p>
</form>
</dialog>
<dialog aria-labelledby="about-title" class="vhost-dialog about-dialog" id="about-dialog">
<div class="window-heading">
<h2 data-i18n="aboutTitle" id="about-title">About RustProxy</h2>
<button class="outline" data-i18n="close" id="about-close" type="button">Close</button>
</div>
<article class="card about-card">
<div class="about-product">
<span aria-hidden="true" class="brand-mark">r<span>/</span>p</span>
<div><span class="small-label">RustProxy</span><strong>0.25.5</strong></div>
</div>
<dl class="about-meta">
<div>
<dt data-i18n="aboutAuthor">Author</dt>
<dd>Mateusz Gruszczynski @linuxiarz.pl</dd>
</div>
<div>
<dt data-i18n="aboutLicense">License</dt>
<dd data-i18n="aboutLicenseValue">Open Source</dd>
</div>
</dl>
<p data-i18n="aboutOpenSourceText">RustProxy is an open-source project. Its source code is publicly
available for inspection, development and community contribution.</p>
<a class="outline about-repository" href="https://git.linuxiarz.pl/gru/RustProxy" rel="noopener noreferrer"
target="_blank"><span data-i18n="aboutRepository">Source
repository</span><span>git.linuxiarz.pl/gru/RustProxy</span></a>
</article>
</dialog>
<div aria-live="polite" class="toast" hidden="" id="toast" popover="manual" role="status"></div>
<dialog aria-labelledby="logging-format-title" class="vhost-dialog logging-format-dialog"
id="logging-format-dialog">
+3 -3
View File
@@ -126,7 +126,7 @@ curl --fail --show-error --request POST \
catch (error) { $("certificate-api-status").textContent = error.message; }
finally { certificateApiLoading = false; }
}
const defaults = { enabled: false, listen: "127.0.0.1:9191", path: "/metrics", allowed_cidrs: ["127.0.0.0/8", "::1/128"], bearer_token_env: null, allow_trusted_network_without_token: false, include_system: true, include_vhosts: true, include_backends: true, include_docker_vhosts: true, include_cache_domains: true, include_streams: true, include_logging: true };
const defaults = { enabled: false, listen: "127.0.0.1:9191", path: "/metrics", allowed_cidrs: ["127.0.0.0/8", "::1/128"], bearer_token_env: null, allow_trusted_network_without_token: false, include_system: true, include_vhosts: true, include_traffic: true, include_backends: true, include_docker_vhosts: true, include_cache_domains: true, include_streams: true, include_logging: true };
function example() {
const addr = $("prom-listen").value.trim(), path = $("prom-path").value.trim(), env = $("prom-token-env").value.trim();
$("prom-scrape-example").textContent = `scrape_configs:\n - job_name: rustproxy\n metrics_path: ${JSON.stringify(path)}\n static_configs:\n - targets: [${JSON.stringify(addr)}]${env ? "\n authorization:\n credentials_file: /etc/prometheus/rustproxy.token" : ""}`;
@@ -136,7 +136,7 @@ curl --fail --show-error --request POST \
if (!settingsDirty.has("prometheus-form")) {
$("prom-enabled").checked = p.enabled; $("prom-listen").value = p.listen; $("prom-path").value = p.path;
$("prom-cidrs").value = p.allowed_cidrs.join("\n"); $("prom-token-env").value = p.bearer_token_env || ""; $("prom-trusted-no-token").checked = !!p.allow_trusted_network_without_token;
for (const [id, key] of [["system", "include_system"], ["vhosts", "include_vhosts"], ["backends", "include_backends"], ["docker-vhosts", "include_docker_vhosts"], ["domains", "include_cache_domains"], ["streams", "include_streams"], ["logging", "include_logging"]]) $(`prom-${id}`).checked = p[key];
for (const [id, key] of [["system", "include_system"], ["vhosts", "include_vhosts"], ["traffic", "include_traffic"], ["backends", "include_backends"], ["docker-vhosts", "include_docker_vhosts"], ["domains", "include_cache_domains"], ["streams", "include_streams"], ["logging", "include_logging"]]) $(`prom-${id}`).checked = p[key];
}
const status = latestStatus?.prometheus;
$("prom-status").textContent = status?.restart_required ? t("restartPending") : status?.active?.enabled ? `${t("prometheusActive")}: ${status.active.listen}${p.path}` : t("prometheusDisabled"); example();
@@ -170,7 +170,7 @@ curl --fail --show-error --request POST \
bindSettingsForm("prometheus-form", next => {
next.prometheus = {
enabled: $("prom-enabled").checked, listen: $("prom-listen").value.trim(), path: $("prom-path").value.trim(), allowed_cidrs: $("prom-cidrs").value.split(/[\n,]/).map(v => v.trim()).filter(Boolean), bearer_token_env: $("prom-token-env").value.trim() || null, allow_trusted_network_without_token: $("prom-trusted-no-token").checked,
include_system: $("prom-system").checked, include_vhosts: $("prom-vhosts").checked, include_backends: $("prom-backends").checked, include_docker_vhosts: $("prom-docker-vhosts").checked, include_cache_domains: $("prom-domains").checked, include_streams: $("prom-streams").checked, include_logging: $("prom-logging").checked
include_system: $("prom-system").checked, include_vhosts: $("prom-vhosts").checked, include_traffic: $("prom-traffic").checked, include_backends: $("prom-backends").checked, include_docker_vhosts: $("prom-docker-vhosts").checked, include_cache_domains: $("prom-domains").checked, include_streams: $("prom-streams").checked, include_logging: $("prom-logging").checked
};
}, "prometheus");
bindSettingsForm("administration-form", next => {
+229 -27
View File
@@ -3920,7 +3920,8 @@ body[data-density="dense"] .stat strong {
dialog.vhost-dialog,
dialog.logs-dialog,
dialog.audit-dialog,
dialog.certificate-dialog {
dialog.certificate-dialog,
dialog.config-history-dialog {
border-radius: 0;
background: var(--bg);
color: var(--text);
@@ -4692,11 +4693,16 @@ dialog .window-heading {
}
@media(max-width:1100px) {
.traffic-wide-chart { grid-column: span 1 }
.traffic-wide-chart {
grid-column: span 1
}
}
@media(max-width:650px) {
.traffic-bar-row { grid-template-columns: minmax(88px, 1.2fr) minmax(90px, 2fr) minmax(60px, auto); gap: 7px }
.traffic-bar-row {
grid-template-columns: minmax(88px, 1.2fr) minmax(90px, 2fr) minmax(60px, auto);
gap: 7px
}
}
.logging-grid label {
@@ -4720,6 +4726,7 @@ dialog .window-heading {
}
@media(max-width:720px) {
.traffic-chart-grid,
.logging-grid {
grid-template-columns: 1fr
@@ -6778,7 +6785,6 @@ body[data-density="compact"] .page-footer {
}
}
/* Certificates workspace (0.20.0): use the same tab language as Settings. */
#panel-tls .certificate-workspace-heading {
align-items: flex-start;
margin-bottom: 12px
@@ -7031,7 +7037,6 @@ body[data-density="compact"] .page-footer {
}
}
/* Inline operation feedback mirrors the login feedback pattern and remains local to the action. */
.operation-message:empty {
display: none;
}
@@ -7330,7 +7335,6 @@ body[data-density="compact"] .page-footer {
white-space: normal;
}
/* 0.24.3 dashboard refinements */
.payload-split {
display: flex !important;
align-items: center;
@@ -7515,7 +7519,7 @@ body[data-density="compact"] .page-footer {
color: var(--notice-text);
font-weight: 600;
}
/* 0.24.8 cache occupancy charts and compact memory-accounting help */
.mini-heading-actions {
display: inline-flex;
align-items: center;
@@ -7607,7 +7611,6 @@ body[data-density="compact"] .page-footer {
color: var(--transform-chart);
}
/* Web Service editor mirrors the vhost tabbed configuration layout. */
.web-service-pages {
min-width: 0;
}
@@ -7628,21 +7631,10 @@ body[data-density="compact"] .page-footer {
}
.config-history-dialog {
color: var(--text);
background: var(--bg);
border: 1px solid var(--line);
padding: 0;
width: min(1180px, calc(100vw - 24px));
max-width: calc(100vw - 24px);
max-height: calc(100dvh - 28px);
overflow: hidden;
border-radius: 7px;
box-shadow: 0 18px 70px var(--shadow-strong);
}
.config-history-dialog::backdrop {
background: var(--shadow-strong);
backdrop-filter: blur(2px);
}
.config-history-layout {
@@ -7667,7 +7659,6 @@ body[data-density="compact"] .page-footer {
.config-history-month {
border: 1px solid var(--line);
border-radius: 6px;
padding: 9px;
background: var(--panel);
}
@@ -7695,7 +7686,6 @@ body[data-density="compact"] .page-footer {
min-height: 34px;
padding: 4px;
border: 1px solid transparent;
border-radius: 4px;
background: transparent;
color: var(--muted);
font: 10px var(--mono);
@@ -7769,7 +7759,6 @@ button.config-history-day.is-selected {
.config-history-change {
border: 1px solid var(--line);
border-radius: 5px;
padding: 8px;
background: var(--panel);
}
@@ -7797,9 +7786,222 @@ button.config-history-day.is-selected {
}
@media (max-width: 860px) {
.config-history-layout { grid-template-columns: 1fr; overflow: auto; }
.config-history-browser, .config-history-detail { overflow: visible; }
.config-history-change-values { grid-template-columns: 1fr; }
.config-history-detail-head { flex-direction: column; }
.config-history-detail-actions { align-items: flex-start; text-align: left; }
.config-history-layout {
grid-template-columns: 1fr;
overflow: auto;
}
.config-history-browser,
.config-history-detail {
overflow: visible;
}
.config-history-change-values {
grid-template-columns: 1fr;
}
.config-history-detail-head {
flex-direction: column;
}
.config-history-detail-actions {
align-items: flex-start;
text-align: left;
}
}
.mobile-menu-toggle {
display: none;
}
@media(max-width:850px) {
.sidebar {
position: sticky;
top: 0;
z-index: 60;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px 12px;
padding: 10px 12px;
}
.sidebar .brand {
min-width: 0;
}
.mobile-menu-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 9px;
min-height: 36px;
padding: 7px 10px;
border: 1px solid var(--line);
background: transparent;
color: var(--muted);
font: 600 11px var(--mono);
}
.mobile-menu-toggle:hover,
.mobile-menu-toggle[aria-expanded="true"] {
color: var(--text);
border-color: var(--border-hover);
background: var(--nav-active-bg);
}
.mobile-menu-icon {
display: block;
flex: 0 0 auto;
width: 15px;
height: 1px;
background: currentColor;
box-shadow: 0 -5px currentColor, 0 5px currentColor;
}
.sidebar .main-navigation {
grid-column: 1 / -1;
display: none;
width: 100%;
margin: 0;
padding-top: 10px;
border-top: 1px solid var(--line);
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
}
.sidebar.mobile-nav-open .main-navigation {
display: grid;
}
.sidebar .main-navigation .nav {
width: 100%;
min-width: 0;
justify-content: flex-start;
gap: 8px;
padding: 9px 10px;
border: 1px solid transparent;
border-left: 2px solid transparent;
text-align: left;
}
.sidebar .main-navigation .nav.active {
border-color: var(--line);
border-left-color: var(--green);
background: var(--nav-active-bg);
}
}
@media(max-width:600px) {
.sidebar .main-navigation .nav span {
display: none;
}
}
.about-dialog {
width: min(560px, calc(100vw - 24px));
overflow: hidden;
}
.about-dialog .window-heading button {
margin-inline-start: auto;
}
.about-card {
display: grid;
gap: 16px;
margin: 0;
padding: 18px;
border: 0;
box-shadow: none;
}
.about-product {
display: flex;
align-items: center;
gap: 12px;
}
.about-product>div {
display: grid;
gap: 4px;
}
.about-product strong {
font: 600 16px var(--mono);
}
.about-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1px;
margin: 0;
border: 1px solid var(--line);
background: var(--line);
}
.about-meta>div {
min-width: 0;
padding: 10px 12px;
background: var(--panel);
}
.about-meta dt {
margin-bottom: 5px;
color: var(--muted);
font: 9px var(--mono);
letter-spacing: .8px;
text-transform: uppercase;
}
.about-meta dd {
margin: 0;
overflow-wrap: anywhere;
}
.about-card>p {
margin: 0;
color: var(--muted);
line-height: 1.65;
}
.about-repository {
justify-content: space-between;
gap: 12px;
width: 100%;
white-space: normal;
text-align: left;
}
.about-repository span:last-child {
color: var(--muted);
font: 10px var(--mono);
overflow-wrap: anywhere;
}
@media(max-width:620px) {
.topbar {
height: auto;
min-height: 46px;
padding: 7px 0;
}
.topbar .breadcrumb {
display: none;
}
.top-actions {
width: 100%;
flex-wrap: wrap;
justify-content: flex-end;
}
.about-meta {
grid-template-columns: 1fr;
}
.about-repository {
align-items: flex-start;
flex-direction: column;
}
}
+2 -2
View File
@@ -9,8 +9,8 @@ use argon2::{
Algorithm, Argon2, Params, Version,
};
pub use files::{
load_or_create_setup_token, load_setup_token, lock_directory, prepare_database, try_lock_directory,
prepare_directory,
load_or_create_setup_token, load_setup_token, lock_directory, prepare_database,
prepare_directory, try_lock_directory,
};
use http::{header::COOKIE, HeaderMap, StatusCode};
use rand::{rngs::OsRng, RngCore};
+6 -5
View File
@@ -68,10 +68,7 @@ impl Store {
self.create_session_with_lifetime(generation, "browser", previous, session_seconds, 60)
.await
}
pub async fn create_live_log_session(
&self,
generation: i64,
) -> Result<Option<SessionToken>> {
pub async fn create_live_log_session(&self, generation: i64) -> Result<Option<SessionToken>> {
self.create_session_with_lifetime(generation, "cli", None, SESSION_SECONDS, 8 * 60 * 60)
.await
}
@@ -87,7 +84,11 @@ impl Store {
let secret = random_secret();
let hash = secret_hash(&secret);
let csrf = random_secret();
let lifetime = if kind == "cli" { cli_lifetime } else { browser_lifetime };
let lifetime = if kind == "cli" {
cli_lifetime
} else {
browser_lifetime
};
let mut tx = self.pool.begin().await?;
let valid = sqlx::query(
"UPDATE administrator SET generation = generation WHERE id = 1 AND generation = ?",
+35 -9
View File
@@ -144,7 +144,10 @@ fn harden_tree(root: &Path, path: &Path) -> Result<()> {
if metadata.file_type().is_symlink() {
let resolved = fs::canonicalize(path).context("restored symlink target is missing")?;
let canonical_root = fs::canonicalize(root)?;
ensure!(resolved.starts_with(&canonical_root), "restored symlink escapes the data directory");
ensure!(
resolved.starts_with(&canonical_root),
"restored symlink escapes the data directory"
);
return Ok(());
}
#[cfg(unix)]
@@ -164,7 +167,10 @@ fn harden_tree(root: &Path, path: &Path) -> Result<()> {
fn private_dir(path: &Path) -> Result<()> {
fs::create_dir_all(path)?;
let meta = fs::symlink_metadata(path)?;
ensure!(meta.is_dir() && !meta.file_type().is_symlink(), "backup directory must not be a symlink");
ensure!(
meta.is_dir() && !meta.file_type().is_symlink(),
"backup directory must not be a symlink"
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
@@ -337,13 +343,19 @@ fn encrypt_archive(input: &Path, output: &Path, crypto: &CryptoContext) -> Resul
fn decrypt_archive(input: &Path, output: &Path, password: &str) -> Result<Zeroizing<[u8; 32]>> {
let metadata = fs::symlink_metadata(input)?;
ensure!(metadata.is_file() && !metadata.file_type().is_symlink(), "backup must be a regular file");
ensure!(
metadata.is_file() && !metadata.file_type().is_symlink(),
"backup must be a regular file"
);
let mut source = File::open(input)?;
let mut magic = [0u8; 8];
source.read_exact(&mut magic)?;
ensure!(&magic == BACKUP_MAGIC, "not a RustProxy backup");
let header_len = read_u32(&mut source)? as usize;
ensure!(header_len > 0 && header_len <= MAX_HEADER_BYTES, "invalid backup header length");
ensure!(
header_len > 0 && header_len <= MAX_HEADER_BYTES,
"invalid backup header length"
);
let mut header_bytes = vec![0u8; header_len];
source.read_exact(&mut header_bytes)?;
let crypto = parse_crypto_header(header_bytes, password)?;
@@ -372,7 +384,10 @@ fn decrypt_archive(input: &Path, output: &Path, password: &str) -> Result<Zeroiz
counter = counter.checked_add(1).context("backup is too large")?;
}
let mut extra = [0u8; 1];
ensure!(source.read(&mut extra)? == 0, "trailing data after encrypted backup");
ensure!(
source.read(&mut extra)? == 0,
"trailing data after encrypted backup"
);
target.sync_all()?;
Ok(crypto.key)
}
@@ -398,7 +413,10 @@ fn seal_master_key(master: &[u8], backup_key: &[u8]) -> Result<Vec<u8>> {
}
fn open_master_key(blob: &[u8], backup_key: &[u8]) -> Result<Zeroizing<Vec<u8>>> {
ensure!(blob.len() == 4 + 12 + 32 + AES_256_GCM.tag_len(), "invalid wrapped secret store key");
ensure!(
blob.len() == 4 + 12 + 32 + AES_256_GCM.tag_len(),
"invalid wrapped secret store key"
);
ensure!(&blob[..4] == KEY_MAGIC, "invalid wrapped secret store key");
let mut nonce_bytes = [0u8; 12];
nonce_bytes.copy_from_slice(&blob[4..16]);
@@ -416,7 +434,10 @@ fn open_master_key(blob: &[u8], backup_key: &[u8]) -> Result<Zeroizing<Vec<u8>>>
fn hash_file(path: &Path) -> Result<(u64, String)> {
let metadata = fs::symlink_metadata(path)?;
ensure!(metadata.is_file() && !metadata.file_type().is_symlink(), "backup input must be a regular file");
ensure!(
metadata.is_file() && !metadata.file_type().is_symlink(),
"backup input must be a regular file"
);
let mut file = File::open(path)?;
let mut hasher = Sha256::new();
let mut size = 0u64;
@@ -426,7 +447,9 @@ fn hash_file(path: &Path) -> Result<(u64, String)> {
if read == 0 {
break;
}
size = size.checked_add(read as u64).context("backup input too large")?;
size = size
.checked_add(read as u64)
.context("backup input too large")?;
hasher.update(&buffer[..read]);
}
Ok((size, format!("{:x}", hasher.finalize())))
@@ -437,5 +460,8 @@ fn hash_bytes(bytes: &[u8]) -> String {
}
fn excluded_directory(name: &str) -> bool {
matches!(name, "cache" | "logs" | "tmp" | "temp" | "work" | "acme-http01")
matches!(
name,
"cache" | "logs" | "tmp" | "temp" | "work" | "acme-http01"
)
}
+64 -21
View File
@@ -29,7 +29,10 @@ fn backup_input(path: &Path) -> Result<File> {
options.custom_flags(libc::O_NOFOLLOW);
}
let file = options.open(path)?;
ensure!(file.metadata()?.is_file(), "backup input must be a regular file");
ensure!(
file.metadata()?.is_file(),
"backup input must be a regular file"
);
Ok(file)
}
@@ -65,7 +68,9 @@ fn copy_snapshot_file(source: &Path, target: &Path) -> Result<(u64, String)> {
}
output.write_all(&buffer[..read])?;
hasher.update(&buffer[..read]);
size = size.checked_add(read as u64).context("backup input too large")?;
size = size
.checked_add(read as u64)
.context("backup input too large")?;
}
output.sync_all()?;
let after = input.metadata()?;
@@ -82,7 +87,10 @@ fn symlink_target_bytes(target: &Path) -> Result<Vec<u8>> {
{
use std::os::unix::ffi::OsStrExt;
let bytes = target.as_os_str().as_bytes().to_vec();
ensure!(!bytes.is_empty() && bytes.len() <= 4096, "invalid symlink target");
ensure!(
!bytes.is_empty() && bytes.len() <= 4096,
"invalid symlink target"
);
return Ok(bytes);
}
#[cfg(not(unix))]
@@ -92,13 +100,19 @@ fn symlink_target_bytes(target: &Path) -> Result<Vec<u8>> {
.context("symlink target must be valid UTF-8")?
.as_bytes()
.to_vec();
ensure!(!bytes.is_empty() && bytes.len() <= 4096, "invalid symlink target");
ensure!(
!bytes.is_empty() && bytes.len() <= 4096,
"invalid symlink target"
);
Ok(bytes)
}
}
fn symlink_target_path(bytes: &[u8]) -> Result<PathBuf> {
ensure!(!bytes.is_empty() && bytes.len() <= 4096, "invalid symlink target");
ensure!(
!bytes.is_empty() && bytes.len() <= 4096,
"invalid symlink target"
);
#[cfg(unix)]
{
use std::os::unix::ffi::OsStringExt;
@@ -106,22 +120,33 @@ fn symlink_target_path(bytes: &[u8]) -> Result<PathBuf> {
}
#[cfg(not(unix))]
{
Ok(PathBuf::from(std::str::from_utf8(bytes).context("symlink target is not UTF-8")?))
Ok(PathBuf::from(
std::str::from_utf8(bytes).context("symlink target is not UTF-8")?,
))
}
}
fn validate_symlink_target(link_relative: &Path, target: &Path) -> Result<()> {
ensure!(!target.is_absolute(), "backup symlink target must be relative");
let mut depth = link_relative.parent().map_or(0usize, |parent| parent.components().count());
ensure!(
!target.is_absolute(),
"backup symlink target must be relative"
);
let mut depth = link_relative
.parent()
.map_or(0usize, |parent| parent.components().count());
for component in target.components() {
match component {
Component::Normal(_) => depth = depth.checked_add(1).context("symlink target too deep")?,
Component::Normal(_) => {
depth = depth.checked_add(1).context("symlink target too deep")?
}
Component::CurDir => {}
Component::ParentDir => {
ensure!(depth > 0, "backup symlink escapes the data directory");
depth -= 1;
}
Component::RootDir | Component::Prefix(_) => bail!("backup symlink target must be relative"),
Component::RootDir | Component::Prefix(_) => {
bail!("backup symlink target must be relative")
}
}
}
Ok(())
@@ -152,7 +177,10 @@ fn collect_data_files(
let path = item.path();
let name = item.file_name();
let name_text = name.to_string_lossy();
if excluded_file(&name_text) || excluded_directory(&name_text) || path == root.join("secrets") {
if excluded_file(&name_text)
|| excluded_directory(&name_text)
|| path == root.join("secrets")
{
continue;
}
let meta = fs::symlink_metadata(&path)?;
@@ -215,30 +243,45 @@ fn validate_snapshot_symlinks(snapshot_root: &Path, symlinks: &[PathBuf]) -> Res
for link in symlinks {
let resolved = fs::canonicalize(link)
.with_context(|| format!("backup symlink target is missing: {}", link.display()))?;
ensure!(resolved.starts_with(&root), "backup symlink escapes the data directory: {}", link.display());
ensure!(
resolved.starts_with(&root),
"backup symlink escapes the data directory: {}",
link.display()
);
}
Ok(())
}
fn validate_snapshot_config(envelope: &crate::config::Envelope, snapshot_root: &Path) -> Result<()> {
fn validate_snapshot_config(
envelope: &crate::config::Envelope,
snapshot_root: &Path,
) -> Result<()> {
let cert_dir = snapshot_root.join("certs");
for certificate in &envelope.config.tls.certificates {
for name in [&certificate.cert_file, &certificate.key_file] {
crate::tls::safe_name(name)?;
let path = cert_dir.join(name);
let meta = fs::symlink_metadata(&path)
.with_context(|| format!("configured certificate file changed during backup: {name}"))?;
ensure!(meta.is_file() && !meta.file_type().is_symlink(), "configured certificate file is not a regular file: {name}");
let meta = fs::symlink_metadata(&path).with_context(|| {
format!("configured certificate file changed during backup: {name}")
})?;
ensure!(
meta.is_file() && !meta.file_type().is_symlink(),
"configured certificate file is not a regular file: {name}"
);
}
}
if envelope.config.administration.tls && envelope.config.administration.certificate_id.is_none() {
if envelope.config.administration.tls && envelope.config.administration.certificate_id.is_none()
{
for name in ["default.pem", "default.key"] {
let path = cert_dir.join(name);
let meta = fs::symlink_metadata(&path)
.with_context(|| format!("default administration certificate changed during backup: {name}"))?;
ensure!(meta.is_file() && !meta.file_type().is_symlink(), "default administration certificate is not a regular file: {name}");
let meta = fs::symlink_metadata(&path).with_context(|| {
format!("default administration certificate changed during backup: {name}")
})?;
ensure!(
meta.is_file() && !meta.file_type().is_symlink(),
"default administration certificate is not a regular file: {name}"
);
}
}
Ok(())
}
+5 -4
View File
@@ -68,7 +68,8 @@ mod tests {
restore(&archive, &restored, "correct horse battery staple")
.await
.unwrap();
let restored_link = restored.join("certs/acme/production-linuxiarz/config/live/rustproxy/fullchain.pem");
let restored_link =
restored.join("certs/acme/production-linuxiarz/config/live/rustproxy/fullchain.pem");
assert_eq!(
fs::read_link(&restored_link).unwrap(),
PathBuf::from("../../archive/rustproxy/fullchain1.pem")
@@ -88,8 +89,8 @@ mod tests {
Path::new("../../../etc/passwd")
)
.is_err());
assert!(validate_symlink_target(Path::new("certs/link.pem"), Path::new("/etc/passwd"))
.is_err());
assert!(
validate_symlink_target(Path::new("certs/link.pem"), Path::new("/etc/passwd")).is_err()
);
}
}
+130 -34
View File
@@ -2,13 +2,19 @@ fn write_inner_archive(path: &Path, manifest: &Manifest, entries: &[ArchiveEntry
let mut file = private_file_options().create_new(true).open(path)?;
file.write_all(ARCHIVE_MAGIC)?;
let manifest_bytes = serde_json::to_vec(manifest)?;
ensure!(manifest_bytes.len() <= MAX_MANIFEST_BYTES, "backup manifest is too large");
ensure!(
manifest_bytes.len() <= MAX_MANIFEST_BYTES,
"backup manifest is too large"
);
file.write_all(&(manifest_bytes.len() as u32).to_be_bytes())?;
file.write_all(&manifest_bytes)?;
let mut buffer = [0u8; 1024 * 1024];
for entry in entries {
let path_bytes = entry.manifest.path.as_bytes();
ensure!(!path_bytes.is_empty() && path_bytes.len() <= u16::MAX as usize, "backup path is too long");
ensure!(
!path_bytes.is_empty() && path_bytes.len() <= u16::MAX as usize,
"backup path is too long"
);
file.write_all(&(path_bytes.len() as u16).to_be_bytes())?;
file.write_all(path_bytes)?;
file.write_all(&entry.manifest.size.to_be_bytes())?;
@@ -23,7 +29,10 @@ fn write_inner_archive(path: &Path, manifest: &Manifest, entries: &[ArchiveEntry
file.write_all(&buffer[..read])?;
remaining -= read as u64;
}
ensure!(input.read(&mut buffer[..1])? == 0, "backup snapshot changed while being archived");
ensure!(
input.read(&mut buffer[..1])? == 0,
"backup snapshot changed while being archived"
);
}
EntrySource::Bytes(bytes) => file.write_all(bytes)?,
}
@@ -49,11 +58,17 @@ fn read_u64<R: Read>(reader: &mut R) -> Result<u64> {
}
fn safe_relative(path: &str) -> Result<PathBuf> {
ensure!(!path.is_empty() && path.len() <= 4096 && !path.contains('\\') && !path.contains(':'), "unsafe backup path");
ensure!(
!path.is_empty() && path.len() <= 4096 && !path.contains('\\') && !path.contains(':'),
"unsafe backup path"
);
let value = Path::new(path);
ensure!(!value.is_absolute(), "unsafe absolute backup path");
for component in value.components() {
ensure!(matches!(component, Component::Normal(_)), "unsafe backup path component");
ensure!(
matches!(component, Component::Normal(_)),
"unsafe backup path component"
);
}
Ok(value.to_path_buf())
}
@@ -61,35 +76,61 @@ fn safe_relative(path: &str) -> Result<PathBuf> {
fn validate_manifest_entries(manifest: &Manifest) -> Result<()> {
let mut kinds = std::collections::HashMap::new();
for entry in &manifest.entries {
ensure!(kinds.insert(entry.path.clone(), entry.kind).is_none(), "duplicate backup entry");
ensure!(
kinds.insert(entry.path.clone(), entry.kind).is_none(),
"duplicate backup entry"
);
if entry.path == "keys/secret-store-key.enc" {
ensure!(entry.kind == EntryKind::File, "invalid Secret Store key entry type");
ensure!(
entry.kind == EntryKind::File,
"invalid Secret Store key entry type"
);
continue;
}
let relative = entry.path.strip_prefix("data/").context("unsupported backup entry")?;
let relative = entry
.path
.strip_prefix("data/")
.context("unsupported backup entry")?;
safe_relative(relative)?;
if manifest.archive_version == 1 {
ensure!(entry.kind == EntryKind::File, "legacy backup contains unsupported entry type");
ensure!(
entry.kind == EntryKind::File,
"legacy backup contains unsupported entry type"
);
}
match entry.kind {
EntryKind::Directory => ensure!(entry.size == 0 && entry.sha256 == hash_bytes(&[]), "invalid directory backup entry"),
EntryKind::Symlink => ensure!(entry.size > 0 && entry.size <= 4096, "invalid symlink backup entry"),
EntryKind::Directory => ensure!(
entry.size == 0 && entry.sha256 == hash_bytes(&[]),
"invalid directory backup entry"
),
EntryKind::Symlink => ensure!(
entry.size > 0 && entry.size <= 4096,
"invalid symlink backup entry"
),
EntryKind::File => {}
}
}
for (path, kind) in &kinds {
let Some(relative) = path.strip_prefix("data/") else { continue };
let Some(relative) = path.strip_prefix("data/") else {
continue;
};
let relative = Path::new(relative);
let mut parent = relative.parent();
while let Some(value) = parent.filter(|value| !value.as_os_str().is_empty()) {
let ancestor = format!("data/{}", value.to_string_lossy().replace('\\', "/"));
if let Some(ancestor_kind) = kinds.get(&ancestor) {
ensure!(*ancestor_kind == EntryKind::Directory, "backup entry has a non-directory ancestor");
ensure!(
*ancestor_kind == EntryKind::Directory,
"backup entry has a non-directory ancestor"
);
}
parent = value.parent();
}
if *kind == EntryKind::Symlink {
ensure!(manifest.archive_version >= 2, "legacy backup cannot contain symlinks");
ensure!(
manifest.archive_version >= 2,
"legacy backup cannot contain symlinks"
);
}
}
Ok(())
@@ -99,27 +140,49 @@ fn extract_inner_archive(path: &Path, stage: &Path) -> Result<(Manifest, Vec<u8>
let mut file = File::open(path)?;
let mut magic = [0u8; 8];
file.read_exact(&mut magic)?;
ensure!(&magic == ARCHIVE_MAGIC, "invalid decrypted RustProxy backup payload");
ensure!(
&magic == ARCHIVE_MAGIC,
"invalid decrypted RustProxy backup payload"
);
let manifest_len = read_u32(&mut file)? as usize;
ensure!(manifest_len > 0 && manifest_len <= MAX_MANIFEST_BYTES, "invalid backup manifest length");
ensure!(
manifest_len > 0 && manifest_len <= MAX_MANIFEST_BYTES,
"invalid backup manifest length"
);
let mut manifest_bytes = vec![0u8; manifest_len];
file.read_exact(&mut manifest_bytes)?;
let manifest: Manifest = serde_json::from_slice(&manifest_bytes).context("invalid backup manifest")?;
ensure!((1..=ARCHIVE_VERSION).contains(&manifest.archive_version), "unsupported backup archive version");
let manifest: Manifest =
serde_json::from_slice(&manifest_bytes).context("invalid backup manifest")?;
ensure!(
(1..=ARCHIVE_VERSION).contains(&manifest.archive_version),
"unsupported backup archive version"
);
validate_backup_version(&manifest.rustproxy_version)?;
ensure!(manifest.schema_version <= crate::db::SCHEMA_VERSION, "backup database schema is newer than this RustProxy binary");
ensure!(manifest.entries.len() <= 100_000, "backup contains too many files");
ensure!(
manifest.schema_version <= crate::db::SCHEMA_VERSION,
"backup database schema is newer than this RustProxy binary"
);
ensure!(
manifest.entries.len() <= 100_000,
"backup contains too many files"
);
validate_manifest_entries(&manifest)?;
let mut wrapped_key = None;
let mut pending_symlinks = Vec::new();
let mut buffer = vec![0u8; 1024 * 1024];
for expected in &manifest.entries {
let path_len = read_u16(&mut file)? as usize;
ensure!(path_len > 0 && path_len <= 4096, "invalid backup path length");
ensure!(
path_len > 0 && path_len <= 4096,
"invalid backup path length"
);
let mut path_bytes = vec![0u8; path_len];
file.read_exact(&mut path_bytes)?;
let archive_path = String::from_utf8(path_bytes).context("backup path is not UTF-8")?;
ensure!(archive_path == expected.path, "backup manifest entry order/path mismatch");
ensure!(
archive_path == expected.path,
"backup manifest entry order/path mismatch"
);
let size = read_u64(&mut file)?;
ensure!(size == expected.size, "backup manifest size mismatch");
let mut hasher = Sha256::new();
@@ -130,7 +193,9 @@ fn extract_inner_archive(path: &Path, stage: &Path) -> Result<(Manifest, Vec<u8>
hasher.update(&bytes);
wrapped_key = Some(bytes);
} else {
let relative_text = archive_path.strip_prefix("data/").context("unsupported backup entry")?;
let relative_text = archive_path
.strip_prefix("data/")
.context("unsupported backup entry")?;
let relative = safe_relative(relative_text)?;
let target = stage.join(&relative);
match expected.kind {
@@ -158,10 +223,16 @@ fn extract_inner_archive(path: &Path, stage: &Path) -> Result<(Manifest, Vec<u8>
}
}
}
ensure!(format!("{:x}", hasher.finalize()) == expected.sha256, "backup entry checksum mismatch");
ensure!(
format!("{:x}", hasher.finalize()) == expected.sha256,
"backup entry checksum mismatch"
);
}
let mut extra = [0u8; 1];
ensure!(file.read(&mut extra)? == 0, "trailing data in backup payload");
ensure!(
file.read(&mut extra)? == 0,
"trailing data in backup payload"
);
for (relative, bytes) in pending_symlinks {
let target = symlink_target_path(&bytes)?;
validate_symlink_target(&relative, &target)?;
@@ -172,7 +243,10 @@ fn extract_inner_archive(path: &Path, stage: &Path) -> Result<(Manifest, Vec<u8>
create_symlink(&target, &link)?;
}
let key = wrapped_key.context("backup is missing Secret Store key material")?;
ensure!(stage.join("config.sqlite3").is_file(), "backup is missing database");
ensure!(
stage.join("config.sqlite3").is_file(),
"backup is missing database"
);
Ok((manifest, key))
}
@@ -180,7 +254,11 @@ pub async fn create(data_dir: &Path, output: &Path, password: &str) -> Result<Ba
crate::auth::prepare_directory(data_dir)?;
let offline_lock = crate::auth::try_lock_directory(data_dir)?;
let database = data_dir.join("config.sqlite3");
ensure!(database.is_file(), "database does not exist at {}", database.display());
ensure!(
database.is_file(),
"database does not exist at {}",
database.display()
);
crate::auth::prepare_database(&database)?;
let store = crate::db::Store::open(&database, &crate::config::Config::default()).await?;
crate::secret_store::initialize(data_dir)?;
@@ -190,8 +268,12 @@ pub async fn create(data_dir: &Path, output: &Path, password: &str) -> Result<Ba
}
let parent = parent_or_dot(output);
let data_root = fs::canonicalize(data_dir).context("cannot resolve data directory")?;
let output_parent = fs::canonicalize(parent).context("cannot resolve backup output directory")?;
ensure!(!output_parent.starts_with(&data_root), "backup output must be outside the RustProxy data directory");
let output_parent =
fs::canonicalize(parent).context("cannot resolve backup output directory")?;
ensure!(
!output_parent.starts_with(&data_root),
"backup output must be outside the RustProxy data directory"
);
let temp = create_temp_root(parent, "rustproxy-backup")?;
let snapshot = temp.0.join("config.sqlite3");
let snapshot_root = temp.0.join("data-snapshot");
@@ -204,7 +286,13 @@ pub async fn create(data_dir: &Path, output: &Path, password: &str) -> Result<Ba
let envelope = store.backup_to(&snapshot).await?;
let mut entries = Vec::new();
let mut symlinks = Vec::new();
let result = collect_data_files(data_dir, data_dir, &snapshot_root, &mut entries, &mut symlinks)
let result = collect_data_files(
data_dir,
data_dir,
&snapshot_root,
&mut entries,
&mut symlinks,
)
.and_then(|_| validate_snapshot_symlinks(&snapshot_root, &symlinks))
.and_then(|_| validate_snapshot_config(&envelope, &snapshot_root));
match result {
@@ -216,7 +304,8 @@ pub async fn create(data_dir: &Path, output: &Path, password: &str) -> Result<Ba
}
}
let (envelope, mut entries) = captured.ok_or_else(|| {
last_error.unwrap_or_else(|| anyhow::anyhow!("could not capture a stable live data snapshot"))
last_error
.unwrap_or_else(|| anyhow::anyhow!("could not capture a stable live data snapshot"))
})?;
let crypto = new_crypto(password)?;
let master = crate::secret_store::read_master_key_for_backup(data_dir)?;
@@ -265,7 +354,10 @@ pub async fn create(data_dir: &Path, output: &Path, password: &str) -> Result<Ba
pub async fn restore(archive: &Path, data_dir: &Path, password: &str) -> Result<BackupSummary> {
let archive_meta = fs::symlink_metadata(archive).context("backup archive does not exist")?;
ensure!(archive_meta.is_file() && !archive_meta.file_type().is_symlink(), "backup archive must be a regular file");
ensure!(
archive_meta.is_file() && !archive_meta.file_type().is_symlink(),
"backup archive must be a regular file"
);
let parent = parent_or_dot(data_dir);
ensure!(parent.is_dir(), "restore parent directory does not exist");
let old_lock = if data_dir.exists() {
@@ -287,7 +379,10 @@ pub async fn restore(archive: &Path, data_dir: &Path, password: &str) -> Result<
let stage_db = stage.join("config.sqlite3");
crate::auth::prepare_database(&stage_db)?;
let store = crate::db::Store::open(&stage_db, &crate::config::Config::default()).await?;
let envelope = store.load().await.context("restored configuration is unreadable")?;
let envelope = store
.load()
.await
.context("restored configuration is unreadable")?;
crate::secret_store::validate_all(&store, &stage)
.await
.context("restored Secret Store cannot be decrypted")?;
@@ -310,7 +405,8 @@ pub async fn restore(archive: &Path, data_dir: &Path, password: &str) -> Result<
}
drop(old_lock);
if previous.exists() {
fs::remove_dir_all(&previous).context("restore succeeded but old data directory could not be removed")?;
fs::remove_dir_all(&previous)
.context("restore succeeded but old data directory could not be removed")?;
}
drop(stage_lock);
Ok(BackupSummary {
+41 -14
View File
@@ -24,10 +24,26 @@ impl BackendState {
latency_us: 0,
ewma_us: 0,
transitions: 0,
unhealthy_source: if mode == HealthMode::Off { "none" } else { "active" },
unhealthy_reason: if mode == HealthMode::Off { "none" } else { "checking" },
active_unhealthy_source: if mode == HealthMode::Off { "none" } else { "active" },
active_unhealthy_reason: if mode == HealthMode::Off { "none" } else { "checking" },
unhealthy_source: if mode == HealthMode::Off {
"none"
} else {
"active"
},
unhealthy_reason: if mode == HealthMode::Off {
"none"
} else {
"checking"
},
active_unhealthy_source: if mode == HealthMode::Off {
"none"
} else {
"active"
},
active_unhealthy_reason: if mode == HealthMode::Off {
"none"
} else {
"checking"
},
passive_unhealthy_source: "none",
passive_unhealthy_reason: "none",
}),
@@ -169,8 +185,8 @@ impl BackendState {
return;
}
let current_source_is_still_unhealthy =
(h.unhealthy_source.starts_with("active") && !h.active_healthy)
let current_source_is_still_unhealthy = (h.unhealthy_source.starts_with("active")
&& !h.active_healthy)
|| (h.unhealthy_source.starts_with("passive") && !h.passive_healthy);
if current_source_is_still_unhealthy {
return;
@@ -202,7 +218,8 @@ impl BackendState {
h.active_healthy = true;
}
} else {
self.active_health_failures_total.fetch_add(1, Ordering::Relaxed);
self.active_health_failures_total
.fetch_add(1, Ordering::Relaxed);
h.successes = 0;
h.failures = h.failures.saturating_add(1);
if h.failures >= settings.fall {
@@ -260,11 +277,21 @@ impl BackendState {
fn record_passive_observation(&self, reason: &'static str) {
match reason {
"http_5xx" => { self.http_5xx_total.fetch_add(1, Ordering::Relaxed); }
"timeout" => { self.timeouts_total.fetch_add(1, Ordering::Relaxed); }
"connect_error" => { self.connect_errors_total.fetch_add(1, Ordering::Relaxed); }
"io_error" => { self.io_errors_total.fetch_add(1, Ordering::Relaxed); }
"slow_response" => { self.slow_responses_total.fetch_add(1, Ordering::Relaxed); }
"http_5xx" => {
self.http_5xx_total.fetch_add(1, Ordering::Relaxed);
}
"timeout" => {
self.timeouts_total.fetch_add(1, Ordering::Relaxed);
}
"connect_error" => {
self.connect_errors_total.fetch_add(1, Ordering::Relaxed);
}
"io_error" => {
self.io_errors_total.fetch_add(1, Ordering::Relaxed);
}
"slow_response" => {
self.slow_responses_total.fetch_add(1, Ordering::Relaxed);
}
_ => {}
}
}
@@ -301,7 +328,8 @@ impl BackendState {
if !passive.counts(reason) {
return;
}
self.passive_health_failures_total.fetch_add(1, Ordering::Relaxed);
self.passive_health_failures_total
.fetch_add(1, Ordering::Relaxed);
h.passive_successes = 0;
h.passive_failures = h.passive_failures.saturating_add(1);
if h.passive_failures >= passive.fall(active) {
@@ -325,7 +353,6 @@ impl BackendState {
}
self.finish_transition(&mut h, previous);
}
}
#[derive(Debug)]
pub struct Member {
+99 -25
View File
@@ -26,7 +26,9 @@ impl Lease {
|| !self.passive_health.count_slow_responses)
{
if is_slow {
self.member.state.record_passive_observation("slow_response");
self.member
.state
.record_passive_observation("slow_response");
}
"http_5xx"
} else if is_slow {
@@ -62,7 +64,10 @@ impl Lease {
self.member.state.record_failure(class, detail);
let passive_reason = match class {
"connect" | "dns" | "tls" => "connect_error",
"timeout" | "body_timeout" | "response_header_timeout" | "read_timeout"
"timeout"
| "body_timeout"
| "response_header_timeout"
| "read_timeout"
| "send_timeout" => "timeout",
_ => "io_error",
};
@@ -147,33 +152,102 @@ impl Pool {
let _ = writeln!(text, "rust_proxy_backend_{metric}{{{labels}}} {value}");
}
for (metric, value) in [
("active_health_failures_total", state.active_health_failures_total.load(Ordering::Relaxed)),
("passive_health_failures_total", state.passive_health_failures_total.load(Ordering::Relaxed)),
("http_5xx_total", state.http_5xx_total.load(Ordering::Relaxed)),
("passive_http_5xx_total", state.http_5xx_total.load(Ordering::Relaxed)),
("timeouts_total", state.timeouts_total.load(Ordering::Relaxed)),
("passive_timeouts_total", state.timeouts_total.load(Ordering::Relaxed)),
("connect_errors_total", state.connect_errors_total.load(Ordering::Relaxed)),
("passive_connect_errors_total", state.connect_errors_total.load(Ordering::Relaxed)),
("io_errors_total", state.io_errors_total.load(Ordering::Relaxed)),
("passive_io_errors_total", state.io_errors_total.load(Ordering::Relaxed)),
("slow_responses_total", state.slow_responses_total.load(Ordering::Relaxed)),
("passive_slow_total", state.slow_responses_total.load(Ordering::Relaxed)),
("retry_attempts_total", state.retry_attempts_total.load(Ordering::Relaxed)),
("retry_successes_total", state.retry_successes_total.load(Ordering::Relaxed)),
("capacity_rejections_total", state.capacity_rejections_total.load(Ordering::Relaxed)),
("queue_timeouts_total", state.queue_timeouts_total.load(Ordering::Relaxed)),
("dns_errors_total", state.dns_errors_total.load(Ordering::Relaxed)),
("tls_errors_total", state.tls_errors_total.load(Ordering::Relaxed)),
("response_header_timeouts_total", state.response_header_timeouts_total.load(Ordering::Relaxed)),
("read_timeouts_total", state.read_timeouts_total.load(Ordering::Relaxed)),
("send_timeouts_total", state.send_timeouts_total.load(Ordering::Relaxed)),
(
"active_health_failures_total",
state.active_health_failures_total.load(Ordering::Relaxed),
),
(
"passive_health_failures_total",
state.passive_health_failures_total.load(Ordering::Relaxed),
),
(
"http_5xx_total",
state.http_5xx_total.load(Ordering::Relaxed),
),
(
"passive_http_5xx_total",
state.http_5xx_total.load(Ordering::Relaxed),
),
(
"timeouts_total",
state.timeouts_total.load(Ordering::Relaxed),
),
(
"passive_timeouts_total",
state.timeouts_total.load(Ordering::Relaxed),
),
(
"connect_errors_total",
state.connect_errors_total.load(Ordering::Relaxed),
),
(
"passive_connect_errors_total",
state.connect_errors_total.load(Ordering::Relaxed),
),
(
"io_errors_total",
state.io_errors_total.load(Ordering::Relaxed),
),
(
"passive_io_errors_total",
state.io_errors_total.load(Ordering::Relaxed),
),
(
"slow_responses_total",
state.slow_responses_total.load(Ordering::Relaxed),
),
(
"passive_slow_total",
state.slow_responses_total.load(Ordering::Relaxed),
),
(
"retry_attempts_total",
state.retry_attempts_total.load(Ordering::Relaxed),
),
(
"retry_successes_total",
state.retry_successes_total.load(Ordering::Relaxed),
),
(
"capacity_rejections_total",
state.capacity_rejections_total.load(Ordering::Relaxed),
),
(
"queue_timeouts_total",
state.queue_timeouts_total.load(Ordering::Relaxed),
),
(
"dns_errors_total",
state.dns_errors_total.load(Ordering::Relaxed),
),
(
"tls_errors_total",
state.tls_errors_total.load(Ordering::Relaxed),
),
(
"response_header_timeouts_total",
state.response_header_timeouts_total.load(Ordering::Relaxed),
),
(
"read_timeouts_total",
state.read_timeouts_total.load(Ordering::Relaxed),
),
(
"send_timeouts_total",
state.send_timeouts_total.load(Ordering::Relaxed),
),
] {
let _ = writeln!(text, "rust_proxy_backend_{metric}{{{labels}}} {value}");
}
for (metric, value) in [
("last_ejected_timestamp_milliseconds", state.last_ejected_ms.load(Ordering::Relaxed)),
("last_recovered_timestamp_milliseconds", state.last_recovered_ms.load(Ordering::Relaxed)),
(
"last_ejected_timestamp_milliseconds",
state.last_ejected_ms.load(Ordering::Relaxed),
),
(
"last_recovered_timestamp_milliseconds",
state.last_recovered_ms.load(Ordering::Relaxed),
),
] {
let _ = writeln!(text, "rust_proxy_backend_{metric}{{{labels}}} {value}");
}
+12 -3
View File
@@ -241,10 +241,16 @@ impl Settings {
let _: HeaderValue = host.parse()?;
}
if let Some(fall) = self.passive_health.fall {
ensure!((1..=20).contains(&fall), "passive health fall must be 1..20");
ensure!(
(1..=20).contains(&fall),
"passive health fall must be 1..20"
);
}
if let Some(rise) = self.passive_health.rise {
ensure!((1..=20).contains(&rise), "passive health rise must be 1..20");
ensure!(
(1..=20).contains(&rise),
"passive health rise must be 1..20"
);
}
ensure!(
(1..=3600).contains(&k.idle_timeout_seconds)
@@ -252,7 +258,10 @@ impl Settings {
&& (1..=64).contains(&k.max_idle_per_host),
"invalid keepalive limits"
);
ensure!(self.retry.max_retries <= 3, "retry max_retries must be 0..3");
ensure!(
self.retry.max_retries <= 3,
"retry max_retries must be 0..3"
);
ensure!(
self.retry.retry_statuses.len() <= 8
&& self
+2 -4
View File
@@ -53,10 +53,8 @@ impl Pool {
};
let same_origin = serde_json::to_vec(&member.definition.backend()).ok()
== serde_json::to_vec(&old.definition.backend()).ok();
let same_health = serde_json::to_vec(&(
&self.settings.health_check,
&self.settings.passive_health,
))
let same_health =
serde_json::to_vec(&(&self.settings.health_check, &self.settings.passive_health))
.ok()
== serde_json::to_vec(&(
&previous.settings.health_check,
+24 -6
View File
@@ -31,15 +31,24 @@ impl Pool {
"max_concurrent_requests".into(),
json!(m.definition.max_concurrent_requests),
);
item.insert("queue_timeout_ms".into(), json!(m.definition.queue_timeout_ms));
item.insert(
"queue_timeout_ms".into(),
json!(m.definition.queue_timeout_ms),
);
item.insert("status".into(), json!(status));
item.insert("healthy".into(), json!(h.healthy));
item.insert("active".into(), json!(m.state.active.load(Ordering::Relaxed)));
item.insert(
"active".into(),
json!(m.state.active.load(Ordering::Relaxed)),
);
item.insert(
"requests".into(),
json!(m.state.requests.load(Ordering::Relaxed)),
);
item.insert("errors".into(), json!(m.state.errors.load(Ordering::Relaxed)));
item.insert(
"errors".into(),
json!(m.state.errors.load(Ordering::Relaxed)),
);
item.insert(
"bytes_in".into(),
json!(m.state.bytes_in.load(Ordering::Relaxed)),
@@ -48,7 +57,10 @@ impl Pool {
"bytes_out".into(),
json!(m.state.bytes_out.load(Ordering::Relaxed)),
);
item.insert("traffic_bytes_per_second".into(), json!(m.state.traffic_rate()));
item.insert(
"traffic_bytes_per_second".into(),
json!(m.state.traffic_rate()),
);
item.insert("latency_ms".into(), json!(h.latency_us as f64 / 1000.0));
item.insert("ewma_latency_ms".into(), json!(h.ewma_us as f64 / 1000.0));
item.insert("last_probe_ms".into(), json!(h.last_probe_ms));
@@ -107,7 +119,10 @@ impl Pool {
);
item.insert(
"response_header_timeouts_total".into(),
json!(m.state.response_header_timeouts_total.load(Ordering::Relaxed)),
json!(m
.state
.response_header_timeouts_total
.load(Ordering::Relaxed)),
);
item.insert(
"read_timeouts_total".into(),
@@ -134,7 +149,10 @@ impl Pool {
);
item.insert(
"passive_health_failures_total".into(),
json!(m.state.passive_health_failures_total.load(Ordering::Relaxed)),
json!(m
.state
.passive_health_failures_total
.load(Ordering::Relaxed)),
);
item.insert(
"last_status".into(),
+39 -29
View File
@@ -36,7 +36,8 @@
}],
"load_balancing":{"health_check":{"mode":"off"}}
}]
})).unwrap();
}))
.unwrap();
let policy = crate::config::ValidationPolicy {
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
admin_addr: "127.0.0.1:9090".parse().unwrap(),
@@ -86,7 +87,9 @@
#[test]
fn traffic_rate_drops_to_zero_after_an_idle_sample_window() {
let state = BackendState::new(HealthMode::Off);
state.bytes_out.store(2048, std::sync::atomic::Ordering::Relaxed);
state
.bytes_out
.store(2048, std::sync::atomic::Ordering::Relaxed);
lock(&state.traffic).at = std::time::Instant::now() - std::time::Duration::from_secs(2);
state.sample_traffic();
assert!(state.traffic_rate() > 0.0);
@@ -127,7 +130,10 @@
}
assert!(state.healthy());
assert_eq!(state.http_5xx_total.load(Ordering::Relaxed), 10);
assert_eq!(state.passive_health_failures_total.load(Ordering::Relaxed), 0);
assert_eq!(
state.passive_health_failures_total.load(Ordering::Relaxed),
0
);
let passive = PassiveHealth {
enabled: true,
@@ -155,16 +161,14 @@
..Default::default()
};
for _ in 0..100 {
state.observe_passive(
&active,
&passive,
"http_5xx",
Duration::from_millis(5),
);
state.observe_passive(&active, &passive, "http_5xx", Duration::from_millis(5));
}
assert!(state.healthy());
assert_eq!(state.http_5xx_total.load(Ordering::Relaxed), 100);
assert_eq!(state.passive_health_failures_total.load(Ordering::Relaxed), 0);
assert_eq!(
state.passive_health_failures_total.load(Ordering::Relaxed),
0
);
}
#[test]
@@ -178,23 +182,16 @@
..Default::default()
};
for _ in 0..2 {
state.observe_passive(
&active,
&passive,
"connect_error",
Duration::from_millis(5),
);
state.observe_passive(&active, &passive, "connect_error", Duration::from_millis(5));
assert!(state.healthy());
}
state.observe_passive(
&active,
&passive,
"connect_error",
Duration::from_millis(5),
);
state.observe_passive(&active, &passive, "connect_error", Duration::from_millis(5));
assert!(!state.healthy());
assert_eq!(state.connect_errors_total.load(Ordering::Relaxed), 3);
assert_eq!(state.passive_health_failures_total.load(Ordering::Relaxed), 3);
assert_eq!(
state.passive_health_failures_total.load(Ordering::Relaxed),
3
);
}
#[test]
@@ -211,7 +208,10 @@
}
assert!(state.healthy());
assert_eq!(state.http_5xx_total.load(Ordering::Relaxed), 20);
assert_eq!(state.passive_health_failures_total.load(Ordering::Relaxed), 0);
assert_eq!(
state.passive_health_failures_total.load(Ordering::Relaxed),
0
);
}
#[test]
@@ -254,7 +254,12 @@
let off = BackendState::new(HealthMode::Off);
let settings = settings(HealthMode::Off);
for _ in 0..5 {
off.observe_passive(&settings, &PassiveHealth::default(), "connect_error", Duration::ZERO);
off.observe_passive(
&settings,
&PassiveHealth::default(),
"connect_error",
Duration::ZERO,
);
}
assert!(off.healthy());
assert!(!off.claim_probe(&settings));
@@ -351,15 +356,20 @@
fn detailed_backend_failures_are_exposed_without_losing_aggregate_counters() {
let pool = capacity_pool(0, 0);
let selection = select(&pool);
selection
.lease
.error_detail("dns", "lookup backend.example failed", Duration::from_millis(7));
selection.lease.error_detail(
"dns",
"lookup backend.example failed",
Duration::from_millis(7),
);
let view = pool.view();
let member = &view["members"][0];
assert_eq!(member["dns_errors_total"], 1);
assert_eq!(member["connect_errors_total"], 1);
assert_eq!(member["last_failure_class"], "dns");
assert_eq!(member["last_failure_detail"], "lookup backend.example failed");
assert_eq!(
member["last_failure_detail"],
"lookup backend.example failed"
);
assert!(member["last_failure_at_ms"].as_u64().unwrap_or_default() > 0);
}
#[tokio::test]
+4 -1
View File
@@ -109,7 +109,10 @@
let state = &selected.lease.member.state;
assert_eq!(state.http_5xx_total.load(Ordering::Relaxed), 1);
assert_eq!(state.slow_responses_total.load(Ordering::Relaxed), 1);
assert_eq!(state.passive_health_failures_total.load(Ordering::Relaxed), 0);
assert_eq!(
state.passive_health_failures_total.load(Ordering::Relaxed),
0
);
assert!(state.healthy());
}
+12 -13
View File
@@ -57,7 +57,10 @@ impl Cache {
let batch: Vec<_> = {
let cold = self.cold.lock().unwrap_or_else(|e| e.into_inner());
cold.lru
.range((std::ops::Bound::Excluded(after_sequence), std::ops::Bound::Unbounded))
.range((
std::ops::Bound::Excluded(after_sequence),
std::ops::Bound::Unbounded,
))
.take(SNAPSHOT_BATCH)
.filter_map(|(sequence, key)| {
cold.entries.get(key).map(|entry| {
@@ -137,11 +140,9 @@ impl Cache {
break;
}
}
if !cold
.entries
.get(&candidate.key)
.is_some_and(|entry| entry.sequence == candidate.sequence && entry.pinned_reads == 0)
{
if !cold.entries.get(&candidate.key).is_some_and(|entry| {
entry.sequence == candidate.sequence && entry.pinned_reads == 0
}) {
continue;
}
if let Some(entry) = cold.remove(&candidate.key) {
@@ -217,7 +218,10 @@ impl Cache {
.clone()
.try_acquire_owned()
.map_err(|_| anyhow::anyhow!("cold cache cleanup is already running"))?;
let id = self.cleanup_sequence.fetch_add(1, Ordering::Relaxed).saturating_add(1);
let id = self
.cleanup_sequence
.fetch_add(1, Ordering::Relaxed)
.saturating_add(1);
let job = ColdCleanupJob {
id,
status: "queued",
@@ -251,12 +255,7 @@ impl Cache {
.unwrap_or_else(|e| e.into_inner())
.clone();
let result = cache
.remove_cold_candidates(
Some(&request),
None,
None,
settings.cold_gc_max_delete,
)
.remove_cold_candidates(Some(&request), None, None, settings.cold_gc_max_delete)
.await;
let mut jobs = cache.cleanup_jobs.lock().unwrap_or_else(|e| e.into_inner());
if let Some(job) = jobs.get_mut(&id) {
+23 -5
View File
@@ -115,8 +115,19 @@ impl Cache {
.get(&entry.scope)
.copied()
.unwrap_or(0);
let key_generation = store.key_generations.get(&entry.base_key).copied().unwrap_or(0);
if !self.enqueue_demotion(key, entry, revision, generation, scope_generation, key_generation) {
let key_generation = store
.key_generations
.get(&entry.base_key)
.copied()
.unwrap_or(0);
if !self.enqueue_demotion(
key,
entry,
revision,
generation,
scope_generation,
key_generation,
) {
self.counters.evictions.fetch_add(1, Ordering::Relaxed);
}
}
@@ -156,11 +167,18 @@ impl Cache {
.get(&entry.scope)
.copied()
.unwrap_or(0);
let key_generation = store.key_generations.get(&entry.base_key).copied().unwrap_or(0);
let key_generation = store
.key_generations
.get(&entry.base_key)
.copied()
.unwrap_or(0);
let bytes = entry.cost;
let memory_lease = self.try_reserve_transient(bytes, &settings).unwrap_or_else(|| {
let memory_lease =
self.try_reserve_transient(bytes, &settings)
.unwrap_or_else(|| {
let bytes = bytes as u64;
self.transient_bytes_active.fetch_add(bytes, Ordering::Relaxed);
self.transient_bytes_active
.fetch_add(bytes, Ordering::Relaxed);
TransientLease {
_inner: Arc::new(TransientLeaseInner {
counter: self.transient_bytes_active.clone(),
+19 -4
View File
@@ -33,7 +33,10 @@ impl Cache {
for item in read_dir.flatten() {
let path = item.path();
if path.is_file() {
let extension = path.extension().and_then(|v| v.to_str()).unwrap_or_default();
let extension = path
.extension()
.and_then(|v| v.to_str())
.unwrap_or_default();
if matches!(extension, "json" | "body" | "tmp") {
let _ = std::fs::remove_file(path);
}
@@ -142,12 +145,24 @@ impl Cache {
revision,
scope: file.scope,
scheme: file.scheme,
authority: if file.authority.is_empty() { file.domain.clone() } else { file.authority },
authority: if file.authority.is_empty() {
file.domain.clone()
} else {
file.authority
},
domain: file.domain,
path: file.path.clone(),
target: if file.target.is_empty() { file.path } else { file.target },
target: if file.target.is_empty() {
file.path
} else {
file.target
},
tags: file.tags,
created_at: if file.created_at_ms == 0 { stored_at } else { system_time_ms(file.created_at_ms) },
created_at: if file.created_at_ms == 0 {
stored_at
} else {
system_time_ms(file.created_at_ms)
},
headers,
body_path,
meta_path: path,
+22 -4
View File
@@ -101,7 +101,9 @@ impl Cache {
let key = key_hex(&draft.key);
let body_name = format!("{key}.body");
let (body_path, meta_path, body_tmp, meta_tmp) = cold_paths(&draft.root, &draft.key);
let Some(shard) = body_path.parent() else { return; };
let Some(shard) = body_path.parent() else {
return;
};
if tokio::fs::create_dir_all(shard).await.is_err() {
self.counters.cold_errors.fetch_add(1, Ordering::Relaxed);
return;
@@ -125,7 +127,13 @@ impl Cache {
path: draft.entry.path.clone(),
target: draft.entry.target.clone(),
tags: draft.entry.tags.clone(),
created_at_ms: draft.entry.created_at.duration_since(UNIX_EPOCH).unwrap_or_default().as_millis().min(u64::MAX as u128) as u64,
created_at_ms: draft
.entry
.created_at
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.min(u64::MAX as u128) as u64,
headers,
body_file: body_name,
body_bytes: draft.entry.body.len(),
@@ -138,8 +146,18 @@ impl Cache {
stale_if_error: draft.entry.stale_if_error,
must_revalidate: draft.entry.must_revalidate,
proxy_revalidate: draft.entry.proxy_revalidate,
origin_etag: draft.entry.origin_etag.as_ref().and_then(|v| v.to_str().ok()).map(str::to_owned),
origin_last_modified: draft.entry.origin_last_modified.as_ref().and_then(|v| v.to_str().ok()).map(str::to_owned),
origin_etag: draft
.entry
.origin_etag
.as_ref()
.and_then(|v| v.to_str().ok())
.map(str::to_owned),
origin_last_modified: draft
.entry
.origin_last_modified
.as_ref()
.and_then(|v| v.to_str().ok())
.map(str::to_owned),
last_access_ms: now_ms(),
hits: draft.entry.hits,
transformed: draft.entry.transformed,
+44 -12
View File
@@ -92,25 +92,47 @@ fn canonical_url(scheme: &str, authority: &str, target: &str) -> String {
fn normalized_matcher(request: &InvalidationRequest) -> Result<SelectorMatcher> {
ensure!(!request.value.is_empty(), "invalidation value is required");
ensure!(request.value.len() <= 8192, "invalidation value is too long");
ensure!(
request.value.len() <= 8192,
"invalidation value is too long"
);
if let Some(vhost) = &request.vhost {
ensure!(!vhost.is_empty() && vhost.len() <= 64, "invalid vhost selector");
ensure!(
!vhost.is_empty() && vhost.len() <= 64,
"invalid vhost selector"
);
}
if let Some(host) = &request.host {
ensure!(!host.is_empty() && host.len() <= 253, "invalid host selector");
ensure!(
!host.is_empty() && host.len() <= 253,
"invalid host selector"
);
}
let mut value = request.value.trim().to_owned();
let mut scheme = None;
let mut host = request.host.as_ref().map(|value| value.to_ascii_lowercase());
let regex = match request.selector {
let mut host = request
.host
.as_ref()
.map(|value| value.to_ascii_lowercase());
let regex =
match request.selector {
InvalidationSelector::Exact | InvalidationSelector::Prefix => {
if value.starts_with("http://") || value.starts_with("https://") {
let url = url::Url::parse(&value).context("invalid purge URL")?;
ensure!(url.username().is_empty() && url.password().is_none(), "purge URL must not contain credentials");
ensure!(url.fragment().is_none(), "purge URL must not contain a fragment");
ensure!(
url.username().is_empty() && url.password().is_none(),
"purge URL must not contain credentials"
);
ensure!(
url.fragment().is_none(),
"purge URL must not contain a fragment"
);
scheme = Some(url.scheme().to_ascii_lowercase());
let authority = match url.port() {
Some(port) => format!("{}:{port}", url.host_str().context("purge URL host missing")?),
Some(port) => format!(
"{}:{port}",
url.host_str().context("purge URL host missing")?
),
None => url.host_str().context("purge URL host missing")?.to_owned(),
};
host = Some(authority.to_ascii_lowercase());
@@ -120,12 +142,19 @@ fn normalized_matcher(request: &InvalidationRequest) -> Result<SelectorMatcher>
value.push_str(query);
}
}
ensure!(value.starts_with('/'), "exact/prefix invalidation must use an absolute URL or /path target");
ensure!(
value.starts_with('/'),
"exact/prefix invalidation must use an absolute URL or /path target"
);
None
}
InvalidationSelector::Tag => {
ensure!(value.len() <= 128, "cache tag is too long");
ensure!(value.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b':' | b'/')), "invalid cache tag");
ensure!(
value.bytes().all(|b| b.is_ascii_alphanumeric()
|| matches!(b, b'_' | b'-' | b'.' | b':' | b'/')),
"invalid cache tag"
);
None
}
InvalidationSelector::Regex => {
@@ -138,7 +167,10 @@ fn normalized_matcher(request: &InvalidationRequest) -> Result<SelectorMatcher>
)
}
InvalidationSelector::CacheKey => {
ensure!(parse_key_hex(&value).is_some(), "cache_key must be 64 hexadecimal characters");
ensure!(
parse_key_hex(&value).is_some(),
"cache_key must be 64 hexadecimal characters"
);
value.make_ascii_lowercase();
None
}
@@ -198,7 +230,7 @@ fn matcher_matches(
InvalidationSelector::Tag => {
host_matches(matcher, scheme, authority, domain)
&& tags.iter().any(|tag| tag == &matcher.value)
},
}
InvalidationSelector::Regex => {
if !host_matches(matcher, scheme, authority, domain) {
return false;
+13 -6
View File
@@ -56,24 +56,31 @@ impl Cache {
let started = Instant::now();
let matcher = normalized_matcher(&request)?;
if requires_wide_confirmation(&matcher) {
ensure!(request.confirm_wide, "wide invalidation requires confirm_wide=true");
ensure!(
request.confirm_wide,
"wide invalidation requires confirm_wide=true"
);
}
match request.operation {
InvalidationOperation::Ban => {
let ttl = request.ttl_seconds.unwrap_or(DEFAULT_BAN_TTL_SECONDS);
ensure!((1..=MAX_BAN_TTL_SECONDS).contains(&ttl), "BAN ttl_seconds must be 1..604800");
ensure!(
(1..=MAX_BAN_TTL_SECONDS).contains(&ttl),
"BAN ttl_seconds must be 1..604800"
);
let now = SystemTime::now();
let mut bans = self.bans.lock().unwrap_or_else(|e| e.into_inner());
bans.retain(|rule| !rule.expired(now));
ensure!(bans.len() < MAX_BAN_RULES, "active BAN rule limit reached");
let id = self.ban_sequence.fetch_add(1, Ordering::Relaxed).wrapping_add(1);
let id = self
.ban_sequence
.fetch_add(1, Ordering::Relaxed)
.wrapping_add(1);
bans.push(BanRule {
id,
matcher: matcher.clone(),
created_at: now,
expires_at: now
.checked_add(Duration::from_secs(ttl))
.unwrap_or(now),
expires_at: now.checked_add(Duration::from_secs(ttl)).unwrap_or(now),
});
self.counters.bans_created.fetch_add(1, Ordering::Relaxed);
Ok(InvalidationResult {
+1 -6
View File
@@ -13,12 +13,7 @@ impl Cache {
.copied()
.unwrap_or(0)
!= ticket.scope_generation
|| store
.key_generations
.get(&ticket.key)
.copied()
.unwrap_or(0)
!= ticket.key_generation
|| store.key_generations.get(&ticket.key).copied().unwrap_or(0) != ticket.key_generation
{
return None;
}
+18 -9
View File
@@ -18,7 +18,10 @@ impl Cache {
}
if entry.cost > settings.max_bytes
|| (!entry.requires_revalidation
&& entry.initial_age.saturating_add(entry.received.elapsed()).as_secs()
&& entry
.initial_age
.saturating_add(entry.received.elapsed())
.as_secs()
>= retention_deadline(
entry.ttl,
entry.stale_while_revalidate,
@@ -37,12 +40,7 @@ impl Cache {
.copied()
.unwrap_or(0)
!= ticket.scope_generation
|| store
.key_generations
.get(&ticket.key)
.copied()
.unwrap_or(0)
!= ticket.key_generation
|| store.key_generations.get(&ticket.key).copied().unwrap_or(0) != ticket.key_generation
{
return false;
}
@@ -80,10 +78,21 @@ impl Cache {
.get(&old.scope)
.copied()
.unwrap_or(0);
let key_generation = store.key_generations.get(&old.base_key).copied().unwrap_or(0);
let key_generation = store
.key_generations
.get(&old.base_key)
.copied()
.unwrap_or(0);
if !(settings.cold_enabled
&& settings.cold_on_lru
&& self.enqueue_demotion(key, old, revision, generation, scope_generation, key_generation))
&& self.enqueue_demotion(
key,
old,
revision,
generation,
scope_generation,
key_generation,
))
{
self.counters.evictions.fetch_add(1, Ordering::Relaxed);
}
+4 -1
View File
@@ -196,7 +196,10 @@ impl Cache {
skip_compression: parts.extensions.get::<crate::compression::Skip>().is_some(),
origin_etag,
origin_last_modified,
transformed: parts.extensions.get::<crate::transform::Applied>().is_some(),
transformed: parts
.extensions
.get::<crate::transform::Applied>()
.is_some(),
_permit: permit,
_fill_leader: fill_leader,
};
+9 -2
View File
@@ -56,7 +56,11 @@ pub struct ColdCleanupRequest {
}
impl Default for ColdCleanupRequest {
fn default() -> Self {
Self { unused_for_seconds: None, oldest: Some(100), hits_zero: false }
Self {
unused_for_seconds: None,
oldest: Some(100),
hits_zero: false,
}
}
}
impl ColdCleanupRequest {
@@ -66,7 +70,10 @@ impl ColdCleanupRequest {
"at least one cold-cache cleanup criterion is required"
);
if let Some(value) = self.unused_for_seconds {
ensure!((1..=31_536_000).contains(&value), "unused_for_seconds must be 1..31536000");
ensure!(
(1..=31_536_000).contains(&value),
"unused_for_seconds must be 1..31536000"
);
}
if let Some(value) = self.oldest {
ensure!((1..=10_000).contains(&value), "oldest must be 1..10000");
+4 -2
View File
@@ -109,7 +109,10 @@ impl Settings {
(500..=599).contains(status),
"cache.stale_if_error_statuses must contain HTTP 5xx status codes"
);
ensure!(statuses.insert(*status), "duplicate stale-if-error HTTP status");
ensure!(
statuses.insert(*status),
"duplicate stale-if-error HTTP status"
);
}
ensure!(
(1..=604_800).contains(&self.proactive_refresh_before_expiry_seconds),
@@ -332,7 +335,6 @@ pub struct ScopeView {
pub cold_bytes: u64,
}
#[derive(Default)]
struct Counters {
hits: AtomicU64,
+5 -3
View File
@@ -248,9 +248,11 @@ impl Revalidation {
}
}
if let Some(Some(value)) = control.get("min-fresh") {
if value.parse::<u64>().ok().is_some_and(|required| {
self.ttl.saturating_sub(self.age) < required
}) {
if value
.parse::<u64>()
.ok()
.is_some_and(|required| self.ttl.saturating_sub(self.age) < required)
{
return Some("request_min_fresh");
}
}
+7 -8
View File
@@ -17,14 +17,16 @@ fn default_cache_scheme() -> String {
fn cache_tags(headers: &HeaderMap) -> Vec<String> {
let mut tags = Vec::new();
for value in headers.get_all("cache-tag") {
let Ok(value) = value.to_str() else { continue; };
let Ok(value) = value.to_str() else {
continue;
};
for raw in value.split(|c: char| c == ',' || c.is_ascii_whitespace()) {
let tag = raw.trim();
if tag.is_empty()
|| tag.len() > 128
|| !tag
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b':' | b'/'))
|| !tag.bytes().all(|b| {
b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.' | b':' | b'/')
})
{
continue;
}
@@ -180,10 +182,7 @@ fn cache_state(
requires_revalidation: bool,
request_headers: &HeaderMap,
) -> CacheState {
if age < ttl
&& !requires_revalidation
&& request_freshness_allows(age, ttl, request_headers)
{
if age < ttl && !requires_revalidation && request_freshness_allows(age, ttl, request_headers) {
return CacheState::Fresh;
}
match stale_mode(
+12 -14
View File
@@ -16,12 +16,7 @@
}
}
async fn fill(
cache: &Arc<Cache>,
settings: &Settings,
path: &str,
body: &'static str,
) -> Ticket {
async fn fill(cache: &Arc<Cache>, settings: &Settings, path: &str, body: &'static str) -> Ticket {
let ticket = cache
.ticket_scoped(
11,
@@ -169,9 +164,10 @@
"public, max-age=120, stale-while-revalidate=30, stale-if-error=90",
),
);
transformed
.headers_mut()
.insert("etag", HeaderValue::from_static("\"rustproxy-representation\""));
transformed.headers_mut().insert(
"etag",
HeaderValue::from_static("\"rustproxy-representation\""),
);
transformed
.headers_mut()
.insert("content-length", HeaderValue::from_static("7"));
@@ -179,11 +175,11 @@
.extensions_mut()
.insert(crate::transform::OriginValidators {
etag: Some(HeaderValue::from_static("\"origin-v1\"")),
last_modified: Some(HeaderValue::from_static(
"Mon, 26 Jan 2026 23:11:40 GMT",
)),
last_modified: Some(HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT")),
});
transformed.extensions_mut().insert(crate::transform::Applied {
transformed
.extensions_mut()
.insert(crate::transform::Applied {
kind: crate::transform::Kind::Json,
original_bytes: 9,
transformed_bytes: 7,
@@ -308,7 +304,9 @@
.await
.expect("no-transform object after restart");
assert_eq!(protected_hit.response.headers()["etag"], "\"origin-css\"");
assert!(crate::transform::no_transform(protected_hit.response.headers()));
assert!(crate::transform::no_transform(
protected_hit.response.headers()
));
assert!(protected_hit
.response
.extensions()
+45 -8
View File
@@ -21,7 +21,10 @@
assert_eq!(view.entries, 0);
assert_eq!(view.resident_bytes, 0);
assert_eq!(view.hot_logical_bytes, 0);
assert_eq!(view.transient_bytes, 0, "persisted ColdEntry must not retain the body allocation");
assert_eq!(
view.transient_bytes, 0,
"persisted ColdEntry must not retain the body allocation"
);
assert_eq!(view.cold_entries, 2);
shutdown.cancel();
@@ -99,14 +102,20 @@
for item in std::fs::read_dir(&root).unwrap() {
let item = item.unwrap();
let name = item.file_name().to_string_lossy().to_string();
assert!(item.path().is_dir(), "cold root must contain shard directories only");
assert!(
item.path().is_dir(),
"cold root must contain shard directories only"
);
assert_eq!(name.len(), 2);
assert!(name.bytes().all(|b| b.is_ascii_hexdigit()));
shards.push(item.path());
}
assert!(!shards.is_empty());
assert!(shards.iter().any(|shard| {
std::fs::read_dir(shard).unwrap().filter_map(|entry| entry.ok()).any(|entry| {
std::fs::read_dir(shard)
.unwrap()
.filter_map(|entry| entry.ok())
.any(|entry| {
let name = entry.file_name().to_string_lossy().to_string();
name.ends_with(".json") || name.ends_with(".body")
})
@@ -123,7 +132,12 @@
std::fs::write(root.join("legacy.body"), b"old").unwrap();
std::fs::write(root.join("legacy.json"), b"{}").unwrap();
let shutdown = CancellationToken::new();
let cache = Cache::start(11, Some(dir.path().to_path_buf()), settings, shutdown.clone());
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings,
shutdown.clone(),
);
assert_eq!(cache.view().cold_entries, 0);
assert!(!root.join("legacy.body").exists());
assert!(!root.join("legacy.json").exists());
@@ -136,12 +150,23 @@
let mut settings = cold_settings();
settings.max_entries = 1;
let shutdown = CancellationToken::new();
let cache = Cache::start(11, Some(dir.path().to_path_buf()), settings.clone(), shutdown.clone());
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings.clone(),
shutdown.clone(),
);
fill(&cache, &settings, "/one", "first-payload").await;
fill(&cache, &settings, "/two", "second-payload").await;
fill(&cache, &settings, "/three", "third-payload").await;
wait_for_cold(&cache, 2).await;
let job = cache.start_cold_cleanup(ColdCleanupRequest { unused_for_seconds: None, oldest: Some(1), hits_zero: false }).unwrap();
let job = cache
.start_cold_cleanup(ColdCleanupRequest {
unused_for_seconds: None,
oldest: Some(1),
hits_zero: false,
})
.unwrap();
for _ in 0..100 {
let current = cache.cold_cleanup_job(job.id).unwrap();
if current.status == "done" {
@@ -161,7 +186,12 @@
let dir = tempfile::tempdir().unwrap();
let settings = cold_settings();
let shutdown = CancellationToken::new();
let cache = Cache::start(11, Some(dir.path().to_path_buf()), settings, shutdown.clone());
let cache = Cache::start(
11,
Some(dir.path().to_path_buf()),
settings,
shutdown.clone(),
);
let _held = cache.cleanup_slots.clone().try_acquire_owned().unwrap();
let result = cache.start_cold_cleanup(ColdCleanupRequest {
unused_for_seconds: None,
@@ -205,7 +235,14 @@
assert_eq!(result.removed_entries, 0);
assert!(cache.cold.lock().unwrap().entries.contains_key(&key));
cache.cold.lock().unwrap().entries.get_mut(&key).unwrap().pinned_reads = 0;
cache
.cold
.lock()
.unwrap()
.entries
.get_mut(&key)
.unwrap()
.pinned_reads = 0;
let result = cache
.remove_cold_candidates(Some(&request), None, None, 1)
.await;
+3 -16
View File
@@ -86,12 +86,7 @@ impl Cache {
.copied()
.unwrap_or(0)
!= ticket.scope_generation
|| store
.key_generations
.get(&ticket.key)
.copied()
.unwrap_or(0)
!= ticket.key_generation
|| store.key_generations.get(&ticket.key).copied().unwrap_or(0) != ticket.key_generation
{
return None;
}
@@ -258,10 +253,7 @@ impl Cache {
}
let hits = cached.hits.saturating_add(1);
let mut headers = cached.headers.clone();
headers.insert(
"age",
HeaderValue::from_str(&cached.age.to_string()).ok()?,
);
headers.insert("age", HeaderValue::from_str(&cached.age.to_string()).ok()?);
headers.append(
"warning",
HeaderValue::from_static("110 RustProxy \"Response is stale\""),
@@ -331,12 +323,7 @@ impl Cache {
.copied()
.unwrap_or(0)
== ticket.scope_generation
&& store
.key_generations
.get(&ticket.key)
.copied()
.unwrap_or(0)
== ticket.key_generation
&& store.key_generations.get(&ticket.key).copied().unwrap_or(0) == ticket.key_generation
}
fn discard_storage_key(&self, key: &Key) {
self.store
-1
View File
@@ -140,7 +140,6 @@ pub fn compile_reusing(
compile_with_active(config, revision, policy, defaults, previous, &[])
}
type LocalHostBuild = (
HashMap<String, Vec<Arc<crate::redirect::CompiledHost>>>,
HashMap<String, Vec<Arc<crate::not_found::CompiledHost>>>,
+4 -1
View File
@@ -47,7 +47,10 @@ pub fn compile_with_active(
config.compression.validate()?;
config.server_header.validate()?;
let forwarding = config.forwarding.compile()?;
ensure!(config.web_services.len() <= 128, "too many Web Services (max 128)");
ensure!(
config.web_services.len() <= 128,
"too many Web Services (max 128)"
);
let mut web_services = HashMap::new();
for service in &mut config.web_services {
service
+11 -3
View File
@@ -199,7 +199,10 @@ for host in &mut config.vhosts {
let (fallback_source, fallback_pool, fallback_backend) = match &host.source {
crate::vhost::Source::ReverseProxy => {
let pool = pool.as_ref().ok_or_else(|| {
anyhow::anyhow!("vhost {} Reverse Proxy source has no backend pool", host.id)
anyhow::anyhow!(
"vhost {} Reverse Proxy source has no backend pool",
host.id
)
})?;
(
CompiledSource::ReverseProxy,
@@ -288,7 +291,8 @@ for host in &mut config.vhosts {
);
validate_route_rewrite(route, path_regex.as_ref())?;
validate_location_response(route.response.as_ref())?;
let (route_profile_acl, route_profile_country_access) = match route.acl_profile_id.as_deref() {
let (route_profile_acl, route_profile_country_access) =
match route.acl_profile_id.as_deref() {
Some(id) => acl_profiles.get(id).cloned().ok_or_else(|| {
anyhow::anyhow!(
"vhost {} location {} references unknown ACL profile {}",
@@ -333,7 +337,11 @@ for host in &mut config.vhosts {
} else {
pool.members[0].backend.clone()
};
(CompiledSource::ReverseProxy, Some(pool.clone()), Some(backend))
(
CompiledSource::ReverseProxy,
Some(pool.clone()),
Some(backend),
)
}
crate::vhost::Source::WebServer { web_service_id } => (
CompiledSource::WebServer(web_services[web_service_id].clone()),
+1 -2
View File
@@ -26,8 +26,7 @@ impl Snapshot {
&& frontend.websocket_rfc8441
&& frontend.http2_enabled
&& frontend.bindings.iter().any(|binding| {
binding.matches(local, tls)
&& if tls { binding.http2 } else { binding.h2c }
binding.matches(local, tls) && if tls { binding.http2 } else { binding.h2c }
})
})
}
+13 -5
View File
@@ -64,7 +64,9 @@
..Default::default()
});
let host = &mut config.vhosts[0];
host.source = crate::vhost::Source::WebServer { web_service_id: "site".into() };
host.source = crate::vhost::Source::WebServer {
web_service_id: "site".into(),
};
host.routes = vec![crate::vhost::PathRoute {
id: "api".into(),
path_prefix: "/api".into(),
@@ -108,8 +110,12 @@
let snapshot = compile(config, 1, &policy()).unwrap();
let first_route = snapshot.select("localhost", "/").unwrap();
let second_route = snapshot.select("second.localhost", "/").unwrap();
assert!(matches!(&first_route.source, CompiledSource::WebServer(service) if service.id == "shared-site"));
assert!(matches!(&second_route.source, CompiledSource::WebServer(service) if service.id == "shared-site"));
assert!(
matches!(&first_route.source, CompiledSource::WebServer(service) if service.id == "shared-site")
);
assert!(
matches!(&second_route.source, CompiledSource::WebServer(service) if service.id == "shared-site")
);
}
#[test]
@@ -124,9 +130,11 @@
#[test]
fn missing_source_fields_remain_reverse_proxy_for_old_configs() {
let source: crate::vhost::Source = serde_json::from_value(serde_json::json!({"type":"reverse_proxy"})).unwrap();
let source: crate::vhost::Source =
serde_json::from_value(serde_json::json!({"type":"reverse_proxy"})).unwrap();
assert_eq!(source, crate::vhost::Source::ReverseProxy);
let route: crate::vhost::PathRoute = serde_json::from_value(serde_json::json!({"id":"legacy"})).unwrap();
let route: crate::vhost::PathRoute =
serde_json::from_value(serde_json::json!({"id":"legacy"})).unwrap();
assert_eq!(route.source, crate::vhost::Source::ReverseProxy);
}
+6 -5
View File
@@ -53,7 +53,8 @@ impl Store {
)
.bind(revision_i64)
.fetch_optional(&self.pool)
.await? else {
.await?
else {
return Ok(None);
};
let document: String = row.try_get("document")?;
@@ -75,9 +76,10 @@ impl Store {
);
(
Some(previous.try_get::<i64, _>("revision")?.try_into()?),
Some(serde_json::from_str(&previous_document).context(
"invalid previous configuration history document",
)?),
Some(
serde_json::from_str(&previous_document)
.context("invalid previous configuration history document")?,
),
)
} else {
(None, None)
@@ -92,5 +94,4 @@ impl Store {
previous_config,
}))
}
}
+13 -8
View File
@@ -105,12 +105,7 @@ mod tests {
let token = "b".repeat(64);
let hash = crate::auth::secret_hash(&token);
let saved = store
.upsert_certificate_api_credential(
"certbot-main",
&hash,
"production certbot",
"owner",
)
.upsert_certificate_api_credential("certbot-main", &hash, "production certbot", "owner")
.await
.unwrap();
assert_eq!(saved.key_id, "certbot-main");
@@ -171,8 +166,18 @@ mod tests {
let remaining = store.certificate_api_credentials().await.unwrap();
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].key_id, "deploy-hook");
assert_eq!(store.revoke_all_certificate_api_credentials("owner").await.unwrap(), 1);
assert!(store.certificate_api_credentials().await.unwrap().is_empty());
assert_eq!(
store
.revoke_all_certificate_api_credentials("owner")
.await
.unwrap(),
1
);
assert!(store
.certificate_api_credentials()
.await
.unwrap()
.is_empty());
store.close().await;
}
+27 -5
View File
@@ -88,14 +88,17 @@ static CLOUDFLARE_NETWORKS: LazyLock<Vec<IpNet>> = LazyLock::new(|| {
.collect()
});
fn cloudflare_peer(peer: SocketAddr) -> bool {
let peer = canonical(peer.ip());
CLOUDFLARE_NETWORKS
.iter()
.any(|network| network.contains(&peer))
}
/// Return CF-Connecting-IP only when the TCP peer itself is in Cloudflare's
/// published edge ranges. The header is otherwise untrusted user input.
pub fn cloudflare_client_ip(headers: &HeaderMap, peer: SocketAddr) -> Option<IpAddr> {
let peer = canonical(peer.ip());
if !CLOUDFLARE_NETWORKS
.iter()
.any(|network| network.contains(&peer))
{
if !cloudflare_peer(peer) {
return None;
}
let mut values = headers.get_all("cf-connecting-ip").iter();
@@ -111,6 +114,25 @@ pub fn cloudflare_client_ip(headers: &HeaderMap, peer: SocketAddr) -> Option<IpA
.ok()
.map(canonical)
}
/// Return a trusted ISO country code supplied by Cloudflare. `CF-IPCountry` is
/// accepted only from a Cloudflare edge and only as one two-letter code.
pub fn cloudflare_country(headers: &HeaderMap, peer: SocketAddr) -> Option<String> {
if !cloudflare_peer(peer) {
return None;
}
let mut values = headers.get_all("cf-ipcountry").iter();
let value = values.next()?;
if values.next().is_some() {
return None;
}
let value = value.to_str().ok()?.trim();
if value.len() != 2 || !value.bytes().all(|byte| byte.is_ascii_alphabetic()) {
return None;
}
let country = value.to_ascii_uppercase();
(country != "XX").then_some(country)
}
fn parse_node(value: &str) -> Option<IpAddr> {
let value = value.trim();
let value = if value.starts_with('"') {
+23
View File
@@ -167,6 +167,29 @@ mod tests {
assert!(cloudflare_client_ip(&headers, "192.0.2.2:443".parse().unwrap()).is_none());
}
#[test]
fn cloudflare_country_requires_trusted_peer_and_iso_code() {
let mut headers = HeaderMap::new();
headers.insert("cf-ipcountry", "pl".parse().unwrap());
assert_eq!(
cloudflare_country(&headers, "104.16.1.2:443".parse().unwrap()),
Some("PL".into())
);
assert!(cloudflare_country(&headers, "192.0.2.2:443".parse().unwrap()).is_none());
headers.insert("cf-ipcountry", "XX".parse().unwrap());
assert!(cloudflare_country(&headers, "104.16.1.2:443".parse().unwrap()).is_none());
headers.insert("cf-ipcountry", "T1".parse().unwrap());
assert!(cloudflare_country(&headers, "104.16.1.2:443".parse().unwrap()).is_none());
headers.insert("cf-ipcountry", "POL".parse().unwrap());
assert!(cloudflare_country(&headers, "104.16.1.2:443".parse().unwrap()).is_none());
}
#[test]
fn cloudflare_country_rejects_duplicate_headers() {
let mut headers = HeaderMap::new();
headers.append("cf-ipcountry", "PL".parse().unwrap());
headers.append("cf-ipcountry", "DE".parse().unwrap());
assert!(cloudflare_country(&headers, "104.16.1.2:443".parse().unwrap()).is_none());
}
#[test]
fn authenticated_client_override_prepends_chain_once() {
let mut c = context(Settings::default(), HeaderMap::new(), "104.16.1.2:443");
c.override_client_ip("203.0.113.8".parse().unwrap())
+98 -24
View File
@@ -1,7 +1,12 @@
impl IoWrite for RedactingWriter {
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> { self.buffer.extend_from_slice(bytes); Ok(bytes.len()) }
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
self.buffer.extend_from_slice(bytes);
Ok(bytes.len())
}
fn flush(&mut self) -> io::Result<()> {
if self.buffer.is_empty() { return Ok(()); }
if self.buffer.is_empty() {
return Ok(());
}
let text = String::from_utf8_lossy(&self.buffer);
let redacted = redact_text(&text);
let mut stderr = io::stderr().lock();
@@ -11,10 +16,18 @@ impl IoWrite for RedactingWriter {
Ok(())
}
}
impl Drop for RedactingWriter { fn drop(&mut self) { let _ = self.flush(); } }
impl Drop for RedactingWriter {
fn drop(&mut self) {
let _ = self.flush();
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for RedactingStderr {
type Writer = RedactingWriter;
fn make_writer(&'a self) -> Self::Writer { RedactingWriter { buffer: Vec::with_capacity(512) } }
fn make_writer(&'a self) -> Self::Writer {
RedactingWriter {
buffer: Vec::with_capacity(512),
}
}
}
fn redact_regex(pattern: &'static str) -> &'static regex::Regex {
@@ -55,17 +68,31 @@ fn is_secret_field(name: &str) -> bool {
}
pub fn redact_text(input: &str) -> String {
let mut value = redact_regex("pem").replace_all(input, "[REDACTED PRIVATE KEY]").into_owned();
value = redact_regex("auth").replace_all(&value, "$1[REDACTED]").into_owned();
value = redact_regex("query").replace_all(&value, "$1[REDACTED]").into_owned();
value = redact_regex("json").replace_all(&value, "$1[REDACTED]$2").into_owned();
redact_regex("kv").replace_all(&value, "$1[REDACTED]").into_owned()
let mut value = redact_regex("pem")
.replace_all(input, "[REDACTED PRIVATE KEY]")
.into_owned();
value = redact_regex("auth")
.replace_all(&value, "$1[REDACTED]")
.into_owned();
value = redact_regex("query")
.replace_all(&value, "$1[REDACTED]")
.into_owned();
value = redact_regex("json")
.replace_all(&value, "$1[REDACTED]$2")
.into_owned();
redact_regex("kv")
.replace_all(&value, "$1[REDACTED]")
.into_owned()
}
fn redact_value(value: &mut Value) {
match value {
Value::String(text) => *text = redact_text(text),
Value::Array(items) => for item in items { redact_value(item); },
Value::Array(items) => {
for item in items {
redact_value(item);
}
}
Value::Object(items) => {
for (key, item) in items {
if is_secret_field(key) {
@@ -80,7 +107,10 @@ fn redact_value(value: &mut Value) {
}
fn rfc3339_now() -> String {
let ms = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as i64;
let ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64;
let seconds = ms / 1000;
let millis = ms % 1000;
let days = seconds.div_euclid(86_400);
@@ -94,7 +124,9 @@ fn rfc3339_now() -> String {
let mp = (5 * doy + 2) / 153;
let day = doy - (153 * mp + 2) / 5 + 1;
let month = mp + if mp < 10 { 3 } else { -9 };
if month <= 2 { year += 1; }
if month <= 2 {
year += 1;
}
let hour = day_seconds / 3600;
let minute = (day_seconds % 3600) / 60;
let second = day_seconds % 60;
@@ -119,7 +151,9 @@ async fn open_error_file(path: PathBuf) -> Result<File> {
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
Ok(file)
}).await.map_err(|_| anyhow::anyhow!("error log open worker failed"))??;
})
.await
.map_err(|_| anyhow::anyhow!("error log open worker failed"))??;
Ok(File::from_std(file))
}
@@ -223,7 +257,9 @@ async fn error_log_loop(
}
}
}
let Some(opened) = file.as_mut() else { continue; };
let Some(opened) = file.as_mut() else {
continue;
};
let line = format_error_event(&event, config.format);
if let Err(error) = opened.write_all(&line).await {
errors.fetch_add(1, Ordering::Relaxed);
@@ -360,7 +396,14 @@ impl Logs {
pub fn recent_limit(&self, limit: usize) -> Vec<LogEntry> {
self.entries
.lock()
.map(|entries| entries.iter().rev().take(limit.min(HISTORY_CAPACITY)).cloned().collect())
.map(|entries| {
entries
.iter()
.rev()
.take(limit.min(HISTORY_CAPACITY))
.cloned()
.collect()
})
.unwrap_or_default()
}
pub fn stats(&self) -> SinkStats {
@@ -406,15 +449,46 @@ fn value_for(entry: &LogEntry, name: &str) -> Value {
"path" => json!(&entry.path),
"http_version" => json!(entry.http_version),
"transport" => json!(entry.transport),
"websocket_bootstrap" => entry.websocket_bootstrap.map(|v| Value::String(v.into())).unwrap_or(Value::Null),
"websocket_phase" => entry.websocket_phase.map(|v| Value::String(v.into())).unwrap_or(Value::Null),
"websocket_error" => entry.websocket_error.map(|v| Value::String(v.into())).unwrap_or(Value::Null),
"h3_reset_code" => entry.h3_reset_code.map(|v| Value::String(v.into())).unwrap_or(Value::Null),
"upstream_status" => entry.upstream_status.map(|v| json!(v)).unwrap_or(Value::Null),
"upstream_error" => entry.upstream_error.clone().map(Value::String).unwrap_or(Value::Null),
"client_ip" => entry.client_ip.clone().map(Value::String).unwrap_or(Value::Null),
"user_agent" => entry.user_agent.clone().map(Value::String).unwrap_or(Value::Null),
"country" => entry.country.clone().map(Value::String).unwrap_or(Value::Null),
"websocket_bootstrap" => entry
.websocket_bootstrap
.map(|v| Value::String(v.into()))
.unwrap_or(Value::Null),
"websocket_phase" => entry
.websocket_phase
.map(|v| Value::String(v.into()))
.unwrap_or(Value::Null),
"websocket_error" => entry
.websocket_error
.map(|v| Value::String(v.into()))
.unwrap_or(Value::Null),
"h3_reset_code" => entry
.h3_reset_code
.map(|v| Value::String(v.into()))
.unwrap_or(Value::Null),
"upstream_status" => entry
.upstream_status
.map(|v| json!(v))
.unwrap_or(Value::Null),
"upstream_error" => entry
.upstream_error
.clone()
.map(Value::String)
.unwrap_or(Value::Null),
"client_ip" => entry
.client_ip
.clone()
.map(Value::String)
.unwrap_or(Value::Null),
"user_agent" => entry
.user_agent
.clone()
.map(Value::String)
.unwrap_or(Value::Null),
"country" => entry
.country
.clone()
.map(Value::String)
.unwrap_or(Value::Null),
"status" => json!(entry.status),
"duration_ms" => json!(entry.duration_ms),
"request_bytes" => json!(entry.request_bytes),
+61 -18
View File
@@ -261,7 +261,6 @@ fn source_value_name(name: &str) -> bool {
)
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum ErrorLogFormat {
#[serde(rename = "JSON")]
@@ -270,7 +269,9 @@ pub enum ErrorLogFormat {
Raw,
}
impl Default for ErrorLogFormat {
fn default() -> Self { Self::Json }
fn default() -> Self {
Self::Json
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -282,14 +283,20 @@ pub struct ErrorLogSettings {
}
impl Default for ErrorLogSettings {
fn default() -> Self {
Self { enabled: false, path: "/var/log/rustproxy/error.log".into(), format: ErrorLogFormat::Json }
Self {
enabled: false,
path: "/var/log/rustproxy/error.log".into(),
format: ErrorLogFormat::Json,
}
}
}
impl ErrorLogSettings {
pub fn validate(&self) -> Result<()> {
if self.enabled {
ensure!(
!self.path.trim().is_empty() && self.path.len() <= 1024 && !self.path.chars().any(char::is_control),
!self.path.trim().is_empty()
&& self.path.len() <= 1024
&& !self.path.chars().any(char::is_control),
"error_log.path must be 1..1024 bytes without controls"
);
}
@@ -339,7 +346,14 @@ impl ErrorLogController {
written.clone(),
errors.clone(),
));
Arc::new(Self { settings, settings_changed, sender, dropped, written, errors })
Arc::new(Self {
settings,
settings_changed,
sender,
dropped,
written,
errors,
})
}
pub fn configure(&self, settings: ErrorLogSettings) {
self.settings.store(Arc::new(settings));
@@ -363,10 +377,14 @@ impl ErrorLogController {
static ERROR_LOG: OnceLock<Arc<ErrorLogController>> = OnceLock::new();
pub fn install_error_log_controller(controller: Arc<ErrorLogController>) -> Result<()> {
ERROR_LOG.set(controller).map_err(|_| anyhow::anyhow!("error logger already initialized"))
ERROR_LOG
.set(controller)
.map_err(|_| anyhow::anyhow!("error logger already initialized"))
}
pub fn configure_error_log(settings: ErrorLogSettings) {
if let Some(controller) = ERROR_LOG.get() { controller.configure(settings); }
if let Some(controller) = ERROR_LOG.get() {
controller.configure(settings);
}
}
pub fn error_log_stats() -> Option<ErrorLogStats> {
ERROR_LOG.get().map(|controller| controller.stats())
@@ -377,7 +395,9 @@ pub struct ErrorLogLayer {
controller: Arc<ErrorLogController>,
}
impl ErrorLogLayer {
pub fn new(controller: Arc<ErrorLogController>) -> Self { Self { controller } }
pub fn new(controller: Arc<ErrorLogController>) -> Self {
Self { controller }
}
}
#[derive(Default)]
@@ -388,21 +408,42 @@ struct EventVisitor {
impl tracing::field::Visit for EventVisitor {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
let value = format!("{value:?}");
if field.name() == "message" { self.message = Some(value); }
else { self.fields.insert(field.name().to_owned(), value); }
if field.name() == "message" {
self.message = Some(value);
} else {
self.fields.insert(field.name().to_owned(), value);
}
}
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
if field.name() == "message" { self.message = Some(value.to_owned()); }
else { self.fields.insert(field.name().to_owned(), value.to_owned()); }
if field.name() == "message" {
self.message = Some(value.to_owned());
} else {
self.fields
.insert(field.name().to_owned(), value.to_owned());
}
}
fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
self.fields
.insert(field.name().to_owned(), value.to_string());
}
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
self.fields
.insert(field.name().to_owned(), value.to_string());
}
fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
self.fields
.insert(field.name().to_owned(), value.to_string());
}
fn record_f64(&mut self, field: &tracing::field::Field, value: f64) {
self.fields
.insert(field.name().to_owned(), value.to_string());
}
fn record_i64(&mut self, field: &tracing::field::Field, value: i64) { self.fields.insert(field.name().to_owned(), value.to_string()); }
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) { self.fields.insert(field.name().to_owned(), value.to_string()); }
fn record_bool(&mut self, field: &tracing::field::Field, value: bool) { self.fields.insert(field.name().to_owned(), value.to_string()); }
fn record_f64(&mut self, field: &tracing::field::Field, value: f64) { self.fields.insert(field.name().to_owned(), value.to_string()); }
}
impl<S: Subscriber> Layer<S> for ErrorLogLayer {
fn on_event(&self, event: &Event<'_>, _ctx: LayerContext<'_, S>) {
if *event.metadata().level() != Level::ERROR { return; }
if *event.metadata().level() != Level::ERROR {
return;
}
let mut visitor = EventVisitor::default();
event.record(&mut visitor);
let mut fields = visitor.fields;
@@ -424,4 +465,6 @@ impl<S: Subscriber> Layer<S> for ErrorLogLayer {
#[derive(Clone, Copy, Default)]
pub struct RedactingStderr;
pub struct RedactingWriter { buffer: Vec<u8> }
pub struct RedactingWriter {
buffer: Vec<u8>,
}
+36 -8
View File
@@ -46,12 +46,24 @@ 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 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 { '_' });
safe.push(
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
ch
} else {
'_'
},
);
}
if safe.is_empty() {
"_unmatched".into()
} else {
safe
}
if safe.is_empty() { "_unmatched".into() } else { safe }
}
struct LogstashState {
address: String,
@@ -93,15 +105,23 @@ async fn sink_loop(
if tokio::fs::create_dir_all(&directory).await.is_err() {
failed = true;
} else {
let path = directory.join(format!("{}.jsonl", vhost_log_name(entry.vhost.as_deref())));
let path =
directory.join(format!("{}.jsonl", vhost_log_name(entry.vhost.as_deref())));
if !vhost_files.contains_key(&path) {
if vhost_files.len() >= PER_VHOST_OPEN_FILES {
if let Some(path) = vhost_files.keys().next().cloned() {
vhost_files.remove(&path);
}
}
match OpenOptions::new().create(true).append(true).open(&path).await {
Ok(opened) => { vhost_files.insert(path.clone(), opened); }
match OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.await
{
Ok(opened) => {
vhost_files.insert(path.clone(), opened);
}
Err(error) => {
tracing::warn!(%error, path=%path.display(), "per-vhost access log file unavailable");
failed = true;
@@ -122,8 +142,16 @@ async fn sink_loop(
vhost_directory = None;
let path = PathBuf::from(&config.file.path);
if file.as_ref().is_none_or(|state| state.path != path) {
file = match OpenOptions::new().create(true).append(true).open(&path).await {
Ok(opened) => Some(FileState { path: path.clone(), file: opened }),
file = match OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.await
{
Ok(opened) => Some(FileState {
path: path.clone(),
file: opened,
}),
Err(error) => {
tracing::warn!(%error, path=%path.display(), "access log file unavailable");
None
+4 -1
View File
@@ -155,6 +155,9 @@ mod tests {
}
assert_eq!(logs.recent_limit(LIVE_WINDOW).len(), LIVE_WINDOW);
assert_eq!(logs.recent().len(), LIVE_WINDOW + 80);
assert_eq!(logs.recent_limit(HISTORY_CAPACITY + 1).len(), LIVE_WINDOW + 80);
assert_eq!(
logs.recent_limit(HISTORY_CAPACITY + 1).len(),
LIVE_WINDOW + 80
);
}
}
+6 -2
View File
@@ -107,9 +107,13 @@ async fn run(
let initialize = directory.clone();
tokio::task::spawn_blocking(move || rust_proxy::tls::initialize_default(&initialize)).await??;
rust_proxy::secret_store::initialize(&data_dir)?;
let migrated = rust_proxy::secret_store::migrate_acme_credentials(&store, &data_dir, &directory).await?;
let migrated =
rust_proxy::secret_store::migrate_acme_credentials(&store, &data_dir, &directory).await?;
if migrated > 0 {
tracing::info!(migrated, "legacy ACME credentials migrated to encrypted Secret Store");
tracing::info!(
migrated,
"legacy ACME credentials migrated to encrypted Secret Store"
);
}
let cert_dir = Some(directory.clone());
let policy = ValidationPolicy {
+54 -25
View File
@@ -8,6 +8,12 @@ use hyper_util::{
client::legacy::{connect::HttpConnector, Client},
rt::TokioExecutor,
};
use rust_proxy::{
auth,
config::{self, Config, Envelope, ValidationPolicy},
db::Store,
runtime::App,
};
use rustls::{
client::{
danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier},
@@ -16,12 +22,6 @@ use rustls::{
pki_types::{CertificateDer, ServerName, UnixTime},
ClientConfig, DigitallySignedStruct, RootCertStore, SignatureScheme,
};
use rust_proxy::{
auth,
config::{self, Config, Envelope, ValidationPolicy},
db::Store,
runtime::App,
};
use sha2::{Digest, Sha256};
use std::io::{self, BufRead, IsTerminal, Read, Write};
use std::{
@@ -34,14 +34,14 @@ use tokio::{
net::{TcpListener, UdpSocket},
task::JoinSet,
};
use zeroize::Zeroizing;
use tracing_subscriber::prelude::*;
use zeroize::Zeroizing;
#[derive(Parser)]
#[command(
name = "rust-proxy",
version,
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.25.4"
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.25.5"
)]
struct Cli {
#[arg(
@@ -142,9 +142,7 @@ enum BackupCommand {
output: Option<PathBuf>,
},
/// Restore an encrypted full backup atomically. Stop the RustProxy service first.
Restore {
archive: PathBuf,
},
Restore { archive: PathBuf },
}
#[derive(Subcommand)]
@@ -241,11 +239,21 @@ fn main() -> Result<()> {
}
fn installed_data_dir_candidates() -> &'static [&'static str] {
#[cfg(any(target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly"))]
#[cfg(any(
target_os = "freebsd",
target_os = "openbsd",
target_os = "netbsd",
target_os = "dragonfly"
))]
{
return &["/var/db/rustproxy", "/var/lib/rustproxy"];
}
#[cfg(not(any(target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "dragonfly")))]
#[cfg(not(any(
target_os = "freebsd",
target_os = "openbsd",
target_os = "netbsd",
target_os = "dragonfly"
)))]
{
&["/var/lib/rustproxy", "/var/db/rustproxy"]
}
@@ -302,11 +310,16 @@ async fn application() -> Result<()> {
let cli = Cli::from_arg_matches(&matches)?;
let data_dir_explicit = explicit(&matches, "data_dir");
if cli.livelogs {
ensure!(cli.command.is_none(), "--livelogs cannot be combined with a subcommand");
ensure!(
cli.command.is_none(),
"--livelogs cannot be combined with a subcommand"
);
let data_dir = resolve_installed_data_dir(cli.data_dir, data_dir_explicit);
return live_logs_command(data_dir, cli.admin_listen).await;
}
let command = cli.command.context("a subcommand is required (or use --livelogs)")?;
let command = cli
.command
.context("a subcommand is required (or use --livelogs)")?;
match command {
Command::Token => {
admin_command(
@@ -316,7 +329,11 @@ async fn application() -> Result<()> {
.await
}
Command::Admin { action } => {
admin_command(resolve_installed_data_dir(cli.data_dir, data_dir_explicit), action).await
admin_command(
resolve_installed_data_dir(cli.data_dir, data_dir_explicit),
action,
)
.await
}
Command::Tls { action } => {
tls_command(
@@ -368,15 +385,19 @@ async fn backup_command(data_dir: PathBuf, action: BackupCommand) -> Result<()>
});
let mut generated = false;
let password = if io::stdin().is_terminal() {
let value = Zeroizing::new(
rpassword::prompt_password("Backup password (leave empty to generate): ")?
);
let value = Zeroizing::new(rpassword::prompt_password(
"Backup password (leave empty to generate): ",
)?);
if value.is_empty() {
generated = true;
Zeroizing::new(rust_proxy::backup::generated_password())
} else {
let confirmation = Zeroizing::new(rpassword::prompt_password("Confirm backup password: ")?);
ensure!(value.as_str() == confirmation.as_str(), "backup passwords do not match");
let confirmation =
Zeroizing::new(rpassword::prompt_password("Confirm backup password: ")?);
ensure!(
value.as_str() == confirmation.as_str(),
"backup passwords do not match"
);
value
}
} else {
@@ -386,10 +407,15 @@ async fn backup_command(data_dir: PathBuf, action: BackupCommand) -> Result<()>
let summary = rust_proxy::backup::create(&data_dir, &output, password.as_str()).await?;
println!(
"Encrypted backup created: {} ({} files, configuration revision {})",
summary.path.display(), summary.files, summary.configuration_revision
summary.path.display(),
summary.files,
summary.configuration_revision
);
if generated {
println!("Generated backup password (shown once): {}", password.as_str());
println!(
"Generated backup password (shown once): {}",
password.as_str()
);
println!("Store this password securely; restore is impossible without it.");
}
Ok(())
@@ -401,10 +427,13 @@ async fn backup_command(data_dir: PathBuf, action: BackupCommand) -> Result<()>
);
let password = Zeroizing::new(rpassword::prompt_password("Backup password: ")?);
ensure!(!password.is_empty(), "backup password cannot be empty");
let summary = rust_proxy::backup::restore(&archive, &data_dir, password.as_str()).await?;
let summary =
rust_proxy::backup::restore(&archive, &data_dir, password.as_str()).await?;
println!(
"Backup restored to {} ({} files, configuration revision {})",
data_dir.display(), summary.files, summary.configuration_revision
data_dir.display(),
summary.files,
summary.configuration_revision
);
Ok(())
}
+99 -24
View File
@@ -169,7 +169,10 @@ struct LiveLogFilters {
}
fn log_string<'a>(entry: &'a serde_json::Value, key: &str) -> &'a str {
entry.get(key).and_then(|value| value.as_str()).unwrap_or("-")
entry
.get(key)
.and_then(|value| value.as_str())
.unwrap_or("-")
}
fn log_optional_string<'a>(entry: &'a serde_json::Value, key: &str) -> Option<&'a str> {
@@ -178,7 +181,10 @@ fn log_optional_string<'a>(entry: &'a serde_json::Value, key: &str) -> Option<&'
fn live_status_matches(filter: &str, status: u64) -> bool {
if let Some(prefix) = filter.strip_suffix("xx") {
return prefix.parse::<u64>().ok().is_some_and(|class| status / 100 == class);
return prefix
.parse::<u64>()
.ok()
.is_some_and(|class| status / 100 == class);
}
filter.parse::<u64>().ok() == Some(status)
}
@@ -205,7 +211,10 @@ fn live_log_matches(entry: &serde_json::Value, filters: &LiveLogFilters) -> bool
return false;
}
}
let status = entry.get("status").and_then(|value| value.as_u64()).unwrap_or(0);
let status = entry
.get("status")
.and_then(|value| value.as_u64())
.unwrap_or(0);
if filters
.status
.as_ref()
@@ -234,7 +243,10 @@ fn live_log_matches(entry: &serde_json::Value, filters: &LiveLogFilters) -> bool
}
fn live_time(entry: &serde_json::Value) -> String {
let ms = entry.get("time_ms").and_then(|value| value.as_u64()).unwrap_or(0);
let ms = entry
.get("time_ms")
.and_then(|value| value.as_u64())
.unwrap_or(0);
let seconds = (ms / 1000) % 86_400;
format!(
"{:02}:{:02}:{:02}.{:03}Z",
@@ -246,8 +258,14 @@ fn live_time(entry: &serde_json::Value) -> String {
}
fn print_live_entry(entry: &serde_json::Value, detail: bool) {
let id = entry.get("id").and_then(|value| value.as_u64()).unwrap_or(0);
let status = entry.get("status").and_then(|value| value.as_u64()).unwrap_or(0);
let id = entry
.get("id")
.and_then(|value| value.as_u64())
.unwrap_or(0);
let status = entry
.get("status")
.and_then(|value| value.as_u64())
.unwrap_or(0);
let duration = entry
.get("duration_ms")
.and_then(|value| value.as_u64())
@@ -304,7 +322,9 @@ fn live_filters_summary(filters: &LiveLogFilters) -> String {
let mut values = Vec::new();
macro_rules! add {
($name:literal, $value:expr) => {
if let Some(value) = $value.as_ref() { values.push(format!("{}={}", $name, value)); }
if let Some(value) = $value.as_ref() {
values.push(format!("{}={}", $name, value));
}
};
}
add!("host", filters.host);
@@ -317,11 +337,23 @@ fn live_filters_summary(filters: &LiveLogFilters) -> String {
add!("cache", filters.cache);
add!("status", filters.status);
add!("path", filters.path_prefix);
if filters.errors_only { values.push("errors=on".into()); }
if let Some(ms) = filters.slow_ms { values.push(format!("slow>={ms}ms")); }
if filters.detail { values.push("detail=on".into()); }
if filters.paused { values.push("paused".into()); }
if values.is_empty() { "none".into() } else { values.join(" ") }
if filters.errors_only {
values.push("errors=on".into());
}
if let Some(ms) = filters.slow_ms {
values.push(format!("slow>={ms}ms"));
}
if filters.detail {
values.push("detail=on".into());
}
if filters.paused {
values.push("paused".into());
}
if values.is_empty() {
"none".into()
} else {
values.join(" ")
}
}
fn live_help() {
@@ -356,20 +388,44 @@ fn handle_live_command(
match command.as_str() {
"help" | "?" => live_help(),
"filters" => println!("filters: {}", live_filters_summary(filters)),
"reset" => { *filters = LiveLogFilters::default(); println!("filters cleared"); }
"pause" => { filters.paused = true; println!("paused"); }
"resume" => { filters.paused = false; println!("resumed"); }
"clear" => { print!("\x1b[2J\x1b[H"); io::stdout().flush()?; }
"reset" => {
*filters = LiveLogFilters::default();
println!("filters cleared");
}
"pause" => {
filters.paused = true;
println!("paused");
}
"resume" => {
filters.paused = false;
println!("resumed");
}
"clear" => {
print!("\x1b[2J\x1b[H");
io::stdout().flush()?;
}
"quit" | "exit" | "q" => return Ok(false),
"detail" => filters.detail = matches!(value, "on" | "1" | "true"),
"errors" => filters.errors_only = matches!(value, "on" | "1" | "true"),
"slow" => {
filters.slow_ms = if matches!(value, "" | "off" | "*") { None } else { Some(value.parse().context("slow expects milliseconds or off")?) };
filters.slow_ms = if matches!(value, "" | "off" | "*") {
None
} else {
Some(value.parse().context("slow expects milliseconds or off")?)
};
}
"status" => {
if matches!(value, "" | "*" | "off") { filters.status = None; }
else {
ensure!(matches!(value, "1xx" | "2xx" | "3xx" | "4xx" | "5xx") || value.parse::<u16>().ok().is_some_and(|v| (100..=599).contains(&v)), "status expects 100..599 or 1xx..5xx");
if matches!(value, "" | "*" | "off") {
filters.status = None;
} else {
ensure!(
matches!(value, "1xx" | "2xx" | "3xx" | "4xx" | "5xx")
|| value
.parse::<u16>()
.ok()
.is_some_and(|v| (100..=599).contains(&v)),
"status expects 100..599 or 1xx..5xx"
);
filters.status = Some(value.to_ascii_lowercase());
}
}
@@ -378,9 +434,19 @@ fn handle_live_command(
"backend" => set_live_filter(&mut filters.backend, value),
"route" => set_live_filter(&mut filters.route, value),
"client" => set_live_filter(&mut filters.client, value),
"method" => { set_live_filter(&mut filters.method, value); if let Some(v) = filters.method.as_mut() { *v = v.to_ascii_uppercase(); } },
"method" => {
set_live_filter(&mut filters.method, value);
if let Some(v) = filters.method.as_mut() {
*v = v.to_ascii_uppercase();
}
}
"transport" => set_live_filter(&mut filters.transport, value),
"cache" => { set_live_filter(&mut filters.cache, value); if let Some(v) = filters.cache.as_mut() { *v = v.to_ascii_uppercase(); } },
"cache" => {
set_live_filter(&mut filters.cache, value);
if let Some(v) = filters.cache.as_mut() {
*v = v.to_ascii_uppercase();
}
}
"path" => set_live_filter(&mut filters.path_prefix, value),
"show" => {
let id: u64 = value.parse().context("show expects a request id")?;
@@ -391,7 +457,10 @@ fn handle_live_command(
}
_ => bail!("unknown live-log command '{command}' (type help)"),
}
if !matches!(command.as_str(), "help" | "?" | "filters" | "show" | "clear") {
if !matches!(
command.as_str(),
"help" | "?" | "filters" | "show" | "clear"
) {
println!("filters: {}", live_filters_summary(filters));
}
Ok(true)
@@ -406,7 +475,13 @@ fn take_sse_event(buffer: &mut Vec<u8>) -> Option<Vec<u8>> {
let lf = find(b"\n\n").map(|index| (index, 2));
let crlf = find(b"\r\n\r\n").map(|index| (index, 4));
let (index, delimiter) = match (lf, crlf) {
(Some(a), Some(b)) => if a.0 <= b.0 { a } else { b },
(Some(a), Some(b)) => {
if a.0 <= b.0 {
a
} else {
b
}
}
(Some(a), None) => a,
(None, Some(b)) => b,
(None, None) => return None,
Loaded 100 of 149 files, more files were not shown because too many files have changed in this diff. Show more