v0.31.8
This commit is contained in:
1 parent
68ada6680d
commit
180a0e4877
56 files changed
+9865
-204
No files matched your search
@@ -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.31.7.
|
||||
compatibility. Current source version: 0.31.8.
|
||||
|
||||
## Delivery
|
||||
|
||||
@@ -36,6 +36,10 @@ SQLite write. Invalid updates leave the old snapshot active.
|
||||
|
||||
## Verification
|
||||
|
||||
Keep production Rust source files within 500 lines. Rust unit and integration
|
||||
**test files are exempt** from this limit: do not create numbered `part_XX.rs`
|
||||
fragments to satisfy source-size checks. Consolidate coherent test suites in
|
||||
`tests.rs` and preserve the existing test cases.
|
||||
Run `./dev.sh check` and `./dev.sh test` when Rust is available. Run the SQL and upgrade
|
||||
scripts even when it is not. UI tests use a simulated API; the Python protocol-peer
|
||||
self-test is not a test of RustProxy. Record these distinctions accurately.
|
||||
|
||||
@@ -3,6 +3,16 @@
|
||||
> Operator-facing release history. Historical engineering notes are kept in
|
||||
> [`docs/CHANGELOG_TECHNICAL_ARCHIVE.md`](docs/CHANGELOG_TECHNICAL_ARCHIVE.md).
|
||||
|
||||
## 0.31.8
|
||||
|
||||
- Consolidate 28 numbered Rust unit-test fragments into seven existing `tests.rs` suites (Admin, Balance, Config, Web Server and three Cache suites). Preserve the tests, remove obsolete fragment directories, and exempt Rust test sources (not production modules) from the 500-line check. Fix the test fixture's relative `login-keys.json` include path. No runtime functionality changes.
|
||||
- Fix Docker VHosts startup status: a missing first observation is WAITING, not a connection failure. Read cached agent status when opening the page (including with Live view off) and briefly retry while the first background check is pending. Display an error only after a failed agent check; omit the configured certificate SHA-256 from generic failure details because it is not a diagnosed failure cause. A configured pin alone does not mark the agent connected. Keep agent polling, certificate validation, manual refresh and persisted settings unchanged.
|
||||
- Add server-side Stored Logs sorting by visible columns, including numeric and discovered nested fields; preserve sorting over the complete bounded scan before pagination. Column and direction persist with account preferences, with no SQLite schema change.
|
||||
- Add matching dark HTML built-in pages for HTTP 200, 400, 408, 410, 413, 500 and 502. Align the remaining generic HTML status template with the established RustProxy appearance. Preserve administrator-configured responses, status codes, protocol headers and bodyless responses.
|
||||
- Reduce long-lived GeoIP CSV allocations by storing exactly sized boxed range slices and 34-byte network-order IPv6 records instead of 48-byte u128-aligned records; retain equivalent ordered range search.
|
||||
- Avoid duplicating a single uniquely-owned transformed response frame when filling HOT cache, provided its allocation capacity is known and tightly bounded; preserve the copying fallback for stream/fragmented or shared bodies, HOT accounting and HTTP content unchanged.
|
||||
- Keep existing cache/transport limits and persisted configuration format. Build, Rust tests, live deployment and memory comparisons still require verification with the supported toolchain.
|
||||
|
||||
## 0.31.7
|
||||
|
||||
- Allow intentionally pruned historical release audits and implementation notes in the 0.31.7 source archive. Update preflight, documentation/source contract checks, and the embedded Reader registry to require actual runtime/compiled documents instead. Remove stale documentation links, update Reader integration assertions, and add a regression check that still rejects missing embedded documents or runtime modules. No runtime configuration, storage format or UI behavior changed.
|
||||
|
||||
Generated
+1
-1
@@ -1881,7 +1881,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rust-proxy"
|
||||
version = "0.31.7"
|
||||
version = "0.31.8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rust-proxy"
|
||||
version = "0.31.7"
|
||||
version = "0.31.8"
|
||||
edition = "2021"
|
||||
rust-version = "1.99"
|
||||
description = "RustProxy - independent reverse proxy with local administration"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
# RustProxy 0.31.7
|
||||
# RustProxy 0.31.8
|
||||
|
||||
**0.31.0 panel/cache additions:** browser-negotiated Zstd/Brotli/gzip for HTML, JS and CSS (Settings → Administration); optional HTTP BAN/PURGE restricted to explicit IP/CIDR allowlist (Settings → Cache, disabled by default); System information Refresh button beside the API badge. See [Settings](docs/SETTINGS.md) and [Cache](docs/CACHE.md).
|
||||
|
||||
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.31.7 per-vhost User-Agent limits, Settings grouping, revision diff, and safe update retention (Rust/Cargo and live testing required).**
|
||||
**0.31.8 Stored Logs sorting, consistent built-in status pages and scoped memory optimizations (Rust/Cargo and live testing required).**
|
||||
|
||||
## Layer-4 streams
|
||||
|
||||
@@ -13,7 +13,17 @@ Raw TCP/UDP forwarding has its own **Streams** page in the primary navigation, a
|
||||
|
||||
Named HTTP/HTTPS listener bind addresses use the same chip-style editor as vhost hostnames, including multi-address listeners.
|
||||
|
||||
## What changed in 0.31.7
|
||||
## What changed in 0.31.8
|
||||
|
||||
- **Test organization:** merge 28 mechanically numbered Rust test fragments into seven coherent `tests.rs` suites. Test-only files are exempt from the 500-line production-module limit; no test cases or runtime behavior are intentionally changed. See [test report](docs/TEST_REPORT.md).
|
||||
- **Stored Logs:** click a desktop column heading to sort ascending/descending (including discovered nested JSON columns). Sorting applies to the entire bounded server-side matching scan **before pagination**, with missing values last. Direction and selected sort column persist per administrator. Existing filters, mobile cards, and repeated-value analysis are unchanged.
|
||||
- **Built-in HTML responses:** add missing styled pages for 200, 400, 408, 410, 413, 500 and 502. Any other built-in HTTP status uses the same compact dark appearance as the established 401/403/404/429/503/504 templates. Administrators' custom HTML remains authoritative, and 204/205/304 remain bodyless.
|
||||
- **GeoIP:** freeze loaded CSV arrays to exactly sized boxed slices; pack IPv6 range addresses in network byte order (34 bytes versus 48 bytes per record) without changing lookup semantics. Expected decrease in retained GeoIP allocations is workload-dependent, approximately 17 MiB for the reported 293,569 IPv4 and 284,256 IPv6 records; verify actual RSS separately.
|
||||
- **Cache fills:** avoid a duplicate body allocation when a transformed response supplies a single uniquely owned frame with known, tightly sized capacity. Preserve the existing bounded Vec-based streaming/fallback path and charge the shared backing capacity to HOT. This targets temporary peak allocations, not a guaranteed reduction of steady-state RSS.
|
||||
- **Docker VHosts:** display a neutral waiting state while the first agent observation is pending after restart, then the result from background discovery. Opening Docker VHosts reads the latest cached status even with per-page Live view off; first-result synchronization stops afterward. A configured TLS fingerprint no longer falsely signals a verified connection. Agent polling and security remain unchanged.
|
||||
- No settings/SQLite schema migration or change to the HOT limit. **Runtime acceptance gate remains open** until Cargo build/tests, browser, H1/H2/H3 and real-process resource tests pass.
|
||||
|
||||
## Previously in 0.31.7
|
||||
|
||||
- **Source-package cleanup:** old per-version audits and implementation reports may be omitted; preflight still requires active sources and docs embedded in the panel Reader. This fixes the removed `docs/V0.25.5_AUDIT.md` preflight error without restoring obsolete files.
|
||||
- **Settings → Rules & headers:** the global User-Agent blocklist, global custom header editor and client response settings now share a Cache-style tabbed page (three persistent sub-tabs). Settings → Security keeps sessions/password and host access policies.
|
||||
|
||||
+11
@@ -1,5 +1,16 @@
|
||||
# RustProxy roadmap
|
||||
|
||||
## 0.31.8 - Stored Logs sorting, built-in pages and allocation reduction (runtime acceptance pending)
|
||||
|
||||
- [x] Consolidate 28 numbered Rust test fragments into seven large `tests.rs` files; exempt test sources from the 500-line production limit and remove obsolete fragment paths.
|
||||
- [x] Sort Stored Logs across the bounded server-side scan before pagination, including dynamic columns and durable account preferences.
|
||||
- [x] Complete the built-in status response pages in the existing visual style, including 200 OK.
|
||||
- [x] Pack GeoIP IPv6 records and remove unused CSV Vec capacity without changing lookup results.
|
||||
- [x] Add bounded zero-copy transformed-cache fill where exclusive ownership and exact allocation capacity are available.
|
||||
- [x] Correct Docker VHosts post-restart pending/failed status, first-read synchronization and TLS pin indicator, without changing poll/TLS/SQLite protocols.
|
||||
- [ ] Compile, format, run Rust tests and check performance with Rust 1.99+, resolve/verify dependencies and run real H1/H2/H3 + browser acceptance.
|
||||
- [ ] Compare stable RSS at identical HOT usage and traffic; do not claim the estimated allocation savings are measured RSS.
|
||||
|
||||
## 0.31.7 - UA-specific throttling and managed update housekeeping (runtime acceptance pending)
|
||||
|
||||
- [x] Group global UA blocking, headers and response controls in a tabbed Settings page.
|
||||
|
||||
+55
-73
@@ -1,18 +1,18 @@
|
||||
61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore
|
||||
4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example
|
||||
9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore
|
||||
685f856d31bf33f724aaa4afc23a6b3d9d9ca9e8e99338792db4f3563621f449 AGENTS.md
|
||||
5910c76f4aacab46365bb9a549d90a5b47b1e67986efeb36dbbc6b2fbb510229 CHANGELOG.md
|
||||
b8e0b191c849a1c4eac46b0091c228a0c0cc52de4c708f9e9ca23a621b870e3a Cargo.lock
|
||||
3f8c093e5a499d526df5f81ee246df70b3512a8fa1cbb1e071210b6eae0af706 Cargo.toml
|
||||
6ea926f3894f548532eaec87bbe4ef03cacbb9bc49d81331af11d14d49f8b3fa AGENTS.md
|
||||
763041714a5be537d3d1bcd4cf393600228807706924855ccfd9663a8af5d403 CHANGELOG.md
|
||||
427b7b7bffadc0440a8d3400565b528153800825c0cfc3951f6c67c33a64ae1d Cargo.lock
|
||||
83a13d441c530b0a120beb45a78381b53153568f3b6c1289e5922a3a0a7383ad Cargo.toml
|
||||
25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile
|
||||
4c58d8ad6cdbcc643616a25e5d6e3711124e0164aaaedfc1a9efa74715fdab77 README.md
|
||||
db3235387db9e11f8193dfeeb853767f27487107a903cfbb0b5873b1b5124be8 ROADMAP.md
|
||||
a1a145015876773f43efef010a6d1e48819e7d1ac5a61b9ef6b466ab1221ec8c README.md
|
||||
9e38c8a5b4f011fcd26f467ebcbdc59658b45fa9da1622ebf7704fdb335d11ba ROADMAP.md
|
||||
da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml
|
||||
f645f218dd062bf6cb54a8ee634290562cad364a4cccf09f16e3c8303582af8c build.rs
|
||||
ea8d091843f86f4c66a5c76b20268b2feb397070851b6e05c5bc7d7a8d9157eb deploy/README.md
|
||||
4834c278430ddf3bf842995735195368f1ac2c3b5c1d749df4f9238caf1e2b25 deploy/rustproxy.service
|
||||
c1addab11162ba4a763ac40548ac8ae29db5a6909940309e4bba4c0abc334e7a dev.sh
|
||||
1a8fe0ebb42fe780831edbda10b6e122b70ee546598dc80a13ec4cde5d516e03 dev.sh
|
||||
5f51efe621f45512a466fa7f2656867c6502a135ca040f4bfeec06a6919b39b6 docker-agent/README.md
|
||||
7fee7d51a7deb67e73e53a0bdff3aa4630aea9bab9b23a0f9b8001b2169892b1 docker-agent/certificate.go
|
||||
5e4f565c3c9de4dba897af2e801ea646a83395e72eae4717a6d502dff2fcec10 docker-agent/docker-agent.env.example
|
||||
@@ -29,15 +29,15 @@ e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/n
|
||||
ec15163f5f51c9212003b7edd1fc380321311c2496fbaa9b45c838281b9af475 docs/ACME_RFC2136.md
|
||||
e25ce97c6d1d75f0cab162eee28c1cb77561b210ad36ba7189d81328c3bb4d6c docs/API.md
|
||||
b0c6fae1d538f33fb468a00307cbb745a601d9fc341c0a4f859d60c7e8d48865 docs/API_ACCESS.md
|
||||
4c5288c9ed427cfb919bd023ed9a321dcf6d64bc4cad3e1768393356c5d835ba docs/ARCHITECTURE.md
|
||||
6595694c6fb6c52e26c4a15822b5ca8564d4e0de7f5a7fea5c24ab67c24cb49a docs/ARCHITECTURE.md
|
||||
e9e2327684a4ae2d1ca4a0b3fb67d0ff83b5a150d38d4de4cfad2c5d3102eab6 docs/CACHE.md
|
||||
9113f3abbfe81bafc4855d8680df8aa2a381ecc358cc3e1a0f78b6b756421117 docs/CACHE_DIAGNOSTICS.md
|
||||
3822d72e12f82c37a9e70ab40c7e89e89145ad37f29914e4702db4bf024a5501 docs/CHANGELOG_TECHNICAL_ARCHIVE.md
|
||||
c8e69e492179ce39958cc63615cf46c4ac08cfdb7f3483aaab66868a3abcd687 docs/CONTAINER.md
|
||||
833ddb12f23dcac796223521388b19c6ec3ea04c5550cf1c5f4ea4eb7c58f3a0 docs/CUSTOM_LOCATIONS.md
|
||||
16d8f59908c66e595d6003495cbf5c068c68b10aa9495eb8612c38b2de0e4a7c docs/DOCKER_DISCOVERY.md
|
||||
4d70797bda8d227787fcff897951e702e2458fc6c6f5352aa0b5577595bd834b docs/DOCKER_DISCOVERY.md
|
||||
5324b215075214a5bb9b388bc82d6491eacfc507df95856d1aecedd184e25b80 docs/ERROR_LOGGING.md
|
||||
74dfbc0d67241d3ec47f17850e69cb8348c10e55e53bb26176b715f36d01883e docs/FILE_LOGS.md
|
||||
f749f7bd2eb4ae9c484f77a3cd8cd5ff21445319a3938e0384a3414134fe713a docs/FILE_LOGS.md
|
||||
36599e0b59164e662442f3e8203c16b3123d09b7684d8ecdbffce42d22cc221b docs/HTTP3.md
|
||||
a00ec3c1d3b28ba10677435128e031e5f18fe8c5cef2ac90eb0f6779cefeb434 docs/INSTALL.md
|
||||
3b5c31c805e5aee0dc2100b81246a9b631b5abb996faa598ae94f87ba39305f3 docs/INTEGRATIONS_040.md
|
||||
@@ -63,7 +63,7 @@ ec7e32fd19e6ba6ed1732f1f3dd92ac0860609e8c0bbd3c1998840cdfb24bf54 docs/SECURITY.
|
||||
007537ee971463072b2482dfa4fdcf1955f837ced0b711d4eef3b6267d16d4f6 docs/SECURITY_AUDIT_0.31.0.md
|
||||
7f27d782534455eebce538f10c902346f7d194acd75b6106c7f8dec3c44857e9 docs/SETTINGS.md
|
||||
3611defd206cde107e75cac006159856d12cd8f15df7ae2eb9ec3592ed2b4abd docs/STREAMS.md
|
||||
cb440d5f2ce8fb77e00e75f4bff9a768044b19bad3188888b6221759975c099f docs/TEST_REPORT.md
|
||||
e97895472f909bd6dcbb0643c9f5d3b7c3eb229c81b53487807265faef6fff62 docs/TEST_REPORT.md
|
||||
cfb452d8496839e832b5fbacdd4e52525b0ecc3112fbb62340c1e580bd5c1717 docs/TODO.md
|
||||
751c9b13b47d0959a8575d6e82bdecba988b950a374ba0b084fbba08b4b65ebe docs/TRANSFORM.md
|
||||
660687fc961f71aae2bdf71595aa47a336fa782f4e78ceee8c1d1e63e46393ee docs/TRANSPORT.md
|
||||
@@ -95,8 +95,8 @@ cb01fdef286f38b442dd9252c23c4f8e44eb72e9ecdd99adb926e8e9f6318bb1 integrations/r
|
||||
96563a83e87cfb33e238c2a970ce36b1a1b51e91318d09b123c3755002ba2646 integrations/routeros-oci-build/build-routeros-oci.sh
|
||||
3f267cafa16006e5456ade78ee2d5fae02cc7ce747839523068ffd8d7c98798a integrations/routeros-oci-build/docker-entrypoint.sh
|
||||
71cde48daa538431dfac51e438b1ed23b061abfdb4e144ba704237d75ac6270c langs/README.md
|
||||
1e746962a3db371948b1f820bcca02ecf6852319868f6e77b421d4da277845f1 langs/en_US.json
|
||||
a45134738a1cea89f0c18bf352e1ff7e18089769abf49b2a043f1d61d5f50f03 langs/pl_pl.json
|
||||
4106dd653c2968b4a20b18ce629a976b7e58cf9dcd5d6d9938855ec6ef005efc langs/en_US.json
|
||||
a2fa657001729c02a073fa962f37d5025be05b206e098e08a3f177453fc2ab0b langs/pl_pl.json
|
||||
a99ad040be10fa028351e290b9363236b3513a1cadbdc9fb9e0260d517b9b0ae make_zip.py
|
||||
e830d37dd1f81a4b0aa7d535f5bba69e34e433b0fd99cc8ee0b296786c32379a migrations/0002_admin.sql
|
||||
20112cc82192098b8d602f1c4f7ca08712e9e1366c845fabb92753187c370d35 migrations/0003_certificate_renewals.sql
|
||||
@@ -122,7 +122,7 @@ a641bd35400c56f3b68b0a92e97b602d06090f03972ca8f01fa744e90dd97403 scripts/deploy
|
||||
2cc658faaa610b21b48942c59b955525539ad4503fd60f2a49c7e626a22faaff scripts/migration/migrate_npm.py
|
||||
a459d95bbb0076f33ade8b273b813308167f9d8d45242b309a2d18320ca0093c scripts/migration/migrate_npm_api.py
|
||||
31ccad3ec3c880119f3b7002f0c6652f8b43634edad8ab3763cb0e4497d0bb1f scripts/migration/migrate_npm_core.py
|
||||
c904ae3acee8b4faf56fe64d8bd430542a44fbd923c65bb2bab310bf23def6ab scripts/resolve_dependencies_0312.sh
|
||||
f4bc093128ab0d8091f9e4e47d61bfaeacbebb0cf6e3b523cf7ddfdf5996aed7 scripts/resolve_dependencies_0312.sh
|
||||
20124173452b4514bad8850c1637569a9a191882478451e549bd16b432605fc1 scripts/runtime/acme_provider.py
|
||||
e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtime/acme_rfc2136.py
|
||||
22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py
|
||||
@@ -143,16 +143,16 @@ b6f38f3fd75d6685bbc882ed1077a38bf495cfcf01c397f69ed9b98be68b446f src/admin/acce
|
||||
958546c43c05bf4bed8121b24db3b3096f9243f3c56a71e1762592b755684013 src/admin/documentation/documents.rs
|
||||
67b901485d5858d5b5bf78a6260035db32241531d99481754c3533fdf404facb src/admin/documentation/tests.rs
|
||||
1ce779b84aa8f47bdb3c34f83593c1f2f06d2235f0319847096cfcd035434168 src/admin/file_log_search.rs
|
||||
b3aad0db47d930ee177253cb5d5eb772272452bab4385a34566b111c239a6302 src/admin/file_logs.rs
|
||||
ab18acf6fb4ac86fb22635b9219a12dac76d24952c8f15538486267a9b4b7855 src/admin/file_logs.rs
|
||||
c6b552cd5acd9e743c28b1a5564420c214c619fe891ab0561b7d4499aae8d6c0 src/admin/file_logs_io.rs
|
||||
d5eb4019a4aa6a4fdcad9310dce9076dcb85ca4640540c64e924367ba2276234 src/admin/file_logs_query.rs
|
||||
c720a7cad6a15c57b1f2ae7b3e5fbf30a97c64afb9137915f37b23f4e576bdc5 src/admin/file_logs_query.rs
|
||||
7de7da489a367658b700f00776fa43604d3554500b5fbef4da2764e402e68afa src/admin/logging_api.rs
|
||||
58802528128f53e493f7339063433a747b605007a8842b5efc670132d897d6f7 src/admin/mod.rs
|
||||
8d9db966f3c4f092c29bd17a4c16fbab2cfd3c6bb2c5f0063da3bb3035e91b67 src/admin/openapi.json
|
||||
6e5c18e8d9db9a52f3f0c3989efc41552251f238a6eaee87a19908495da2eb87 src/admin/openapi.json
|
||||
0d1a5324e8ad32f5ab0861b722663f50f066e256bc5fd5dcced6a63f2bd399d1 src/admin/operations.rs
|
||||
30895e05c5d5d0a26be29f667e0f6d72f16a96a5c8a98e09cd11d8ef2d7b4763 src/admin/panel_assets.rs
|
||||
75bbedd54fdabb2b489642a642ee7c6533f69cdacb85e251763a56ba11174d5c src/admin/panel_content.rs
|
||||
7c113cd9d8642a808c73ebee24e695f7a318995e39dc2b81e747d2b2246e277f src/admin/preferences.rs
|
||||
d8bad220bf42663be3355d0a4b8ea9cede1c425ba6c6e0d961c59cd16ff7f55a src/admin/preferences.rs
|
||||
d9e90166d2199152585a5208feedfc1dd1395dc32ddbd2a094ae4a1aee45e115 src/admin/preferences_columns.rs
|
||||
055642f1239346616788f3b1da52f814de5ba0649316352459b6d84b0710eeea src/admin/public.rs
|
||||
7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs
|
||||
@@ -163,21 +163,10 @@ e7355ef15e4377cd71e47d054c607079dd8e6711f1d86824f716b8003210a046 src/admin/stat
|
||||
e79d3dc0c4fde1ca4a69c204fb41cc5aa1db02aefdabff3435f27b5db0c6eb8d src/admin/status_live.rs
|
||||
c97881b17b7a42511868b0e48e2f25aab6fb5b613d538f5e1f1967508454e486 src/admin/status_system.rs
|
||||
cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs
|
||||
684307b74155ac7833acfb5e9e8eaccc92575d49b760928c30fc22e805a42a4c src/admin/tests.rs
|
||||
18aa617a0bf60a3d8d2ad7dd059a369e7549ef46683d8f912e07f8fb091c26d1 src/admin/tests/part_01.rs
|
||||
20abde2c1fa72d0712c2962479d66ae0131fb67ed12850c541306fe5ed1d2b84 src/admin/tests/part_02.rs
|
||||
34cf08cafb63cf7a8a6336e5c81fc99abfce8669662e387a3702c3a532be8056 src/admin/tests/part_03.rs
|
||||
2137a53991c9337988545c4472b4ffcb9a111cac9c460ebbec7e51c0be8f5630 src/admin/tests/part_04.rs
|
||||
334b1409f2c7f934b613c6613a0e0de7d2b65b8282b96255e072431373cd93b4 src/admin/tests/part_05.rs
|
||||
0274038c2b18f1c9ab0d990c0d636cf6d4255454018463f49984898086f74bdb src/admin/tests/part_06.rs
|
||||
402a219dbd70d6654bd1afc3d2ede2f5d3add96ba3f9867f7f51fba870efa404 src/admin/tests/part_07.rs
|
||||
54ed2130e85239ffa4576417dd68fbb9349e396210b75f5da862897137d7e876 src/admin/tests/part_08.rs
|
||||
225a29e2e560e42e06e19c3d41c0196c4a42e1e84caa7c83446cab62e3ac30dc src/admin/tests/part_09.rs
|
||||
bffa6611f3776e97d0065b510b63f083aabf7b3429fcc5cb26b1883fdb7c47dd src/admin/tests/part_10.rs
|
||||
578f93cc5771b5858813945e5608b15c572bd3d7fd15bae291d292e2994ece25 src/admin/tests/part_11.rs
|
||||
f2d56dfa0e03ca54077124842e66874a510415b95206f91a655eba4430b2c03e src/admin/tests.rs
|
||||
48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js
|
||||
d6c5448db12de34a7bb4924411b44d09c7144bc3e659bd3c8a7c0ebf1938da1c src/admin/web/app/config-history.js
|
||||
a42640573b29bf0d13f87b3336d3c86e50cd7f8af2c453836edfda23628fba97 src/admin/web/app/core.js
|
||||
d8a06511406d6ab974a6bf6cb5da35a1eeb4031144ef462c5390f9d0954fa059 src/admin/web/app/core.js
|
||||
23721bfbac850c4f77b116cc32bb9d2db6361ac4dd7e310a8c8070ab906997f6 src/admin/web/app/preferences.js
|
||||
729ed152eb8471c67fb0e55e44fe0669c2708802ce83228df9cb4e53c14cee02 src/admin/web/app/refresh.js
|
||||
984d092961bfaaa19f982e54c7d4be474e8417b208d814ac88bda63f1ab19f4c src/admin/web/app/runtime.js
|
||||
@@ -185,19 +174,25 @@ db56370db60e514a2241d231cd949bac2c7aa2f413a2c14035a877029a8d864f src/admin/web/
|
||||
86ac42bd4a6a84bb137d5a3c3dc3d1f4e404a543447845e6ea6e6e8210e268ce src/admin/web/app/settings.js
|
||||
4fec1344be3cd540d1e6d0b87fb0aa94e1bef2968c4f86366a62c56d4085f452 src/admin/web/cache-panel.js
|
||||
50d1ebcbaf4d0b2968da23bb943fc3f67131c5d32df3f61d23ff37aad9da5b81 src/admin/web/demo.html
|
||||
40cbf3a19827adbff179009b9189263a58a469319db25cf4adf0e4dc441c6532 src/admin/web/docker.js
|
||||
d01a2e48f344fe074c1e63ee0a9bbf7151dfe26140690418535197e13a2a16d7 src/admin/web/docker.js
|
||||
d4aef376cb44b2a13174dca1cb9383f64c04b0b93279f4f0b4805e76002548a1 src/admin/web/docs.css
|
||||
aeffd403fa1823df7d9fa4c48280b4033b2d15e4cb9f66608caff89290de0a52 src/admin/web/docs.html
|
||||
6adb291fcc98d516fd9745d9673db16e408854a32cbf41335ae956bda11d46de src/admin/web/docs.js
|
||||
c52c430b9a6e584012e6014709e1e16ae7e775b2a5aedff8eb767fa867c1cbdd src/admin/web/drafts.js
|
||||
007c24189ad7b497d4da024fc52e6c51393f91ee135f525a356e1f14882b7632 src/admin/web/error_400.html
|
||||
232b766f278ae4424705af351910677fcd7ac9a20bb8320d9a12b763c91219a2 src/admin/web/error_401.html
|
||||
16293363c7848471d866092fec5e83023df087aa824989e87450cee7b7ca1263 src/admin/web/error_403.html
|
||||
f7d0955ab18c15e04369204e789f341d2e832f927c1ff20a3b2b9e35e7898f00 src/admin/web/error_404.html
|
||||
64126ffcb39dd9bac7772fe737267f8d9391ee6b750d8b84f024f87a6123db3a src/admin/web/error_408.html
|
||||
4ba01f7a53e9ca914e7b2100c1d439bc56e46be698a9c020dae20159759120ac src/admin/web/error_410.html
|
||||
f2cb26eec86b6c03151072f847153df8aea612d840ca8b76367f41b9605bce64 src/admin/web/error_413.html
|
||||
7dc3634e3d508176545936f77db9deb3dc51f0abb71985e1950aa91b81b6f44f src/admin/web/error_429.html
|
||||
6fdb616c086b2769e3b484133415a6f8e9274a74d03ad95657eec480b3a28b9f src/admin/web/error_500.html
|
||||
ff72fc4c6a11907a16234c4b3c7d7f7bcf82eb72868dc3fbf654b04aff806fc8 src/admin/web/error_502.html
|
||||
386b9dc3aecebe7727f7cf7bbd2a02ab036a3abf01315975111a51559128df73 src/admin/web/error_503.html
|
||||
e6c4d91d1542d0a5b47bb8736f9539d43a6713df5a473b635519d5b7eac2eb9c src/admin/web/error_504.html
|
||||
0a5fff35555153f59d4346933030a67b6e1a8768cee7a47cfa2fcf1a2c564f63 src/admin/web/favicon.svg
|
||||
bc9e11724536de89bced1f0a752974068a0229ee14c3488592a27fc58261aaed src/admin/web/file-logs.js
|
||||
a065af0a331a953dc51c72f0f589023cf8a917cda5baa5308dd0d8c105ec3d91 src/admin/web/file-logs.js
|
||||
b14e7b3578322f309627f6fe965cc16abcf85da3437c9070be6caecae1908743 src/admin/web/host404.js
|
||||
4740b0fe963604528db10f0beffca5c43f52a8fab923c71f31c4cdfdea446e9a src/admin/web/index.html
|
||||
03ad9572bb14c34059636d612cf6b876d6361ce601817cff674c57b715dc65dd src/admin/web/integrations.js
|
||||
@@ -207,8 +202,9 @@ cbd41678eadd994dc5252628ee8ef9551fa9f7d8694ca5abd206240d9a96ad30 src/admin/web/
|
||||
136c093a907825e0d942c06186fffc834936b60dd6408bcafe81092cff7e3c64 src/admin/web/management.js
|
||||
90d9a0836662135b79fb6f183c4f3eff60f8a64392d6c5795ccf1d8ef6862263 src/admin/web/redirects.js
|
||||
2936ef4ea7d56edb878758ddcaeed7f55fcfe57044c82eacab7d7126c0ba1b44 src/admin/web/settings.js
|
||||
8eadbada619b15087783dcfa0e889a81f88e78034d084c5ab616f7912b835940 src/admin/web/status_200.html
|
||||
bb67e77af5514478485cfb69f339d63d7eb0cbadd093fa054814a416260ccc1d src/admin/web/streams.js
|
||||
303d3b4d426c521392981c872b3fb685843b8e965ee510ab7a5bab014b21ebc8 src/admin/web/style.css
|
||||
a616a18259ae03b9161d3e672f24023e6470ed207ac81b0734cff8060322b800 src/admin/web/style.css
|
||||
d290c048450655b3eeae7c6ec54a82d645d1965513694ab29fdf7eef796a1abd src/admin/web/traffic-panel.js
|
||||
a6e7bbff4fd2d50ecf759ed28161a672d7b483af2b35c9f912746a7a06a9f476 src/admin/web/transform-panel.js
|
||||
00f59a6f656e44c364ed3c8eed33ce1df878a488908b30a9523a87b64f50f9ef src/admin/web/vhosts/core.js
|
||||
@@ -236,9 +232,7 @@ a1e5d43f5417d71f3974812cb539c0932f206e2290090917f067494f3587fc72 src/balance/le
|
||||
41c3c020a60c372dbf965bd277c330dd9cf2ab079f6608b472adf72a656f4cb3 src/balance/pool_core.rs
|
||||
477132dd381e23f1be197c32ff58a0925f41d3e6aa63fe77c10973cefb2b76f6 src/balance/pool_selection.rs
|
||||
519cb798040ac9a65d9ebe43ae11ef729bd78c5c4a5c756490458add4b6fdc24 src/balance/pool_view.rs
|
||||
7d94d1bd4eb698e0588b822d93e56e3dc5594c46edac4aa9713f148893ebe6d5 src/balance/tests.rs
|
||||
81141aedaec495183b88a4f6362a71f56ee14cbfa83d71fce51d8abed8df3f5d src/balance/tests/part_01.rs
|
||||
490b38a8b1472dbd9fcb823ec0ed0e92498fb9528b982bcee340c8579e89ae36 src/balance/tests/part_02.rs
|
||||
656a6ab3c9855f92b7e3e0dd6de1bcc408da003db8bcfcda52b71c5234c56693 src/balance/tests.rs
|
||||
5049d2bd01a9c866f3bad2288d8d594df96bba6045dfe15cd22b4173562b7388 src/basic_auth.rs
|
||||
9a8858b7ff461a4d961ba701ca3ed6eaec15c3f7f05eef8e0e30edc63617605a src/basic_auth/tests.rs
|
||||
7def03ace07dc38a4806277d75b6ce673d33ddaaf1c4855fa0a3372be1cf9489 src/cache/cold.rs
|
||||
@@ -248,7 +242,7 @@ e022003be144598100389cf38d76f509b5dbcc035d5bae556bb9b94cc8bd30ba src/cache/cold
|
||||
ea319b6e200b0c6ca93da5e2af7a3f86e3667cef918716f54f82b8012111b9bd src/cache/cold/worker.rs
|
||||
d51fa4f320be56c572f767617428c3f3838543cb0eeb7d1f8ae791e1a1ce9a71 src/cache/collapse.rs
|
||||
27a35689fef639f1aea00d9f5a86adc981c0c304dc3e28e6da40494dffbb6bfa src/cache/dashboard.rs
|
||||
bbd6de8568fea79cc4a11d33b905af75713e609c8f37ec943c82f0d180e66dcc src/cache/fill.rs
|
||||
780c2654c67dbe176d8310678e6deec8022bcebfd85b95df7262069ed41f57b1 src/cache/fill.rs
|
||||
675d18afb53f25c56cba49bbb431fa39ecd9d7bdd192c7b39b0698225de7a2a2 src/cache/invalidation.rs
|
||||
c1357a13658e1ed5158084d3a41ba9ea906512774a5863ce9c56cb508d3fa6c8 src/cache/invalidation/ban.rs
|
||||
0cc31d8e70fb3774c4ce3713b94ed79c184a8d8f0d38ce66a97724410733e17e src/cache/invalidation/invalidate.rs
|
||||
@@ -256,7 +250,7 @@ b74945c7460b76ed7afcb02a45ede09d76f08e0703c47920b9b9d794d4b74980 src/cache/life
|
||||
bd831807ca4720b3a41a6ea1d9a6c0ed2d935225e2dacbd9313be064f2e4a20e src/cache/lookup.rs
|
||||
88f6210c03d7a8e7a35be1f123c9c15675af6e6a599c50db927d99866851b634 src/cache/lookup/hot.rs
|
||||
1779abcee77600c13050a2882f896a5b35625b1ae53737c2554179492ce0e40d src/cache/lookup/insert.rs
|
||||
f3e026a3c0e953dca3e5c9def43d864a7706afcd7cbeeaf0f21c059a335b30cc src/cache/lookup/tiered.rs
|
||||
55a39fbcb8c034a98eaad44d9efc092902cd591ef167b8d8e14a56fd1a9f1794 src/cache/lookup/tiered.rs
|
||||
58c03fde09e5eab40968679793b364f4f1cf5fd832bd79ebcdc92bb23871ad82 src/cache/mod.rs
|
||||
0c8b0d550e94d4474dcdf991aa0340228dc5f2f904f39350e6e666a16f16fe87 src/cache/model.rs
|
||||
ba94226addfd6cdab67d666dc15b8412c4d117111e44639e503e89a95175d0a2 src/cache/model/cache.rs
|
||||
@@ -271,20 +265,12 @@ e26dce02df9c043329ca164fed215d271781bce8fb03240f528331bf3df6b9d7 src/cache/poli
|
||||
d426d341a7678f8ec70406d86052c97d0f49804bbcd8402951103d61a735b9da src/cache/policy/response.rs
|
||||
7e2bcfbb2b57732cdc704d9df7a9298fb80e735e37984431bc936630e41e7f8a src/cache/range.rs
|
||||
7ad96b3baae36ec49c90d03b380e749944fb3d54abb045c90db3a35f6b89b1c1 src/cache/tests.rs
|
||||
13dab7b7a587cfc146826a92ab94ec9e04f7e500b7a84e678d77226829844923 src/cache/tests/cold_tier_tests.rs
|
||||
9cd6a8407888c8f9cbf7652774186d1ca588d2d78308750e8aa1316b4e3e5cb9 src/cache/tests/cold_tier_tests/part_01.rs
|
||||
f43aa6c231f2c85ee3390dddab74aab2eea2a3f874acb35f145c23866266cb52 src/cache/tests/cold_tier_tests/part_02.rs
|
||||
981c33d8321c0d5482a00dc3cf0911e95fa69a29c68a2f5b6cd4cff27cec1cbd src/cache/tests/cold_tier_tests.rs
|
||||
22cb9bf20a0d25bee30c1e2213dfe8278ad7d0d5d294275d47d4a6ff9ac646ab src/cache/tests/dashboard_tests.rs
|
||||
d7a02eb7bad37e6a5524600aabb5e60ccf50e26c7a487e2806891c9d8acd48f3 src/cache/tests/http_invalidation_tests.rs
|
||||
1db70ea5f3cc1f1543504a68498ba23cafce5f9bb48c4616a4bf70d9a536790e src/cache/tests/scope_tests.rs
|
||||
578b18cac918ba51fad775eca188f645b228500e59eb1790f783a0b8d0245b7b src/cache/tests/tests.rs
|
||||
c33013089bf9e75a07a5b43f1030acaba8aba4098796e393ed4bad784f4fff96 src/cache/tests/tests/part_01.rs
|
||||
2f579a67e73817907525651f32c65db9f4175d55c9ea34773e988a7764a7fc8b src/cache/tests/tests/part_02.rs
|
||||
f4c56db79cf939a58c25253ce9abb18faeb90fe1454bfafec7c1f2e3385f78ce src/cache/tests/tests/part_03.rs
|
||||
cdc41a7241739b89297494323f29e0230fa06abbd74e219fedfda11df1db4334 src/cache/tests/tests/part_04.rs
|
||||
541a62b749440aa1099cf0fc8a2bd31eac0c082ccef35df22c1e049e7928e216 src/cache/tests/v022_cache_tests.rs
|
||||
15e85357c3ddf4fc2283d621f5f4cd8ca309275e06677020299d4105f0475949 src/cache/tests/v022_cache_tests/part_01.rs
|
||||
f262c4e946421b06198ab73ff6e36e6826848f53ce8396eada666c64935c2e70 src/cache/tests/v022_cache_tests/part_02.rs
|
||||
9aa6bfdb88b5ea4ef0ecd72e8e3c6e82b5ab9b37051ad300b6a5dc12f8579ddd src/cache/tests/tests.rs
|
||||
444d9a364339248bdb86f2285de6d7e598b880502e25a70be0ee877ff3e52ea7 src/cache/tests/v022_cache_tests.rs
|
||||
a8a0ce446eb53ffdb8f8d97efa3e1344410dc3798b25f62b8817e8e160d28940 src/cache/tickets.rs
|
||||
28f904cf4827294f2f759e6d3624608ed90059b64e42864e55dc9213824eb655 src/compression.rs
|
||||
e2f58279499b7c3b47c65d3a7814bb5c7cd6506544d939875fbb0f91fa9aa59d src/compression/tests.rs
|
||||
@@ -295,10 +281,7 @@ a3d6a9d01dd9b3b602ea2f3dd5559503ea82129544c8944c26ad39445aaa3ad3 src/config/com
|
||||
d5b851aa5823af638fd2b2ba8ef4eeba12c55296c0a01f8ca7dad0674d124acc src/config/model.rs
|
||||
f66c2388f31b19142c12469df23d43451e0f7c48b158d4996920282c06d32076 src/config/routing.rs
|
||||
818545ad36af3ae8d56976f318349dd1f5264ef5665fbf94681a81b79003f8cb src/config/snapshot.rs
|
||||
b9f56cc156deaf1a59daf4da96233bba12a31a4fde3f6fe6cc08909fb6c1823e src/config/tests.rs
|
||||
f712db55af9ac463bc905558d29d068a58ac75269075055724ad187d7ea06fa9 src/config/tests/part_01.rs
|
||||
bb754925f525374eb968a6c326672f6d565f7346a055b4cd8e7f0d546666648d src/config/tests/part_02.rs
|
||||
bc5816805b6e70244c339b4e680b447b3d00d545b59923e242629dc3805907e0 src/config/tests/part_03.rs
|
||||
b20dea6e67919379ac56f64c852e05082974300c183e7b5884e70db68f89ce5f src/config/tests.rs
|
||||
c6db84438878accb8a0c96be13fa01a83da824af39c929dc3f6df498f393b7e0 src/db.rs
|
||||
7fadb86f6a61dd0127dd68c115be36865f0b2af535510f4c8f9aff6c9b8cdbfe src/db/certificate_api.rs
|
||||
2ff744223934685439374572b83fd639bb3eabdbac168bea4a6501a6e9ce0ee0 src/db/history.rs
|
||||
@@ -312,10 +295,10 @@ b45d6040810c93ae2acb04ef6b0a59c189751edf9008cd81a4521c9e04e7f07d src/docker.rs
|
||||
a12c47df4b90b6fc36c978a42477e6ddffbbbf69aed7252101c2fd829709bd6e src/docker/tests.rs
|
||||
2a02ea2e010cde1b1fe99b2e6baa80ea344ea6b332dbb3c4be795b71541f64a7 src/forwarding.rs
|
||||
057e652f216a1478638cbb53b1c64ac88c7f413a65825caacbf893c5af004a63 src/forwarding/tests.rs
|
||||
6823040f72c8de387050a17cdca1acbba39b27858b150c1df71644ec11db3070 src/geoip.rs
|
||||
af7a4e91c32ffbe4701c8c06e48ba3f8bbbe55e8b9e877aecb30ccdf858e9e8f src/geoip.rs
|
||||
80850f7acd1ed4c0048b065e9e81740869a8efbb0d9b5d2e83d8ec7fbc494703 src/geoip/mmdb.rs
|
||||
06aa056d789742cc11c8826c338d985e6a6fb22da6c3530937eec38e35478034 src/geoip/mmdb_tests.rs
|
||||
3e673d15ee8640b272026a0618323f666dc79e1ca7fb15552928e913c8b69ec5 src/geoip/tests.rs
|
||||
d3dc58628683472ef4d205fa5c42f0b7ef83cf90846146d96941cd3b36a40766 src/geoip/tests.rs
|
||||
70091bdf6f38f03b6df8c8c4407c300677e57f293130122503f936500963d37c src/geoip/update.rs
|
||||
c1b49bef28d87712e4c46138e70b52c4bcfb1a00196579031fac043d0b4aaa7b src/geoip/update_http.rs
|
||||
9a912dad0b8d1e10410fd95d0da3684656092ef85cf8b23eda4ffe7fb92921d8 src/geoip/update_tests.rs
|
||||
@@ -347,8 +330,8 @@ e5c2e9189777725f4afa1c3c052e4f75047ec3f2f486630d990f5d3c194bcc96 src/main/tasks
|
||||
a54b5b34ea65f29723b89b81bf7968a238e8247b9c9760f3430d3504cb1017c9 src/metrics/vhost/tests.rs
|
||||
a4702c95c7e5077c2ccff86e2e3216e1da6ff9ee4383ae199a0aff2f78db325f src/not_found.rs
|
||||
668840e7bd70590e2e13f59b6d5b9e43ca7c877f3d07c71907baea91b5bba6cb src/not_found/tests.rs
|
||||
202b4b5fe837f6a3f53f350d4c6368ef70ef7070b4094011d64b481717ecea3c src/pages.rs
|
||||
e70bb6aa6794d30e561ae19d7e2fce34d9adbcc7db05bbdf6f3cf8751e3d2509 src/pages/tests.rs
|
||||
d66936cc15fd5d56566d40fa8cab93f80d94b05f10ac44478a30f63572a95e32 src/pages.rs
|
||||
da40e8ece87245efc49a0c4e750526b1d19fd97565856bd5caf641fcc1dfe18d src/pages/tests.rs
|
||||
0dba7b6a33866cc7bbe05509b01961e545892645d1dad4eb88ac8a4bec65e3bf src/prometheus.rs
|
||||
cc6fb14351b009a16d07ed0f412c2bcc68cd7e551421aea03d8d97ceec9bee40 src/prometheus/core.rs
|
||||
5ed3d03f9b805413544b932d0c266b0e32b830c783d01900af7988bca6bc48a3 src/prometheus/docker_misc.rs
|
||||
@@ -477,11 +460,7 @@ c5cb561b0f276e8082f904b9446fa5885f6b3c0608f25ea2ef425061e9f7f6b4 src/web_server
|
||||
9b58d6bb3ff1520060fc5aa86a3594f99192b1a8f8183255be1fb3ff35b4bb35 src/web_server/routing.rs
|
||||
400e1c8aa2fe857d96036a64499fbc3de44c1e3a53811c5b25101fb843359792 src/web_server/security.rs
|
||||
518856a221161430cb0ab3e411fa81cab07e1a6e52edd470f026d25c5307943e src/web_server/serve.rs
|
||||
63d71a336187779e9cbc1cc6afa065c5fa9472835a8c2cc15dbf4527279d0c90 src/web_server/tests.rs
|
||||
9426ad19fd6234e944d6e122a995d2cf8ebe4177d27e42fd157f64d05abd1e23 src/web_server/tests/part_01.rs
|
||||
5591a0b994d4392265799f881790d15abd8b6263b5475a84766455d66572bfcf src/web_server/tests/part_02.rs
|
||||
8f9a0e2b0a69a1f2b8cf7505a654606150da4a53bc8c09d1d6c4b8751185b134 src/web_server/tests/part_03.rs
|
||||
b25b6e0082b5153272119c7faba9d6f2ccdd288ce5c066526431c56954c66c4c src/web_server/tests/part_04.rs
|
||||
2283548f7a6a1f5e0e4025f498f14f3402b4f741ae4a9ac61c60e3debf5ca39a src/web_server/tests.rs
|
||||
f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json
|
||||
1725c10331745dc04a07675784d385260da7b14567b8dba3cbae5e891eb5dadf tests/http2.rs
|
||||
347a4ff9e028a1e999ad129590c355b5734cf928dd81e5099aea0c8aca8d5908 tests/http3.rs
|
||||
@@ -495,32 +474,35 @@ e5c58fc9b7685ec911bfa86f6ca4f0cc97c19a90c0cff5b60ea9887f1afe9ced tools/checks/a
|
||||
fd082812411cff7afb71b2289a6f1a469b8d3c8a7cfdcdd9e567a2a4baf81be4 tools/checks/admin_surface_audit_0310.py
|
||||
412255d383fcc8306c6086784fb416d9814e3ec790e440a0927431ca615f3411 tools/checks/backend_cache_0297_ui_test.mjs
|
||||
2a493dbce2991a23d807c01952f58608a364f541560836b4cb385e75e108e9e7 tools/checks/backend_health_0300_ui_test.mjs
|
||||
a1e0e4466d0d0acbdcc9db611a1a4183cdc148bae195dedabb18a2892f4faff5 tools/checks/check_source.py
|
||||
5afffaa2b7f96130565e11e1405d97787086438ec1a57647d8757fcf5cd1fa15 tools/checks/check_source.py
|
||||
dc4df51fc15d4b6345c6625890ddbe014103851ebfa16abc583fbb0d809f4015 tools/checks/dashboard_boot_0311_ui_test.mjs
|
||||
259d4b0985c82b631e151a07e8ef3b4c760019cb99bb2af458ce577497f7df58 tools/checks/dashboard_startup_0310_ui_test.mjs
|
||||
03d399d7f75b3f6aa8b6f68fddd0e791ab0071d657b9ba7255988a1d8a812836 tools/checks/dependency_upgrade_0312.py
|
||||
e7f4519116fcd5edfe3251a4ac10f6e3afcbabcc303f9613fddfb566f7f93e86 tools/checks/docs_check.py
|
||||
c75a529827c2bc3e39bfe0bd0e7e9f6e8a6f4f08982edadc05e1a10494f33ace tools/checks/dependency_upgrade_0312.py
|
||||
6c1ea212b9a60215739a5f2cbeb75487aaf51bbd90cc58714ce287ed5bc21267 tools/checks/docker_startup_0318_ui_test.mjs
|
||||
9037865daab1bf206ff0f06f6a98bb385691c1b7e87293b6d2f4bd4fc014454f tools/checks/docs_check.py
|
||||
6ca66c0998d1da852ad66976c91e4d7d3628195aa49ddb44cea95f531eb50033 tools/checks/file_logs_tabs_0315_ui_test.mjs
|
||||
83c74f0027a6d2f51f637d552593f9904e60ccd69665f6460136c48b159afb60 tools/checks/h3_cache_mobile_0315_test.mjs
|
||||
157c3d630fe0846dda55c57d9e0640cbff2831988ccee8757854774b05e8b8e7 tools/checks/h3_navigation_0316.py
|
||||
c3128f6b2e668c92b3026bddd4590d8df01f555f011d0dcfe19d46829b6e8753 tools/checks/h3_navigation_0316.py
|
||||
e33a9cc51d58bc92a637c0e6a1c88cbd24ba4605be55748212e296c3c35a4060 tools/checks/high_traffic_memory_model.py
|
||||
4f7821e7e25b09ce251d25a0b3fb4d8ce8396c36a5ed971c2637aeb325425909 tools/checks/langs_check.py
|
||||
f8f87ba891338b565249d02c3cf5f02cec44e606b4205de99fcbea1eb2c24d57 tools/checks/memory_contracts.py
|
||||
a8bda904a92f9436761db1460f14da3c0c9b3db9edeeee581497700242784d64 tools/checks/memory_contracts.py
|
||||
af98579ee942e774d88775c261ac351847c28bb4a5396dc67e458bd6b1ff7963 tools/checks/origin_resume_0313_ui_test.mjs
|
||||
1ac812929c2a8c0699ae3bdebd0d7e05b5bcaf86c87533bb669b5ba7955e3245 tools/checks/panel_cache_http_0310_ui_test.mjs
|
||||
0e8375cf190440091362c62a1e11865504b281fb03b8dce74e522f171c033033 tools/checks/preflight.py
|
||||
8c6bf085d39da0a27aa4ddab7322aa19e19966dd1e18732e04f54d676d6109e4 tools/checks/preflight.py
|
||||
021a97bd6763b59f3f7eca15f51efdf88d0c863f1f81a2e42ba80a9a09ca3fec tools/checks/refresh_0310_ui_test.mjs
|
||||
c11583dad0899f8b945cae6e2f6cb0bdb55f5526bd5f42c248ac41badec83679 tools/checks/release_0317.py
|
||||
b84b66a6f8f63cd0a354ef607b96ff60c77ef37f80f49ea9a9036b33147c0b6a tools/checks/release_0317_ui_test.mjs
|
||||
17b28a819bc8cc175fa1fb45b9aa14fa1dc8426b28f65969e7fb786dea8c2306 tools/checks/release_smoke.py
|
||||
f69845bc26ec33160fc756953991cf32aa8ad6a7fb4a131caa2093db91e783ef tools/checks/rust_module_contracts.py
|
||||
683e70df42c5c9577c0db5c7c4e94b25ee4654265525c3d4d9d1e471a914e5df tools/checks/release_0318.py
|
||||
1d2f2f3adaaa1f8576fcf97126335c4dcfbf6f4c30dede5f7e0c187241e9ea0e tools/checks/release_0318_ui_test.mjs
|
||||
5825b872b7000daae2c345bb95d755d3beaca5fc3dada07595cff73d447c7dc7 tools/checks/release_smoke.py
|
||||
31b31e976962e2c53e0f1f71bf67f7d2686d30cb26f638defa1bd7636cf1c1e8 tools/checks/rust_module_contracts.py
|
||||
ecfe0bb21a7549225a19b0846bb6de8c99cbfbbf5a706d33cf84db2c7146abed tools/checks/rust_source.py
|
||||
adf84a983e0dbba228e47d71ec2d5c9964f7ca9989d5139de58f96ee961b035a tools/checks/security_contracts.py
|
||||
23b9250245e6bec5ef728c73cee4c88f64924afc3597f8a9b58a1924235df98e tools/checks/security_dependency_contracts.py
|
||||
ff69e1381a759e07effdc25f8401ba92730be6116fe15c7470539e50ddf4f3e5 tools/checks/security_logs_0315_ui_test.mjs
|
||||
325ff424200a908395a2f77c87bda3cbc4ed57509c4927af590911b3ce639ac3 tools/checks/security_logs_0315_ui_test.mjs
|
||||
70e5632f36e2387be8638c33f371aacca57bf264ef67846a6e0bd7f2dd19a5e0 tools/checks/source_module_contracts.py
|
||||
37eebe90c0a60f7d7f4fbdd09986171b7dbd50c4d3760d94c31595dd93a2e680 tools/checks/source_pruning_0317.py
|
||||
e4b6f095ade55881d1d7fe0b8b821e9c5c7312e13ee062db44804dd2b6f727f9 tools/checks/system_info_0301_ui_test.mjs
|
||||
3e86bbe244fad73adc653918f2386815bdddbfc8084a5a19ea17f271681e92eb tools/checks/system_info_0301_ui_test.mjs
|
||||
543a488951ea6ec8fe06e04f168ccc4d4f2af3cfc281c9a728ab7bd87377fa98 tools/checks/system_info_no_subprocess_contracts.py
|
||||
a5105ab53de67e6b1114974c92b019014e1aa93acc445bc5dd6c82af0b0d3a16 tools/checks/version_contracts.py
|
||||
161637cb5e2e613e3c71cf8489cc936fa84913a2a02649463f8893a28a674242 tools/checks/vhost_origin_errors_0297_ui_test.mjs
|
||||
|
||||
@@ -92,6 +92,7 @@ if [[ "$mode" == "test" ]]; then
|
||||
python3 tools/checks/rust_module_contracts.py
|
||||
python3 tools/checks/h3_navigation_0316.py
|
||||
python3 tools/checks/release_0317.py
|
||||
python3 tools/checks/release_0318.py
|
||||
python3 tools/checks/source_pruning_0317.py
|
||||
python3 tools/checks/system_info_no_subprocess_contracts.py
|
||||
python3 tools/checks/source_module_contracts.py
|
||||
@@ -119,6 +120,8 @@ if [[ "$mode" == "test" ]]; then
|
||||
node tools/checks/admin_panel_config_0314_ui_test.mjs
|
||||
node tools/checks/security_logs_0315_ui_test.mjs
|
||||
node tools/checks/release_0317_ui_test.mjs
|
||||
node tools/checks/release_0318_ui_test.mjs
|
||||
node tools/checks/docker_startup_0318_ui_test.mjs
|
||||
node tools/checks/h3_cache_mobile_0315_test.mjs
|
||||
node tools/checks/panel_cache_http_0310_ui_test.mjs
|
||||
node tools/checks/vhost_origin_errors_0297_ui_test.mjs
|
||||
|
||||
@@ -41,9 +41,10 @@ committed document at startup; this is not one cross-memory/SQLite transaction.
|
||||
|
||||
### Rust source organization
|
||||
|
||||
Rust modules are kept as small responsibility-oriented files. Every checked-in `.rs` file
|
||||
is limited to **500 lines**; `tools/checks/rust_module_contracts.py` enforces the limit across
|
||||
the repository. Large logical modules use a thin facade (`config.rs`, `docker.rs`,
|
||||
Production Rust modules are kept as small responsibility-oriented files, with a
|
||||
**500-line limit** enforced by `tools/checks/rust_module_contracts.py`. Unit and
|
||||
integration test files are explicitly exempt: related tests may live together in
|
||||
large `tests.rs` files rather than arbitrary numbered fragments. Large logical modules use a thin facade (`config.rs`, `docker.rs`,
|
||||
`server.rs`, `runtime.rs`, `balance.rs`, `prometheus.rs`, and similar) plus files in a
|
||||
matching directory. `web_server` uses the standard `web_server/mod.rs` facade with
|
||||
submodules for configuration, filesystem confinement, responses, routing, FastCGI and
|
||||
|
||||
@@ -13,6 +13,8 @@ RustProxy 0.22.3 includes an optional standalone Docker discovery agent. The Rus
|
||||
|
||||
The agent returns only running containers with Docker-published TCP ports. Bindings limited to loopback (`127.0.0.0/8` or `::1`) are excluded. Multiple bindings for the same container port are retained instead of being collapsed to one address/port pair.
|
||||
|
||||
In the dashboard, an enabled Docker agent is **WAITING** until its first background observation arrives after RustProxy starts. This is not a connection failure. Opening **Docker vhosts** fetches the current *cached* status immediately, even if the per-page **Live view** option is off. While no enabled agent has been observed, the page briefly checks cached status every five seconds (when global automatic refresh is enabled), and stops once all agents have a first result. A genuine failed poll is reported with the returned error, and the configured TLS certificate pin is marked as verified only after a successful observation. **Refresh agents** explicitly triggers an immediate agent poll. No TLS pin, token or routing verification is bypassed.
|
||||
|
||||
## Install an agent on a Docker host
|
||||
|
||||
The agent is written in Go and uses only the standard library.
|
||||
|
||||
@@ -45,6 +45,12 @@ At least one column must remain visible. Stored-log column selection is saved pe
|
||||
|
||||
Selecting a row highlights it and expands parsed field/value pairs directly below the row. A nested **Raw JSON** section shows the normalized complete record.
|
||||
|
||||
## Sorting columns (0.31.8)
|
||||
|
||||
Click a Stored Logs column header (desktop/table layout) to sort by that column; click again to reverse direction. The selected field and direction are saved with the administrator's column preferences. Numeric fields sort numerically, text case-insensitively, and absent values come last. Both standard columns (`time`, `record`, `source`) and discovered nested JSON fields are supported.
|
||||
|
||||
Sorting takes place **on the server across the entire bounded matching scan, before page offset/limit is applied**. Thus page two continues the same sorted result instead of sorting only the current 100 displayed rows. Server bounds and truncation notices still apply; this is not an unbounded full-log historical index. The default remains newest first. The mobile card layout retains the selected sort order, set via the desktop table view.
|
||||
|
||||
## Narrow-screen navigation (0.31.5)
|
||||
|
||||
At viewport widths up to 700px, `/logs` uses a vertically scrolling list of
|
||||
|
||||
+16
-3
@@ -1,4 +1,17 @@
|
||||
# Verification report - RustProxy 0.31.7
|
||||
# Verification report - RustProxy 0.31.8
|
||||
|
||||
## 0.31.8 — implementation pending runtime acceptance
|
||||
|
||||
- **Test-source consolidation:** merge all 28 numbered `part_XX.rs` fragments across seven Rust test suites into existing `tests.rs` files, preserving test bodies and scopes. Update relative `login-keys.json` fixture path, compiler-independent source checks and archive smoke to exempt test-only Rust files from the 500-line production limit. Delete the seven now-empty fragment directories.
|
||||
- **PASS — retained tests:** all 28 fragments were concatenated in their original order into seven `tests.rs` suites. Exact body comparison (apart from the required `login-keys.json` relative path) confirms all 251 `#[test]`/`#[tokio::test]` markers are retained. All seven obsolete directories are absent; source include references resolve.
|
||||
- **PASS — Rust-independent 0.31.8 release suite:** `./dev.sh test` passed its pre-Cargo Python/SQL/schema/memory/source, JavaScript DOM/API simulation, documentation and translation checks, including Docker VHosts startup regression. The command exited **127** only at the missing Cargo guard. `GOPROXY=off go test ./...` passed in `docker-agent`.
|
||||
- **Unverified:** Rust/Cargo compilation, formatting and unit-test execution are not available in this environment; no claim is made about compiled behavior or live runtime acceptance.
|
||||
- **Docker startup-state follow-up:** fixed the client rendering where a missing first Docker observation incorrectly displayed a connection failure; now shows WAITING until a server-side poll result exists. A visit to Docker VHosts reads GET `/api/docker` regardless of per-page Live view; cached-status retries stop after the first result unless Live view is enabled. A TLS certificate pin is not marked connected without a successful poll; genuine poll errors are still visible. No Docker agent, TLS verification, proxy route, SQLite schema or poll interval changes.
|
||||
- **Docker follow-up verification:** `node tools/checks/docker_startup_0318_ui_test.mjs` passed with the real dashboard JS in a simulated DOM/API: pending/failure/success transitions, TLS pin indicator, per-page Live-off one-shot/initial status reads, re-entry and disabled-agent handling. No live Docker agent or Rust binary was exercised.
|
||||
- **Scope:** Stored Logs server-side sorting + UI, built-in status templates, GeoIP packed/boxed ranges, transformed response single-frame cache allocation sharing, and version metadata.
|
||||
- **Compatibility:** API sorting query parameters and administrator preference fields are optional and default to descending chronological order. No SQLite schema or cold cache format changes.
|
||||
- **PASS — source/static checks (2026-10-12):** `release_0318.py`, `release_0318_ui_test.mjs`, `check_source.py`, `version_contracts.py`, `web_server_contracts.py`, `admin_surface_audit_0310.py`, `security_dependency_contracts.py`, `docs_check.py`, `langs_check.py`, `dependency_upgrade_0312.py`, `memory_contracts.py` and available focused UI tests; Node syntax check, JSON schema parsing and Bash syntax checks. These are not Rust compiler or runtime tests.
|
||||
- **Unverified:** `cargo fmt`, `cargo check`, `cargo clippy`, `cargo test`, resolved lockfile verification, H1/H2/H3, browser behavior, and before/after RSS under matched traffic and HOT load on a Rust 1.99+ build host. The source-only checks cannot validate compilation or the zero-copy runtime optimization.
|
||||
|
||||
## 0.31.7 — pruned source package correction (2026-10-11)
|
||||
|
||||
@@ -82,7 +95,7 @@
|
||||
- **PASS — focused simulated regressions:** `admin_public_origin_0314_config_sim.mjs`: **61/61 local Node HTTP guard-simulation assertions**, comprising 37 cases and 24 repeated session GETs. Invalid forwarded-proto on 3/12 unpinned GETs produced 403; with a pinned HTTPS public URL and **no duplicate DNS allowlist entry**, the same sequence produced 0/12. This simulation is not compiled RustProxy and cannot prove actual mobile/proxy headers.
|
||||
- **PASS — real frontend helper/UI checks:** `admin_panel_config_0314_ui_test.mjs`: **9/9 settings-normalization scenarios** plus form/HTML/locale/OpenAPI consistency; `panel_cache_http_0310_ui_test.mjs` also executes the updated real administration form callback with a duplicated legacy hostname and verifies it is omitted on save.
|
||||
- **PASS — other standalone checks:** all 14 bundled Node UI/simulation scripts, memory model (300,000 simulated PURGEs), 2,000 HTTP requests to a **Python mock peer** (not RustProxy), and `cd docker-agent && go test ./...`.
|
||||
- **Harness and compiler limitations:** an initial `./dev.sh test` run exposed a legacy UI-test harness that lacked the new form helper; the harness was corrected and passed separately. A subsequent monolithic run exceeded the execution time limit after the memory/load-model stage, so no complete `./dev.sh test` success is claimed. `./dev.sh check` exited **127** because `cargo`/`rustc` are not installed. New Rust tests in `src/settings/tests.rs` and `src/admin/tests/part_11.rs` were **not compiled/executed**. The inherited Cargo.lock still needs resolution on Rust 1.99+; live mobile and real reverse-proxy acceptance remain OPEN.
|
||||
- **Harness and compiler limitations:** an initial `./dev.sh test` run exposed a legacy UI-test harness that lacked the new form helper; the harness was corrected and passed separately. A subsequent monolithic run exceeded the execution time limit after the memory/load-model stage, so no complete `./dev.sh test` success is claimed. `./dev.sh check` exited **127** because `cargo`/`rustc` are not installed. New Rust tests in `src/settings/tests.rs` and `src/admin/tests.rs` were **not compiled/executed**. The inherited Cargo.lock still needs resolution on Rust 1.99+; live mobile and real reverse-proxy acceptance remain OPEN.
|
||||
|
||||
### Earlier 0.31.4 validation baseline (2026-10-11)
|
||||
|
||||
@@ -92,7 +105,7 @@
|
||||
- **Node HTTP guard simulation (not compiled RustProxy): PASS, 53/53 assertions**, including 29 individual scenarios and 24 repeated session GETs: without the pin and with intentionally malformed XFP every fourth request, **3/12 GETs returned 403**; with an explicit pin and otherwise identical simulated headers, **0/12 returned 403**. `tools/checks/admin_public_origin_0314_sim.mjs` checks relevant code anchors and operates a local Node HTTP test peer. This does not confirm production headers or backend runtime behavior.
|
||||
- **PASS (2026-10-11):** `./dev.sh test` passed every available Rust-independent stage: source-module boundaries, version/contracts, SQLite schema-9 migrations and upgrade, security source/dependency checks, 300,000 PURGE-model iterations, 2,000 real HTTP requests to a **Python test peer**, all bundled Node/browser UI regressions (including BFCache/resume), 0.31.4 HTTP simulation, documentation and both language packs. Exit **127** at the missing Rust/Cargo toolchain guard only. The simulated peer is **not** a running RustProxy binary. `./dev.sh check` likewise exits 127 due to missing Cargo.
|
||||
- **PASS:** `(cd docker-agent && go test ./...)`, `node --check tools/checks/admin_public_origin_0314_sim.mjs`; source-package size/integrity checks are performed separately.
|
||||
- **Rust regression tests added, NOT EXECUTED:** `src/settings/tests.rs` and `src/admin/tests/part_11.rs` cover pin validation, restart, strict opt-out, session Secure cookie, CSRF, foreign Origin, cross-site, host spoofing and untrusted peers.
|
||||
- **Rust regression tests added, NOT EXECUTED:** `src/settings/tests.rs` and `src/admin/tests.rs` cover pin validation, restart, strict opt-out, session Secure cookie, CSRF, foreign Origin, cross-site, host spoofing and untrusted peers.
|
||||
- **Remaining release gate OPEN:** the sandbox has no Rust/Cargo/rustfmt and the inherited lockfile remains unresolved against 0.31.2+ dependencies. Before production use: resolve on Rust 1.99+, format, clippy, test/build, then capture actual mobile Safari/Chrome and reverse-proxy traffic and run the acceptance matrix in [SETTINGS.md](SETTINGS.md). No claim is made that the original field `auth.invalid_origin` was directly observed or eliminated.
|
||||
|
||||
## 0.31.3 - invalid-Origin resume and mobile panel recovery (2026-10-11)
|
||||
|
||||
+5
-2
@@ -6,6 +6,8 @@
|
||||
"direction": "ltr"
|
||||
},
|
||||
"translations": {
|
||||
"fileLogsSortAscending": "Sort ascending",
|
||||
"fileLogsSortDescending": "Sort descending",
|
||||
"policyRulesTitle": "Rules & headers",
|
||||
"vhUaRateTitle": "User-Agent rate limit overrides",
|
||||
"vhUaRatePattern": "User-Agent pattern",
|
||||
@@ -1387,7 +1389,7 @@
|
||||
"dockerError": "ERROR",
|
||||
"dockerWaiting": "WAITING",
|
||||
"dockerAgentDisabled": "This Docker agent is disabled.",
|
||||
"dockerNoObservation": "No observation yet. Refresh the agents.",
|
||||
"dockerNoObservation": "Waiting for the first agent check. RustProxy will update this after background discovery.",
|
||||
"dockerHost": "Docker host",
|
||||
"dockerDetectedIp": "Detected IP",
|
||||
"dockerEffectiveIp": "Routing IP",
|
||||
@@ -1413,7 +1415,8 @@
|
||||
"dockerTlsPlain": "PLAIN HTTP",
|
||||
"dockerTlsPlainHelp": "The agent token and inventory are not protected by TLS on this endpoint.",
|
||||
"dockerTlsPublicCaHelp": "Certificate chain and hostname are verified against the system/public CA roots.",
|
||||
"dockerAgentConnectionFailed": "Agent connection failed",
|
||||
"dockerAgentConnectionFailed": "Last Docker agent check failed",
|
||||
"dockerUnknownPollError": "The most recent agent check failed.",
|
||||
"dockerAgentVersion": "Agent version",
|
||||
"dockerLastChecked": "Last checked",
|
||||
"dockerNeverChecked": "never",
|
||||
|
||||
+5
-2
@@ -6,6 +6,8 @@
|
||||
"direction": "ltr"
|
||||
},
|
||||
"translations": {
|
||||
"fileLogsSortAscending": "Sortuj rosnąco",
|
||||
"fileLogsSortDescending": "Sortuj malejąco",
|
||||
"policyRulesTitle": "Reguły i nagłówki",
|
||||
"vhUaRateTitle": "Limity dla User-Agent",
|
||||
"vhUaRatePattern": "Wzorzec User-Agent",
|
||||
@@ -1387,7 +1389,7 @@
|
||||
"dockerError": "BŁĄD",
|
||||
"dockerWaiting": "OCZEKIWANIE",
|
||||
"dockerAgentDisabled": "Ten agent Dockera jest wyłączony.",
|
||||
"dockerNoObservation": "Brak odczytu. Odśwież agentów.",
|
||||
"dockerNoObservation": "Oczekiwanie na pierwszy odczyt agenta. RustProxy zaktualizuje status po sprawdzeniu w tle.",
|
||||
"dockerHost": "Host Dockera",
|
||||
"dockerDetectedIp": "Wykryte IP",
|
||||
"dockerEffectiveIp": "IP routingu",
|
||||
@@ -1413,7 +1415,8 @@
|
||||
"dockerTlsPlain": "ZWYKŁY HTTP",
|
||||
"dockerTlsPlainHelp": "Token agenta i dane inventory nie są chronione przez TLS na tym endpointcie.",
|
||||
"dockerTlsPublicCaHelp": "Łańcuch certyfikatu i nazwa hosta są weryfikowane względem publicznych CA.",
|
||||
"dockerAgentConnectionFailed": "Nie udało się połączyć z agentem",
|
||||
"dockerAgentConnectionFailed": "Ostatni odczyt agenta nie powiódł się",
|
||||
"dockerUnknownPollError": "Ostatni odczyt agenta nie powiódł się.",
|
||||
"dockerAgentVersion": "Wersja agenta",
|
||||
"dockerLastChecked": "Ostatnie sprawdzenie",
|
||||
"dockerNeverChecked": "nigdy",
|
||||
|
||||
@@ -8,8 +8,8 @@ if ! command -v cargo >/dev/null 2>&1 || ! command -v rustc >/dev/null 2>&1; the
|
||||
exit 127
|
||||
fi
|
||||
python3 tools/checks/preflight.py
|
||||
if [[ "$(python3 scripts/version.py)" != '0.31.7' ]]; then
|
||||
printf 'Expected RustProxy 0.31.7 sources.\n' >&2
|
||||
if [[ "$(python3 scripts/version.py)" != '0.31.8' ]]; then
|
||||
printf 'Expected RustProxy 0.31.8 sources.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'Toolchain: '; rustc --version
|
||||
|
||||
@@ -35,6 +35,8 @@ struct FileLogQuery {
|
||||
aggregate_field: Option<String>,
|
||||
aggregate_limit: Option<usize>,
|
||||
aggregate_min_count: Option<usize>,
|
||||
sort_by: Option<String>,
|
||||
sort_descending: Option<bool>,
|
||||
offset: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
@@ -50,6 +50,91 @@ fn record_matches(value: &Value, query: &FileLogQuery, search: &[FileLogSearchCl
|
||||
true
|
||||
}
|
||||
|
||||
// Order the entire bounded matching scan before applying offset/limit. Sorting
|
||||
// only the current browser page would give misleading navigation results.
|
||||
#[derive(Debug)]
|
||||
enum FileLogSortKey {
|
||||
Number(serde_json::Number),
|
||||
Text(String),
|
||||
}
|
||||
|
||||
fn stored_log_summary(value: &Value) -> String {
|
||||
let field = |name| value.get(name).and_then(Value::as_str).filter(|text| !text.is_empty());
|
||||
if let Some(reason) = field("reason").or_else(|| field("protocol_error")) {
|
||||
return reason.to_owned();
|
||||
}
|
||||
if field("method").is_some() || field("path").is_some() {
|
||||
return format!("{} {}{}", field("method").unwrap_or(""),
|
||||
field("host").or_else(|| field("authority")).unwrap_or(""),
|
||||
field("path").unwrap_or("")).trim().to_owned();
|
||||
}
|
||||
for key in ["error", "error_detail", "message", "raw", "outcome"] {
|
||||
if let Some(text) = field(key) { return text.to_owned(); }
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
fn stored_log_sort_key(record: &Value, field: &str) -> Option<FileLogSortKey> {
|
||||
if field == "record" {
|
||||
return Some(FileLogSortKey::Text(stored_log_summary(record).to_lowercase()));
|
||||
}
|
||||
// Prefer millisecond timestamps for numeric chronological sorting. RAW
|
||||
// file timestamps use the existing ISO/text fallback.
|
||||
let value = if field == "time" {
|
||||
record.get("time_ms").filter(|value| value.is_number())
|
||||
.or_else(|| record.get("timestamp"))
|
||||
} else {
|
||||
file_log_field(record, field)
|
||||
}?;
|
||||
match value {
|
||||
Value::Null => None,
|
||||
Value::Number(number) => Some(FileLogSortKey::Number(number.clone())),
|
||||
Value::String(text) if !text.is_empty() => Some(FileLogSortKey::Text(text.to_lowercase())),
|
||||
Value::Bool(boolean) => Some(FileLogSortKey::Text(boolean.to_string())),
|
||||
Value::Array(_) | Value::Object(_) => Some(FileLogSortKey::Text(value.to_string().to_lowercase())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn file_log_timestamp_cmp_desc(left: &Value, right: &Value) -> std::cmp::Ordering {
|
||||
match (left.get("time_ms").and_then(Value::as_u64), right.get("time_ms").and_then(Value::as_u64)) {
|
||||
(Some(a), Some(b)) => b.cmp(&a),
|
||||
_ => right.get("timestamp").and_then(Value::as_str).unwrap_or("")
|
||||
.cmp(left.get("timestamp").and_then(Value::as_str).unwrap_or("")),
|
||||
}
|
||||
}
|
||||
|
||||
fn sort_file_log_records(records: &mut Vec<Value>, field: &str, descending: bool) {
|
||||
use std::cmp::Ordering;
|
||||
// Build one key per record; do not repeatedly allocate/parse strings in
|
||||
// the O(n log n) comparison loop. The scan itself is limited to 20k lines.
|
||||
let mut keyed = std::mem::take(records).into_iter().enumerate()
|
||||
.map(|(ordinal, record)| (stored_log_sort_key(&record, field), ordinal, record))
|
||||
.collect::<Vec<_>>();
|
||||
keyed.sort_by(|left, right| {
|
||||
let order = match (&left.0, &right.0) {
|
||||
(None, None) => Ordering::Equal,
|
||||
(None, Some(_)) => return Ordering::Greater, // missing values always last
|
||||
(Some(_), None) => return Ordering::Less,
|
||||
(Some(FileLogSortKey::Number(a)), Some(FileLogSortKey::Number(b))) =>
|
||||
match (a.as_i64(), b.as_i64()) {
|
||||
(Some(left), Some(right)) => left.cmp(&right),
|
||||
_ => match (a.as_u64(), b.as_u64()) {
|
||||
(Some(left), Some(right)) => left.cmp(&right),
|
||||
_ => a.as_f64().partial_cmp(&b.as_f64()).unwrap_or(Ordering::Equal),
|
||||
},
|
||||
},
|
||||
(Some(FileLogSortKey::Text(a)), Some(FileLogSortKey::Text(b))) => a.cmp(b),
|
||||
(Some(FileLogSortKey::Number(_)), Some(FileLogSortKey::Text(_))) => Ordering::Less,
|
||||
(Some(FileLogSortKey::Text(_)), Some(FileLogSortKey::Number(_))) => Ordering::Greater,
|
||||
};
|
||||
(if descending { order.reverse() } else { order })
|
||||
.then_with(|| file_log_timestamp_cmp_desc(&left.2, &right.2))
|
||||
.then_with(|| left.1.cmp(&right.1))
|
||||
});
|
||||
records.extend(keyed.into_iter().map(|(_, _, record)| record));
|
||||
}
|
||||
|
||||
fn validate_file_log_query(query: &FileLogQuery) -> Result<(), ApiError> {
|
||||
let text_ok = |value: &Option<String>, max: usize| {
|
||||
value
|
||||
@@ -61,6 +146,7 @@ fn validate_file_log_query(query: &FileLogQuery) -> Result<(), ApiError> {
|
||||
|| !text_ok(&query.client_ip, 128)
|
||||
|| !text_ok(&query.vhost, 256)
|
||||
|| query.aggregate_field.as_ref().is_some_and(|field| !valid_raw_field_key(field))
|
||||
|| query.sort_by.as_ref().is_some_and(|field| !valid_raw_field_key(field))
|
||||
|| query.aggregate_limit.is_some_and(|value| value == 0 || value > FILE_LOG_MAX_AGGREGATE_RESULTS)
|
||||
|| query.aggregate_min_count.is_some_and(|value| value < 2 || value > FILE_LOG_SCAN_LINES)
|
||||
|| query.offset.unwrap_or(0) > FILE_LOG_MAX_OFFSET
|
||||
@@ -171,17 +257,8 @@ async fn file_logs_query(
|
||||
Err(_) => truncated = true,
|
||||
}
|
||||
}
|
||||
records.sort_by(|a, b| {
|
||||
let numeric = |value: &Value| value.get("time_ms").and_then(Value::as_u64);
|
||||
match (numeric(a), numeric(b)) {
|
||||
(Some(left), Some(right)) => right.cmp(&left),
|
||||
_ => b
|
||||
.get("timestamp")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.cmp(a.get("timestamp").and_then(Value::as_str).unwrap_or("")),
|
||||
}
|
||||
});
|
||||
sort_file_log_records(&mut records, query.sort_by.as_deref().unwrap_or("time"),
|
||||
query.sort_descending.unwrap_or(true));
|
||||
let mut aggregation = aggregate_counts.into_iter()
|
||||
.filter(|(_, count)| *count >= aggregate_min_count)
|
||||
.map(|(value, count)| json!({"value": value, "count": count}))
|
||||
|
||||
+33
-1
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.31.7",
|
||||
"version": "0.31.8",
|
||||
"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": [
|
||||
@@ -3625,6 +3625,17 @@
|
||||
"default": 60
|
||||
}
|
||||
}
|
||||
},
|
||||
"file_log_sort": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"default": "time",
|
||||
"description": "Account-scoped Stored Logs sort column; a fixed or discovered parsed JSON field, including nested dotted fields."
|
||||
},
|
||||
"file_log_descending": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Account-scoped Stored Logs sort direction."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -11009,6 +11020,27 @@
|
||||
"maximum": 500,
|
||||
"default": 100
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sort_by",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Sort field applied across the entire bounded matching scan before pagination. Includes time, record summary, source and parsed nested field names.",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"maxLength": 128,
|
||||
"default": "time"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sort_descending",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"description": "Descending sort direction; null/missing field values are always last.",
|
||||
"schema": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
|
||||
@@ -30,6 +30,8 @@ fn default_admin_dashboard_widgets() -> Vec<String> {
|
||||
.map(|value| (*value).to_owned())
|
||||
.collect()
|
||||
}
|
||||
fn default_file_log_sort() -> String { "time".to_owned() }
|
||||
fn default_file_log_descending() -> bool { true }
|
||||
fn default_file_log_columns() -> Vec<String> {
|
||||
[
|
||||
"time",
|
||||
@@ -99,6 +101,10 @@ struct AdminPreferences {
|
||||
dashboard_hidden_widgets: Vec<String>,
|
||||
#[serde(default = "default_file_log_columns")]
|
||||
file_log_columns: Vec<String>,
|
||||
#[serde(default = "default_file_log_sort")]
|
||||
file_log_sort: String,
|
||||
#[serde(default = "default_file_log_descending")]
|
||||
file_log_descending: bool,
|
||||
#[serde(default = "default_backend_table_columns")]
|
||||
backend_table_columns: Vec<String>,
|
||||
#[serde(default = "default_backend_table_sort")]
|
||||
@@ -146,6 +152,8 @@ impl Default for AdminPreferences {
|
||||
dashboard_widgets: default_admin_dashboard_widgets(),
|
||||
dashboard_hidden_widgets: Vec::new(),
|
||||
file_log_columns: default_file_log_columns(),
|
||||
file_log_sort: default_file_log_sort(),
|
||||
file_log_descending: default_file_log_descending(),
|
||||
backend_table_columns: default_backend_table_columns(),
|
||||
backend_table_sort: default_backend_table_sort(),
|
||||
backend_table_descending: false,
|
||||
@@ -203,6 +211,12 @@ fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiE
|
||||
));
|
||||
}
|
||||
}
|
||||
if !valid_raw_field_key(&preferences.file_log_sort) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid stored-log sort column preference".into(),
|
||||
));
|
||||
}
|
||||
if !matches!(preferences.theme.as_str(), "dark" | "light") {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
|
||||
+2661
-11
File diff suppressed because it is too large.
Load diff
@@ -151,6 +151,7 @@ async function api(path, options = {}) {
|
||||
function setPanel(name, navigate = true) {
|
||||
if (name === "security") { setPanel("settings", navigate); window.rustProxySettings?.select("security"); return; }
|
||||
if (!Object.hasOwn(PANEL_PATHS, name)) name = "overview";
|
||||
const previousPanel = currentPanel;
|
||||
currentPanel = name;
|
||||
$("overview-refresh").hidden = name !== "overview";
|
||||
document.querySelectorAll(".nav").forEach(e => e.classList.toggle("active", e.dataset.panel === name));
|
||||
@@ -159,6 +160,9 @@ function setPanel(name, navigate = true) {
|
||||
if (navigate && typeof location !== "undefined" && ["http:", "https:"].includes(location.protocol) && location.pathname !== PANEL_PATHS[name]) history.pushState({ panel: name }, "", PANEL_PATHS[name]);
|
||||
if (name === "settings") window.rustProxySettings?.syncLocation?.();
|
||||
window.rustProxyRefresh?.setPanel(name);
|
||||
// Read the cached Docker status on each visit, independent of the Live-view toggle.
|
||||
// This is GET /api/docker, not the POST that triggers an agent poll.
|
||||
if (name === "docker-vhosts" && previousPanel !== name && authenticated && envelope) void window.rustProxyRefresh?.manual("docker");
|
||||
if (name === "tls") window.rustProxyAcme?.syncLocation?.();
|
||||
if (name === "filelogs") void window.rustProxyFileLogs?.refresh();
|
||||
if (name === "tls") void refreshCertificateInventory().then(() => { if (authenticated && currentPanel === "tls") renderCertificates(); });
|
||||
|
||||
+10
-4
@@ -10,6 +10,7 @@
|
||||
const HIDDEN_KEY = "rustproxy.docker.hidden-vhosts";
|
||||
const cfg = () => envelope?.config?.docker_agents || { agents: [] };
|
||||
const obs = id => observations.find(item => item.agent_id === id);
|
||||
const hasUnobservedAgent = () => (cfg().agents || []).some(agent => agent.enabled !== false && !obs(agent.id));
|
||||
const firstListener = () => envelope?.config?.listeners?.endpoints?.find(item => item.enabled !== false)?.id || "http";
|
||||
const defaultHealth = () => ({ mode: "tcp", path: "/", host: null, interval_ms: 5000, timeout_ms: 2000, slow_response_ms: 1500, fall: 3, rise: 2, expected_status_min: 200, expected_status_max: 399 });
|
||||
const defaultPassiveHealth = () => ({ enabled: false, count_connect_errors: true, count_timeouts: true, count_io_errors: true, count_http_5xx: true, count_slow_responses: false, fall: null, rise: null });
|
||||
@@ -115,7 +116,7 @@
|
||||
const https = String(agent?.endpoint || "").toLowerCase().startsWith("https://");
|
||||
const pinned = !!agent?.tls_fingerprint_sha256;
|
||||
if (!https) return { key: "dockerTlsPlain", cls: "warn", detail: t("dockerTlsPlainHelp") };
|
||||
if (pinned) return { key: "dockerTlsPinned", cls: "live", detail: `${t("dockerTlsFingerprint")}: ${prettyFingerprint(agent.tls_fingerprint_sha256)}` };
|
||||
if (pinned) return { key: "dockerTlsPinned", cls: state?.ok ? "live" : "", detail: `${t("dockerTlsFingerprint")}: ${prettyFingerprint(agent.tls_fingerprint_sha256)}` };
|
||||
if (state?.ok) return { key: "dockerTlsPublicCa", cls: "live", detail: t("dockerTlsPublicCaHelp") };
|
||||
return { key: "dockerTlsPublicCa", cls: "", detail: t("dockerTlsPublicCaHelp") };
|
||||
}
|
||||
@@ -326,7 +327,10 @@
|
||||
const state = obs(agent.id), box = el("article", undefined, "card docker-agent-card"); box.dataset.agentId = agent.id; box.dataset.searchText = `${agent.id} ${agent.name || ""} ${agent.base_domain || ""}`.toLowerCase(); const head = el("div", undefined, "card-heading"), left = el("div"); left.append(el("span", agent.id, "small-label"), el("h2", agent.name));
|
||||
const badges = el("div", undefined, "entity-actions"), connection = el("span", state?.ok ? t("dockerConnected") : state ? t("dockerError") : t("dockerWaiting"), `pill ${state?.ok ? "live" : ""}`), sec = securityInfo(agent, state); badges.append(el("span", t(sec.key), `pill ${sec.cls}`.trim()), connection); head.append(left, badges); box.append(head);
|
||||
if (!agent.enabled) { box.append(el("p", t("dockerAgentDisabled"), "fine")); target.append(box); continue; }
|
||||
if (!state?.ok) { const error = el("div", undefined, "docker-agent-error"); error.append(el("b", t("dockerAgentConnectionFailed")), el("p", state?.error || t("dockerNoObservation"), "fine"), el("small", sec.detail, "fine")); box.append(error); }
|
||||
// No observation is the normal state while the first background poll is in flight.
|
||||
// A configured TLS fingerprint alone is not proof that an agent was contacted.
|
||||
if (!state) box.append(el("p", t("dockerNoObservation"), "fine"));
|
||||
else if (!state.ok) { const error = el("div", undefined, "docker-agent-error"); error.append(el("b", t("dockerAgentConnectionFailed")), el("p", state.error || t("dockerUnknownPollError"), "fine")); box.append(error); }
|
||||
if (!state?.inventory) { const rows = el("div", undefined, "docker-service-list"); for (const item of agent.assignments || []) rows.append(assignmentRow(agent, null, null, item, true)); if (!rows.children.length) rows.append(el("p", t("dockerNoPublishedPorts"), "empty")); box.append(rows); target.append(box); continue; }
|
||||
const inv = state.inventory, defaultIp = agent.default_target_ip || agent.target_ip_override || t("dockerTargetAuto"), serviceCount = (inv.services || []).length, overview = el("div", undefined, "docker-agent-overview"); box.dataset.searchText += ` ${inv.hostname || ""}`.toLowerCase();
|
||||
overview.append(el("span", `${t("dockerHost")}: ${inv.hostname}`), el("span", `${t("dockerServices")}: ${serviceCount}`), el("span", `${t("dockerLastChecked")}: ${checkedAgo(state.checked_at_unix_ms)}`), el("span", `*.${agent.base_domain}`)); box.append(overview);
|
||||
@@ -406,8 +410,10 @@
|
||||
$("docker-agents-form")?.addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { if (await saveSection("docker-agents", collectSettings())) { setSettingsDirty("docker-agents-form", false); await refresh(true); } }); } catch (error) { toast(error.message, true); } });
|
||||
rustProxyRefresh.register("docker", {
|
||||
interval: () => 5,
|
||||
active: () => currentPanel === "docker-vhosts" && liveEnabled() && canLiveRender(),
|
||||
// Even with local Live view off, observe the first startup result.
|
||||
// Once every enabled agent has a first result, stop unless Live is enabled.
|
||||
active: () => currentPanel === "docker-vhosts" && canLiveRender() && (liveEnabled() || hasUnobservedAgent()),
|
||||
run: async context => refresh(false, context)
|
||||
});
|
||||
window.rustProxyDocker = { render() { syncLiveSwitch(); renderSettings(); renderDiscovery(); }, refresh, applyPreferences, reset() { observations = []; pools = []; poolRoutes = []; poolIndex = new Map(); showHidden = false; listPrefs = { sort: "service", descending: false }; hiddenVhosts = loadHidden(); if ($("docker-search")) $("docker-search").value = ""; if ($("docker-vhost-sort")) $("docker-vhost-sort").value = listPrefs.sort; syncSortDirection(); $("docker-discovery-list")?.replaceChildren(); $("docker-agent-settings-list")?.replaceChildren(); syncLiveSwitch(); applyViewFilters(); } };
|
||||
window.rustProxyDocker = { render() { syncLiveSwitch(); renderSettings(); renderDiscovery(); if (currentPanel === "docker-vhosts" && authenticated) { rustProxyRefresh.sync(); void rustProxyRefresh.manual("docker"); } }, refresh, applyPreferences, reset() { observations = []; pools = []; poolRoutes = []; poolIndex = new Map(); showHidden = false; listPrefs = { sort: "service", descending: false }; hiddenVhosts = loadHidden(); if ($("docker-search")) $("docker-search").value = ""; if ($("docker-vhost-sort")) $("docker-vhost-sort").value = listPrefs.sort; syncSortDirection(); $("docker-discovery-list")?.replaceChildren(); $("docker-agent-settings-list")?.replaceChildren(); syncLiveSwitch(); applyViewFilters(); } };
|
||||
})();
|
||||
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>400 - Bad request - RustProxy</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 12vh auto;
|
||||
padding: 24px;
|
||||
max-width: 620px;
|
||||
background: #131615;
|
||||
color: #e5e9e6;
|
||||
font: 15px/1.6 system-ui, sans-serif
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
margin: 0 0 8px
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #b9c3bc
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main>
|
||||
<h1>400 - Bad request</h1>
|
||||
<p>The request could not be processed because it is invalid.</p>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>408 - Request timeout - RustProxy</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 12vh auto;
|
||||
padding: 24px;
|
||||
max-width: 620px;
|
||||
background: #131615;
|
||||
color: #e5e9e6;
|
||||
font: 15px/1.6 system-ui, sans-serif
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
margin: 0 0 8px
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #b9c3bc
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main>
|
||||
<h1>408 - Request timeout</h1>
|
||||
<p>The request was not completed in time. Please try again.</p>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>410 - Gone - RustProxy</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 12vh auto;
|
||||
padding: 24px;
|
||||
max-width: 620px;
|
||||
background: #131615;
|
||||
color: #e5e9e6;
|
||||
font: 15px/1.6 system-ui, sans-serif
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
margin: 0 0 8px
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #b9c3bc
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main>
|
||||
<h1>410 - Gone</h1>
|
||||
<p>The requested resource is no longer available.</p>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>413 - Payload too large - RustProxy</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 12vh auto;
|
||||
padding: 24px;
|
||||
max-width: 620px;
|
||||
background: #131615;
|
||||
color: #e5e9e6;
|
||||
font: 15px/1.6 system-ui, sans-serif
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
margin: 0 0 8px
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #b9c3bc
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main>
|
||||
<h1>413 - Payload too large</h1>
|
||||
<p>The request body exceeds the allowed size.</p>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>500 - Internal server error - RustProxy</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 12vh auto;
|
||||
padding: 24px;
|
||||
max-width: 620px;
|
||||
background: #131615;
|
||||
color: #e5e9e6;
|
||||
font: 15px/1.6 system-ui, sans-serif
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
margin: 0 0 8px
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #b9c3bc
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main>
|
||||
<h1>500 - Internal server error</h1>
|
||||
<p>The server encountered an unexpected error.</p>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>502 - Bad gateway - RustProxy</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 12vh auto;
|
||||
padding: 24px;
|
||||
max-width: 620px;
|
||||
background: #131615;
|
||||
color: #e5e9e6;
|
||||
font: 15px/1.6 system-ui, sans-serif
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
margin: 0 0 8px
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #b9c3bc
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main>
|
||||
<h1>502 - Bad gateway</h1>
|
||||
<p>The upstream service returned an invalid response.</p>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -11,6 +11,7 @@
|
||||
]);
|
||||
let availableColumns = new Set(defaultColumnIds);
|
||||
let visibleColumns = new Set(defaultColumnIds);
|
||||
let sortBy = "time", sortDescending = true, pendingRefresh = false;
|
||||
|
||||
|
||||
const CONSOLIDATION_STORAGE_KEY = "rp-file-logs-consolidate-vhosts";
|
||||
@@ -146,8 +147,22 @@
|
||||
function syncHeaders() {
|
||||
const row = $("file-logs-table-head").querySelector("tr");
|
||||
const headers = orderedColumns().filter(id => visibleColumns.has(id)).map(id => {
|
||||
const header = el("th", columnLabel(id)); header.dataset.fileLogColumn = id;
|
||||
const key = specialLabels.get(id); if (key) header.dataset.i18n = key;
|
||||
const header = el("th"); header.dataset.fileLogColumn = id;
|
||||
header.setAttribute("aria-sort", sortBy === id ? (sortDescending ? "descending" : "ascending") : "none");
|
||||
const button = el("button", undefined, "file-log-sort-button"); button.type = "button";
|
||||
const caption = el("span", columnLabel(id));
|
||||
const key = specialLabels.get(id); if (key) caption.dataset.i18n = key;
|
||||
const arrow = el("span", sortBy === id ? (sortDescending ? "↓" : "↑") : "↕", "file-log-sort-indicator");
|
||||
arrow.setAttribute("aria-hidden", "true");
|
||||
const willDescend = sortBy === id ? !sortDescending : false;
|
||||
button.title = `${t(willDescend ? "fileLogsSortDescending" : "fileLogsSortAscending")}: ${columnLabel(id)}`;
|
||||
button.setAttribute("aria-label", button.title);
|
||||
button.addEventListener("click", () => {
|
||||
sortDescending = sortBy === id ? !sortDescending : false;
|
||||
sortBy = id; selectedIndex = null; offset = 0;
|
||||
syncHeaders(); void savePreferences(); void refresh(true);
|
||||
});
|
||||
button.append(caption, arrow); header.append(button);
|
||||
return header;
|
||||
});
|
||||
row.replaceChildren(...headers);
|
||||
@@ -172,6 +187,8 @@
|
||||
? preferences.file_log_columns.filter(id => typeof id === "string" && id.length <= 128)
|
||||
: [];
|
||||
visibleColumns = new Set(selected.length ? selected : defaultColumnIds);
|
||||
sortBy = typeof preferences?.file_log_sort === "string" && /^[a-z0-9_.-]{1,128}$/i.test(preferences.file_log_sort) ? preferences.file_log_sort : "time";
|
||||
sortDescending = preferences?.file_log_descending !== false;
|
||||
for (const id of visibleColumns) availableColumns.add(id);
|
||||
preferencesLoaded = true;
|
||||
syncColumnPicker(); syncHeaders(); renderRows();
|
||||
@@ -184,7 +201,7 @@
|
||||
do {
|
||||
preferenceSaveQueued = false;
|
||||
const selected = orderedColumns().filter(id => visibleColumns.has(id));
|
||||
const preferences = await window.rustProxyPreferences.update({ file_log_columns: selected });
|
||||
const preferences = await window.rustProxyPreferences.update({ file_log_columns: selected, file_log_sort: sortBy, file_log_descending: sortDescending });
|
||||
if (!preferenceSaveQueued) applyPreferences(preferences);
|
||||
} while (preferenceSaveQueued);
|
||||
} catch (error) { toast(error.message, true); }
|
||||
@@ -267,12 +284,14 @@
|
||||
params.set("aggregate_min_count", String(Math.max(2, Number($("file-log-aggregate-min")?.value) || 2)));
|
||||
params.set("aggregate_limit", String(Math.max(1, Number($("file-log-aggregate-limit")?.value) || 50)));
|
||||
}
|
||||
params.set("sort_by", sortBy); params.set("sort_descending", String(sortDescending));
|
||||
params.set("offset", String(offset)); params.set("limit", String(currentLimit()));
|
||||
return params.toString();
|
||||
}
|
||||
async function refresh(reset = false) {
|
||||
if (!authenticated || busy) return;
|
||||
if (!authenticated) return;
|
||||
if (reset) offset = 0;
|
||||
if (busy) { pendingRefresh = true; return; }
|
||||
busy = true; $("file-logs-refresh").disabled = true;
|
||||
try {
|
||||
if (!inventory) await loadInventory();
|
||||
@@ -289,10 +308,11 @@
|
||||
$("file-logs-prev").disabled = offset === 0;
|
||||
$("file-logs-next").disabled = offset + currentLimit() >= lastTotal;
|
||||
} catch (error) { toast(error.message, true); }
|
||||
finally { busy = false; $("file-logs-refresh").disabled = false; }
|
||||
finally { busy = false; $("file-logs-refresh").disabled = false; if (pendingRefresh) { pendingRefresh = false; void refresh(); } }
|
||||
}
|
||||
function reset() {
|
||||
inventory = null; offset = 0; lastTotal = 0; currentEntries = []; selectedIndex = null; aggregateField = "";
|
||||
sortBy = "time"; sortDescending = true; pendingRefresh = false;
|
||||
preferencesLoaded = false; preferenceSaveRunning = false; preferenceSaveQueued = false;
|
||||
availableColumns = new Set(defaultColumnIds); visibleColumns = new Set(defaultColumnIds);
|
||||
sourceSelect().replaceChildren(Object.assign(el("option", t("fileLogsAllSources")), { value: "all" }));
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>200 - OK - RustProxy</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 12vh auto;
|
||||
padding: 24px;
|
||||
max-width: 620px;
|
||||
background: #131615;
|
||||
color: #e5e9e6;
|
||||
font: 15px/1.6 system-ui, sans-serif
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 24px;
|
||||
margin: 0 0 8px
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: #b9c3bc
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main>
|
||||
<h1>200 - OK</h1>
|
||||
<p>The request completed successfully.</p>
|
||||
</main>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -8692,6 +8692,25 @@ button.config-history-day.is-selected {
|
||||
box-shadow: 0 1px 0 var(--line);
|
||||
}
|
||||
|
||||
/* Stored Logs sorting is performed by the bounded server scan before pagination. */
|
||||
.file-logs-table th .file-log-sort-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 2px;
|
||||
font: inherit;
|
||||
font-weight: inherit;
|
||||
color: inherit;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
.file-logs-table th .file-log-sort-button:hover { color: var(--accent); }
|
||||
.file-logs-table th[aria-sort="ascending"] .file-log-sort-button,
|
||||
.file-logs-table th[aria-sort="descending"] .file-log-sort-button { color: var(--accent); }
|
||||
.file-logs-table .file-log-sort-indicator { font-size: 11px; opacity: .8; }
|
||||
|
||||
.file-logs-table tbody tr:not(.file-log-detail-row) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
+641
-2
@@ -1,4 +1,643 @@
|
||||
mod tests {
|
||||
include!("tests/part_01.rs");
|
||||
include!("tests/part_02.rs");
|
||||
use super::*;
|
||||
fn settings(mode: HealthMode) -> HealthCheck {
|
||||
HealthCheck {
|
||||
mode,
|
||||
fall: 2,
|
||||
rise: 2,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
fn pool(algorithm: Algorithm, sticky: StickyMode) -> Arc<Pool> {
|
||||
let cfg = serde_json::from_value(serde_json::json!({
|
||||
"listeners":{"endpoints":[{"id":"http","binding":{"address":"127.0.0.1:8080"}}]},
|
||||
"vhosts":[{
|
||||
"id":"app","frontend":{"hosts":["app.localhost"],"listener_ids":["http"]},
|
||||
"backends":[{"id":"a","url":"http://127.0.0.1:9000","allow_private":true,"weight":3},
|
||||
{"id":"b","url":"http://127.0.0.1:9001","allow_private":true,"weight":1}],
|
||||
"load_balancing":{"algorithm":algorithm,"sticky":{"mode":sticky},"slow_start_seconds":0,"health_check":{"mode":"off"}}
|
||||
}]})).unwrap();
|
||||
let policy = crate::config::ValidationPolicy {
|
||||
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
|
||||
admin_addr: "127.0.0.1:9090".parse().unwrap(),
|
||||
tls_addr: None,
|
||||
cert_dir: None,
|
||||
data_dir: None,
|
||||
};
|
||||
crate::config::compile(cfg, 0, &policy).unwrap().pools[0].clone()
|
||||
}
|
||||
fn capacity_pool(limit: u32, queue_timeout_ms: u64) -> Arc<Pool> {
|
||||
let cfg = serde_json::from_value(serde_json::json!({
|
||||
"listeners":{"endpoints":[{"id":"http","binding":{"address":"127.0.0.1:8080"}}]},
|
||||
"vhosts":[{
|
||||
"id":"limited","frontend":{"hosts":["limited.localhost"],"listener_ids":["http"]},
|
||||
"backends":[{
|
||||
"id":"only","url":"http://127.0.0.1:9000","allow_private":true,
|
||||
"max_concurrent_requests":limit,"queue_timeout_ms":queue_timeout_ms
|
||||
}],
|
||||
"load_balancing":{"health_check":{"mode":"off"}}
|
||||
}]
|
||||
}))
|
||||
.unwrap();
|
||||
let policy = crate::config::ValidationPolicy {
|
||||
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
|
||||
admin_addr: "127.0.0.1:9090".parse().unwrap(),
|
||||
tls_addr: None,
|
||||
cert_dir: None,
|
||||
data_dir: None,
|
||||
};
|
||||
crate::config::compile(cfg, 0, &policy).unwrap().pools[0].clone()
|
||||
}
|
||||
fn select(pool: &Pool) -> Selection {
|
||||
pool.select("127.0.0.1".parse().unwrap(), &HeaderMap::new(), false)
|
||||
.unwrap()
|
||||
}
|
||||
#[test]
|
||||
fn per_backend_monitoring_can_be_disabled_or_overridden() {
|
||||
let mut defaults: crate::vhost::PoolMember = serde_json::from_value(serde_json::json!({
|
||||
"id": "error_404", "url": "http://127.0.0.1:8282", "pool_eligible": false
|
||||
})).unwrap();
|
||||
let mut settings = Settings::default();
|
||||
settings.passive_health.enabled = true;
|
||||
assert!(defaults.health_check_enabled);
|
||||
assert_eq!(settings.health_for(&defaults).mode, HealthMode::Tcp);
|
||||
assert!(settings.passive_for(&defaults).enabled);
|
||||
defaults.health_check_enabled = false;
|
||||
assert_eq!(settings.health_for(&defaults).mode, HealthMode::Off);
|
||||
assert!(!settings.passive_for(&defaults).enabled);
|
||||
assert!(!defaults.pool_eligible); // routing intent is independent
|
||||
defaults.health_check_enabled = true;
|
||||
defaults.health_check = Some(HealthCheck { mode: HealthMode::Http, path: "/healthz".into(), ..Default::default() });
|
||||
let effective = settings.health_for(&defaults);
|
||||
assert_eq!(effective.mode, HealthMode::Http);
|
||||
assert_eq!(effective.path, "/healthz");
|
||||
assert_eq!(effective.host, settings.health_check.host);
|
||||
let restored: crate::vhost::PoolMember = serde_json::from_str(&serde_json::to_string(&defaults).unwrap()).unwrap();
|
||||
assert_eq!(settings.health_for(&restored).path, "/healthz");
|
||||
}
|
||||
#[test]
|
||||
fn default_health_check_uses_tcp() {
|
||||
assert_eq!(HealthMode::default(), HealthMode::Tcp);
|
||||
assert_eq!(HealthCheck::default().mode, HealthMode::Tcp);
|
||||
}
|
||||
#[test]
|
||||
fn legacy_settings_without_passive_health_default_to_off() {
|
||||
let parsed: Settings = serde_json::from_value(serde_json::json!({
|
||||
"health_check": { "mode": "off" }
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(!parsed.passive_health.enabled);
|
||||
assert!(parsed.passive_health.count_connect_errors);
|
||||
assert!(parsed.passive_health.count_timeouts);
|
||||
assert!(parsed.passive_health.count_io_errors);
|
||||
assert!(parsed.passive_health.count_http_5xx);
|
||||
assert!(!parsed.passive_health.count_slow_responses);
|
||||
assert_eq!(parsed.retry.max_retries, 1);
|
||||
assert!(parsed.retry.retry_connect_errors);
|
||||
assert!(parsed.retry.retry_timeouts);
|
||||
assert_eq!(parsed.retry.retry_statuses, vec![502, 503, 504]);
|
||||
assert_eq!(parsed.keepalive.pool_recycle_requests, 25_000);
|
||||
assert_eq!(parsed.keepalive.pool_recycle_seconds, 3_600);
|
||||
assert_eq!(parsed.keepalive.http1_max_buffer_bytes, 131_072);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upstream_pool_recycle_limits_are_bounded_and_can_be_disabled() {
|
||||
let mut settings = Settings::default();
|
||||
assert!(settings.validate().is_ok());
|
||||
|
||||
settings.keepalive.pool_recycle_requests = 0;
|
||||
settings.keepalive.pool_recycle_seconds = 0;
|
||||
assert!(settings.validate().is_ok());
|
||||
|
||||
settings.keepalive.pool_recycle_requests = 999;
|
||||
assert!(settings.validate().is_err());
|
||||
settings.keepalive.pool_recycle_requests = 1_000;
|
||||
settings.keepalive.pool_recycle_seconds = 59;
|
||||
assert!(settings.validate().is_err());
|
||||
settings.keepalive.pool_recycle_seconds = 60;
|
||||
settings.keepalive.http1_max_buffer_bytes = 8_191;
|
||||
assert!(settings.validate().is_err());
|
||||
settings.keepalive.http1_max_buffer_bytes = 8_192;
|
||||
assert!(settings.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compatible_pool_rebuild_reuses_upstream_clients() {
|
||||
let previous = pool(Algorithm::LeastConnections, StickyMode::Off);
|
||||
let mut rebuilt = pool(Algorithm::LeastConnections, StickyMode::Off);
|
||||
let rebuilt_mut = Arc::get_mut(&mut rebuilt).expect("fresh pool must be uniquely owned");
|
||||
|
||||
assert!(!Arc::ptr_eq(
|
||||
&rebuilt_mut.members[0].backend,
|
||||
&previous.members[0].backend
|
||||
));
|
||||
rebuilt_mut.reuse_observations(&previous);
|
||||
assert!(Arc::ptr_eq(
|
||||
&rebuilt_mut.members[0].backend,
|
||||
&previous.members[0].backend
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_policy_rejects_unbounded_or_non_5xx_configuration() {
|
||||
let mut settings = Settings::default();
|
||||
settings.retry.max_retries = 4;
|
||||
assert!(settings.validate().is_err());
|
||||
settings.retry.max_retries = 1;
|
||||
settings.retry.retry_statuses = vec![429];
|
||||
assert!(settings.validate().is_err());
|
||||
}
|
||||
|
||||
#[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);
|
||||
lock(&state.traffic).at = std::time::Instant::now() - std::time::Duration::from_secs(2);
|
||||
state.sample_traffic();
|
||||
assert!(state.traffic_rate() > 0.0);
|
||||
lock(&state.traffic).at = std::time::Instant::now() - std::time::Duration::from_secs(2);
|
||||
state.sample_traffic();
|
||||
assert_eq!(state.traffic_rate(), 0.0);
|
||||
}
|
||||
#[test]
|
||||
fn active_failures_are_independent_from_passive_user_traffic() {
|
||||
let state = BackendState::new(HealthMode::Http);
|
||||
let settings = settings(HealthMode::Http);
|
||||
let passive = PassiveHealth::default();
|
||||
let fast = Duration::from_millis(5);
|
||||
assert!(!state.healthy());
|
||||
state.observe_active(&settings, "ok", fast);
|
||||
assert!(!state.healthy());
|
||||
state.observe_active(&settings, "ok", fast);
|
||||
assert!(state.healthy());
|
||||
state.observe_active(&settings, "status", fast);
|
||||
state.observe_passive(&settings, &passive, "ok", fast);
|
||||
state.observe_active(&settings, "status", fast);
|
||||
assert!(!state.healthy());
|
||||
state.observe_passive(&settings, &passive, "http_5xx", fast);
|
||||
assert!(!state.healthy());
|
||||
state.observe_active(&settings, "ok", fast);
|
||||
assert!(!state.healthy());
|
||||
state.observe_active(&settings, "ok", fast);
|
||||
assert!(state.healthy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passive_health_is_opt_in_and_uses_separate_thresholds() {
|
||||
let state = BackendState::new(HealthMode::Off);
|
||||
let active = settings(HealthMode::Off);
|
||||
let fast = Duration::from_millis(5);
|
||||
for _ in 0..10 {
|
||||
state.observe_passive(&active, &PassiveHealth::default(), "http_5xx", fast);
|
||||
}
|
||||
assert!(state.healthy());
|
||||
assert_eq!(state.http_5xx_total.load(Ordering::Relaxed), 10);
|
||||
assert_eq!(
|
||||
state.passive_health_failures_total.load(Ordering::Relaxed),
|
||||
0
|
||||
);
|
||||
|
||||
let passive = PassiveHealth {
|
||||
enabled: true,
|
||||
fall: Some(2),
|
||||
rise: Some(2),
|
||||
..Default::default()
|
||||
};
|
||||
state.observe_passive(&active, &passive, "http_5xx", fast);
|
||||
assert!(state.healthy());
|
||||
state.observe_passive(&active, &passive, "http_5xx", fast);
|
||||
assert!(!state.healthy());
|
||||
state.observe_passive(&active, &passive, "ok", fast);
|
||||
assert!(!state.healthy());
|
||||
state.observe_passive(&active, &passive, "ok", fast);
|
||||
assert!(state.healthy());
|
||||
}
|
||||
#[test]
|
||||
fn passive_http_5xx_can_be_explicitly_ignored_when_passive_health_is_on() {
|
||||
let state = BackendState::new(HealthMode::Off);
|
||||
let active = settings(HealthMode::Off);
|
||||
let passive = PassiveHealth {
|
||||
enabled: true,
|
||||
count_http_5xx: false,
|
||||
fall: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
for _ in 0..100 {
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passive_connect_errors_use_the_explicit_fall_threshold() {
|
||||
let state = BackendState::new(HealthMode::Off);
|
||||
let active = settings(HealthMode::Off);
|
||||
let passive = PassiveHealth {
|
||||
enabled: true,
|
||||
count_connect_errors: true,
|
||||
fall: Some(3),
|
||||
..Default::default()
|
||||
};
|
||||
for _ in 0..2 {
|
||||
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));
|
||||
assert!(!state.healthy());
|
||||
assert_eq!(state.connect_errors_total.load(Ordering::Relaxed), 3);
|
||||
assert_eq!(
|
||||
state.passive_health_failures_total.load(Ordering::Relaxed),
|
||||
3
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_health_can_stay_healthy_while_passive_health_is_off() {
|
||||
let state = BackendState::new(HealthMode::Tcp);
|
||||
let active = settings(HealthMode::Tcp);
|
||||
let passive = PassiveHealth::default();
|
||||
let fast = Duration::from_millis(5);
|
||||
state.observe_active(&active, "ok", fast);
|
||||
state.observe_active(&active, "ok", fast);
|
||||
assert!(state.healthy());
|
||||
for _ in 0..20 {
|
||||
state.observe_passive(&active, &passive, "http_5xx", fast);
|
||||
}
|
||||
assert!(state.healthy());
|
||||
assert_eq!(state.http_5xx_total.load(Ordering::Relaxed), 20);
|
||||
assert_eq!(
|
||||
state.passive_health_failures_total.load(Ordering::Relaxed),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unhealthy_source_hands_off_when_the_original_health_domain_recovers() {
|
||||
let state = BackendState::new(HealthMode::Tcp);
|
||||
let active = HealthCheck {
|
||||
fall: 1,
|
||||
rise: 1,
|
||||
..settings(HealthMode::Tcp)
|
||||
};
|
||||
let passive = PassiveHealth {
|
||||
enabled: true,
|
||||
count_http_5xx: true,
|
||||
fall: Some(1),
|
||||
rise: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
let fast = Duration::from_millis(5);
|
||||
|
||||
state.observe_active(&active, "ok", fast);
|
||||
state.observe_active(&active, "connect_error", fast);
|
||||
{
|
||||
let health = lock(&state.health);
|
||||
assert_eq!(health.unhealthy_source, "active_tcp");
|
||||
}
|
||||
state.observe_passive(&active, &passive, "http_5xx", fast);
|
||||
state.observe_active(&active, "ok", fast);
|
||||
{
|
||||
let health = lock(&state.health);
|
||||
assert!(!health.healthy);
|
||||
assert_eq!(health.unhealthy_source, "passive_http_5xx");
|
||||
assert_eq!(health.unhealthy_reason, "http_5xx");
|
||||
}
|
||||
state.observe_passive(&active, &passive, "ok", fast);
|
||||
assert!(state.healthy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_checks_never_quarantine_and_probes_are_single_flight() {
|
||||
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,
|
||||
);
|
||||
}
|
||||
assert!(off.healthy());
|
||||
assert!(!off.claim_probe(&settings));
|
||||
let on = BackendState::new(HealthMode::Http);
|
||||
let settings = super::tests::settings(HealthMode::Http);
|
||||
assert!(on.claim_probe(&settings));
|
||||
assert!(!on.claim_probe(&settings));
|
||||
on.observe_active(&settings, "ok", Duration::ZERO);
|
||||
assert!(!on.claim_probe(&settings));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn weighted_round_robin_distributes_three_to_one_without_body_collection() {
|
||||
let pool = pool(Algorithm::RoundRobin, StickyMode::Off);
|
||||
let mut counts = [0, 0];
|
||||
for _ in 0..40 {
|
||||
let selection = select(&pool);
|
||||
counts[usize::from(selection.lease.member.definition.id == "b")] += 1;
|
||||
}
|
||||
assert_eq!(counts, [30, 10]);
|
||||
assert_eq!(pool.members[0].state.active.load(Ordering::Relaxed), 0);
|
||||
assert_eq!(pool.members[0].state.requests.load(Ordering::Relaxed), 0); // Mere cache selection isn't origin traffic.
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn least_connections_counts_a_lease_until_its_last_owner_drops() {
|
||||
let pool = pool(Algorithm::LeastConnections, StickyMode::Off);
|
||||
let mut held = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let selection = select(&pool);
|
||||
assert_eq!(selection.lease.member.definition.id, "a");
|
||||
held.push(selection.lease);
|
||||
}
|
||||
let other = select(&pool);
|
||||
assert_eq!(other.lease.member.definition.id, "b");
|
||||
let clone = held[0].clone();
|
||||
held.clear();
|
||||
assert_eq!(pool.members[0].state.active.load(Ordering::Relaxed), 1);
|
||||
drop(clone);
|
||||
assert_eq!(pool.members[0].state.active.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn failover_selection_never_reuses_an_attempted_backend() {
|
||||
let pool = pool(Algorithm::LeastConnections, StickyMode::Off);
|
||||
let first = select(&pool);
|
||||
let first_id = first.lease.member.definition.id.clone();
|
||||
drop(first);
|
||||
let retry = pool
|
||||
.select_retry(
|
||||
"127.0.0.1".parse().unwrap(),
|
||||
&HeaderMap::new(),
|
||||
false,
|
||||
std::slice::from_ref(&first_id),
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(retry.lease.member.definition.id, first_id);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn backend_concurrency_limit_queues_then_rejects_and_recovers() {
|
||||
let pool = capacity_pool(1, 10);
|
||||
let first = select(&pool);
|
||||
let headers = HeaderMap::new();
|
||||
let saturated = pool
|
||||
.select_wait("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.await;
|
||||
assert!(matches!(saturated, Err(SelectionFailure::Capacity)));
|
||||
let view = pool.view();
|
||||
let member = &view["members"][0];
|
||||
assert_eq!(member["capacity_rejections_total"], 1);
|
||||
assert_eq!(member["queue_timeouts_total"], 1);
|
||||
assert_eq!(member["active"], 1);
|
||||
drop(first);
|
||||
assert!(pool
|
||||
.select_wait("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.await
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn zero_capacity_queue_timeout_rejects_without_counting_a_queue_timeout() {
|
||||
let pool = capacity_pool(1, 0);
|
||||
let first = select(&pool);
|
||||
let headers = HeaderMap::new();
|
||||
let saturated = pool
|
||||
.select_wait("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.await;
|
||||
assert!(matches!(saturated, Err(SelectionFailure::Capacity)));
|
||||
let view = pool.view();
|
||||
let member = &view["members"][0];
|
||||
assert_eq!(member["capacity_rejections_total"], 1);
|
||||
assert_eq!(member["queue_timeouts_total"], 0);
|
||||
drop(first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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),
|
||||
);
|
||||
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!(member["last_failure_at_ms"].as_u64().unwrap_or_default() > 0);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn traffic_and_adaptive_selection_use_observed_cost_and_weight() {
|
||||
for algorithm in [Algorithm::LeastTraffic, Algorithm::Adaptive] {
|
||||
let pool = pool(algorithm, StickyMode::Off);
|
||||
lock(&pool.members[0].state.traffic).rate = 50_000_000.0;
|
||||
lock(&pool.members[0].state.health).ewma_us = 1_000_000;
|
||||
assert_eq!(select(&pool).lease.member.definition.id, "b");
|
||||
}
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn ip_hash_is_stable_and_cannot_be_spoofed_with_forwarded_headers() {
|
||||
let pool = pool(Algorithm::IpHash, StickyMode::Off);
|
||||
let original = select(&pool).lease.member.definition.id.clone();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("x-forwarded-for", "10.0.0.99".parse().unwrap());
|
||||
for ip in ["127.0.0.1", "::ffff:127.0.0.1"] {
|
||||
for _ in 0..8 {
|
||||
assert_eq!(
|
||||
pool.select(ip.parse().unwrap(), &headers, false)
|
||||
.unwrap()
|
||||
.lease
|
||||
.member
|
||||
.definition
|
||||
.id,
|
||||
original
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn opaque_cookie_is_removed_from_origin_requests_and_protected_on_responses() {
|
||||
let pool = pool(Algorithm::RoundRobin, StickyMode::Cookie);
|
||||
let first = select(&pool);
|
||||
let cookie = first.cookie.unwrap().to_str().unwrap().to_owned();
|
||||
assert!(
|
||||
cookie.contains("HttpOnly")
|
||||
&& cookie.contains("SameSite=Lax")
|
||||
&& !cookie.contains("Secure")
|
||||
);
|
||||
let cookie = cookie.split(';').next().unwrap();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("cookie", format!("{cookie}; app=ok").parse().unwrap());
|
||||
let next = pool
|
||||
.select("10.0.0.1".parse().unwrap(), &headers, false)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
next.lease.member.definition.id,
|
||||
first.lease.member.definition.id
|
||||
);
|
||||
assert!(next.cookie.is_none());
|
||||
pool.strip_affinity(&mut headers);
|
||||
assert_eq!(headers["cookie"], "app=ok");
|
||||
headers.append(
|
||||
"set-cookie",
|
||||
format!("{}=origin-spoof", pool.cookie_name)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
headers.append("set-cookie", "app=ok".parse().unwrap());
|
||||
pool.strip_affinity_response(&mut headers);
|
||||
assert_eq!(headers.get_all("set-cookie").iter().count(), 1);
|
||||
assert_eq!(headers["set-cookie"], "app=ok");
|
||||
let secure = pool
|
||||
.select("127.0.0.1".parse().unwrap(), &HeaderMap::new(), true)
|
||||
.unwrap();
|
||||
assert!(secure
|
||||
.cookie
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.contains("; Secure"));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn failed_sticky_member_is_replaced_and_duplicate_cookies_are_ignored() {
|
||||
let pool = pool(Algorithm::RoundRobin, StickyMode::Cookie);
|
||||
let first = select(&pool);
|
||||
let cookie = first
|
||||
.cookie
|
||||
.unwrap()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_owned();
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert("cookie", format!("{cookie}; {cookie}").parse().unwrap());
|
||||
assert!(pool.affinity(&headers).is_none());
|
||||
headers.insert("cookie", cookie.parse().unwrap());
|
||||
lock(&first.lease.member.state.health).healthy = false;
|
||||
let next = pool
|
||||
.select("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.unwrap();
|
||||
assert_ne!(
|
||||
next.lease.member.definition.id,
|
||||
first.lease.member.definition.id
|
||||
);
|
||||
assert!(next.cookie.is_some());
|
||||
lock(&next.lease.member.state.health).healthy = false;
|
||||
assert!(pool
|
||||
.select("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.is_none());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn slow_start_ramps_weight_and_error_is_counted_once_per_exchange() {
|
||||
let pool = pool(Algorithm::LeastConnections, StickyMode::Off);
|
||||
let selected = select(&pool);
|
||||
let state = &selected.lease.member.state;
|
||||
assert!(state.weight(100, 100) < 100.0);
|
||||
assert_eq!(state.weight(100, 0), 100.0);
|
||||
selected.lease.started();
|
||||
selected.lease.bytes(50, true);
|
||||
selected.lease.bytes(100, false);
|
||||
selected.lease.error("timeout", Duration::from_secs(1));
|
||||
selected.lease.error("body", Duration::from_secs(2));
|
||||
selected.lease.retry_attempted();
|
||||
selected.lease.retry_succeeded();
|
||||
assert_eq!(state.requests.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(state.errors.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(state.timeouts_total.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(state.io_errors_total.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(state.bytes_in.load(Ordering::Relaxed), 50);
|
||||
assert_eq!(state.bytes_out.load(Ordering::Relaxed), 100);
|
||||
assert_eq!(state.retry_attempts_total.load(Ordering::Relaxed), 1);
|
||||
assert_eq!(state.retry_successes_total.load(Ordering::Relaxed), 1);
|
||||
assert!(pool
|
||||
.prometheus()
|
||||
.contains("rust_proxy_backend_requests_total{vhost=\"app\",backend=\"a\"} 1"));
|
||||
assert!(pool
|
||||
.prometheus()
|
||||
.contains("rust_proxy_backend_retry_attempts_total{vhost=\"app\",backend=\"a\"} 1"));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn slow_5xx_counts_both_observability_classes_when_passive_is_off() {
|
||||
let pool = pool(Algorithm::LeastConnections, StickyMode::Off);
|
||||
let selected = select(&pool);
|
||||
selected.lease.response(500, Duration::from_secs(20), true);
|
||||
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!(state.healthy());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn client_upload_duration_does_not_trigger_passive_slow_failure() {
|
||||
let pool = pool(Algorithm::LeastConnections, StickyMode::Off);
|
||||
let upload = select(&pool);
|
||||
upload.lease.response(200, Duration::from_secs(20), false);
|
||||
assert_eq!(upload.lease.member.state.errors.load(Ordering::Relaxed), 0);
|
||||
let read = select(&pool);
|
||||
read.lease.response(200, Duration::from_secs(20), true);
|
||||
assert_eq!(read.lease.member.state.errors.load(Ordering::Relaxed), 1);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn backup_is_only_new_assignment_when_no_primary_is_eligible() {
|
||||
let mut pool = pool(Algorithm::RoundRobin, StickyMode::Off);
|
||||
Arc::get_mut(&mut Arc::get_mut(&mut pool).unwrap().members[1])
|
||||
.unwrap()
|
||||
.definition
|
||||
.backup = true;
|
||||
for _ in 0..8 {
|
||||
assert_eq!(select(&pool).lease.member.definition.id, "a");
|
||||
}
|
||||
lock(&pool.members[0].state.health).healthy = false;
|
||||
assert_eq!(select(&pool).lease.member.definition.id, "b");
|
||||
lock(&pool.members[0].state.health).healthy = true;
|
||||
assert_eq!(select(&pool).lease.member.definition.id, "a");
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn drain_rejects_new_sessions_but_preserves_valid_existing_affinity() {
|
||||
let mut pool = pool(Algorithm::RoundRobin, StickyMode::Cookie);
|
||||
Arc::get_mut(&mut Arc::get_mut(&mut pool).unwrap().members[0])
|
||||
.unwrap()
|
||||
.definition
|
||||
.drain = true;
|
||||
assert_eq!(select(&pool).lease.member.definition.id, "b");
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"cookie",
|
||||
format!("{}={}", pool.cookie_name, pool.members[0].token)
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
let existing = pool
|
||||
.select("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.unwrap();
|
||||
assert_eq!(existing.lease.member.definition.id, "a");
|
||||
lock(&pool.members[0].state.health).healthy = false;
|
||||
assert_eq!(
|
||||
pool.select("127.0.0.1".parse().unwrap(), &headers, false)
|
||||
.unwrap()
|
||||
.lease
|
||||
.member
|
||||
.definition
|
||||
.id,
|
||||
"b"
|
||||
);
|
||||
}
|
||||
}
|
||||
Vendored
+55
-4
@@ -63,6 +63,10 @@ struct Candidate {
|
||||
plan: Plan,
|
||||
headers: HeaderMap,
|
||||
data: Vec<u8>,
|
||||
// A transformed response with one uniquely owned Bytes frame can share its
|
||||
// allocation with the downstream response, avoiding an extra Vec copy.
|
||||
shared: Option<Bytes>,
|
||||
shared_allocated: usize,
|
||||
fill_gauge: FillGauge,
|
||||
settings: Settings,
|
||||
skip_compression: bool,
|
||||
@@ -77,7 +81,7 @@ impl Candidate {
|
||||
if self
|
||||
.plan
|
||||
.expected_length
|
||||
.is_some_and(|length| self.data.len() != length)
|
||||
.is_some_and(|length| self.shared.as_ref().map_or(self.data.len(), Bytes::len) != length)
|
||||
{
|
||||
let reason = if self.transformed {
|
||||
"transform_length_mismatch"
|
||||
@@ -112,8 +116,12 @@ impl Candidate {
|
||||
// Keep the actual backing capacity as the HOT charge. Vec::reserve_exact
|
||||
// avoids geometric multi-MiB overshoot while Bytes::from(Vec) transfers
|
||||
// the allocation without copying.
|
||||
let body_allocation = self.data.capacity().max(self.data.len());
|
||||
let body = Bytes::from(std::mem::take(&mut self.data));
|
||||
let (body, body_allocation) = if let Some(body) = self.shared.take() {
|
||||
(body, self.shared_allocated)
|
||||
} else {
|
||||
let allocated = self.data.capacity().max(self.data.len());
|
||||
(Bytes::from(std::mem::take(&mut self.data)), allocated)
|
||||
};
|
||||
let scope = self.ticket.scope.clone();
|
||||
let scheme = self.ticket.scheme.clone();
|
||||
let authority = self.ticket.authority.clone();
|
||||
@@ -192,8 +200,51 @@ impl HttpBody for FillBody {
|
||||
let this = self.get_mut();
|
||||
match Pin::new(&mut this.inner).poll_frame(cx) {
|
||||
Poll::Ready(Some(Ok(frame))) => {
|
||||
let mut frame = frame;
|
||||
// Only the transformed single-frame case is eligible. Taking
|
||||
// unique ownership lets Bytes reveal its true allocation
|
||||
// capacity; we must never undercharge an arbitrary shared
|
||||
// Bytes slice that could retain a much larger parent buffer.
|
||||
if this.inner.is_end_stream() {
|
||||
if let Some(candidate) = &mut this.candidate {
|
||||
if let Some(data) = frame.data_ref() {
|
||||
let single_frame = candidate.transformed
|
||||
&& candidate.data.is_empty()
|
||||
&& candidate.shared.is_none()
|
||||
&& frame.data_ref().is_some_and(|data| {
|
||||
candidate.plan.expected_length == Some(data.len())
|
||||
&& data.len() <= candidate.settings.max_object_bytes
|
||||
});
|
||||
if single_frame {
|
||||
match frame.into_data() {
|
||||
Ok(bytes) => {
|
||||
match bytes.try_into_mut() {
|
||||
Ok(mutable) => {
|
||||
let allocation = mutable.capacity();
|
||||
let bytes = mutable.freeze();
|
||||
// A very oversized backing Vec is cheaper to
|
||||
// copy into a smaller HOT allocation instead.
|
||||
let acceptable_spare = bytes.len().saturating_div(8).min(64 * 1024);
|
||||
if allocation <= bytes.len().saturating_add(acceptable_spare)
|
||||
&& allocation <= candidate.settings.max_object_bytes
|
||||
&& candidate.fill_gauge.grow(allocation, &candidate.settings)
|
||||
{
|
||||
candidate.shared = Some(bytes.clone());
|
||||
candidate.shared_allocated = allocation;
|
||||
}
|
||||
frame = Frame::data(bytes);
|
||||
}
|
||||
Err(bytes) => frame = Frame::data(bytes),
|
||||
}
|
||||
}
|
||||
Err(original) => frame = original,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(candidate) = &mut this.candidate {
|
||||
if candidate.shared.is_some() {
|
||||
// The cache now owns the very same backing allocation.
|
||||
} else if let Some(data) = frame.data_ref() {
|
||||
let needed = candidate.data.len().saturating_add(data.len());
|
||||
if needed <= candidate.settings.max_object_bytes {
|
||||
let before = candidate.data.capacity();
|
||||
|
||||
Vendored
+7
-5
@@ -170,7 +170,10 @@ impl Cache {
|
||||
Some(validators) => validators.last_modified.clone(),
|
||||
None => headers.get("last-modified").cloned(),
|
||||
};
|
||||
let initial_capacity = fill_size.min(64 * 1024);
|
||||
let transformed = parts.extensions.get::<crate::transform::Applied>().is_some();
|
||||
// A transformed, fully buffered body normally arrives as one owned
|
||||
// frame. Do not allocate a second fill buffer before inspecting it.
|
||||
let initial_capacity = if transformed { 0 } else { fill_size.min(64 * 1024) };
|
||||
let Some(mut fill_gauge) = FillGauge::new(self.clone(), initial_capacity, settings) else {
|
||||
self.counters.rejected_fills.fetch_add(1, Ordering::Relaxed);
|
||||
self.record_reason(&ticket.scope, &ticket.domain, "hot_memory_pressure");
|
||||
@@ -191,15 +194,14 @@ impl Cache {
|
||||
plan,
|
||||
headers,
|
||||
data,
|
||||
shared: None,
|
||||
shared_allocated: 0,
|
||||
fill_gauge,
|
||||
settings: settings.clone(),
|
||||
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,
|
||||
_permit: permit,
|
||||
_fill_leader: fill_leader,
|
||||
};
|
||||
|
||||
Vendored
+744
-2
@@ -1,4 +1,746 @@
|
||||
mod cold_tier_tests {
|
||||
include!("cold_tier_tests/part_01.rs");
|
||||
include!("cold_tier_tests/part_02.rs");
|
||||
use super::*;
|
||||
use http_body_util::BodyExt;
|
||||
|
||||
fn cold_settings() -> Settings {
|
||||
Settings {
|
||||
enabled: true,
|
||||
max_entries: 1,
|
||||
max_bytes: 256 * 1024,
|
||||
max_object_bytes: 64 * 1024,
|
||||
cold_enabled: true,
|
||||
cold_path: "cache/cold-test".into(),
|
||||
cold_max_bytes: 2 * 1024 * 1024,
|
||||
cold_max_entries: 16,
|
||||
cold_after_seconds: 300,
|
||||
..Settings::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn fill(cache: &Arc<Cache>, settings: &Settings, path: &str, body: &'static str) -> Ticket {
|
||||
let ticket = cache
|
||||
.ticket_scoped(
|
||||
11,
|
||||
"cold-test",
|
||||
"http",
|
||||
"cold.example",
|
||||
"origin@127.0.0.1:8080",
|
||||
path,
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut response = Response::new(Body::from(body));
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static("public, max-age=120"),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("etag", HeaderValue::from_static("\"cold-v1\""));
|
||||
let plan = plan(StatusCode::OK, response.headers(), settings, Duration::ZERO).unwrap();
|
||||
cache
|
||||
.wrap(response, ticket.clone(), plan, settings)
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
ticket
|
||||
}
|
||||
|
||||
async fn wait_for_cold(cache: &Cache, expected: usize) {
|
||||
for _ in 0..100 {
|
||||
if cache.view().cold_entries >= expected {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("cold tier did not persist in time");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn selective_purge_removes_matching_cold_object() {
|
||||
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.clone(),
|
||||
shutdown.clone(),
|
||||
);
|
||||
let first = fill(&cache, &settings, "/one?v=1", "first-payload").await;
|
||||
let _second = fill(&cache, &settings, "/two", "second-payload").await;
|
||||
wait_for_cold(&cache, 1).await;
|
||||
assert_eq!(cache.view().cold_entries, 1);
|
||||
let result = cache
|
||||
.invalidate(InvalidationRequest {
|
||||
operation: InvalidationOperation::Purge,
|
||||
selector: InvalidationSelector::Exact,
|
||||
value: "/one?v=1".into(),
|
||||
host: Some("cold.example".into()),
|
||||
vhost: Some("cold-test".into()),
|
||||
ttl_seconds: None,
|
||||
confirm_wide: false,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(result.affected_entries, 1);
|
||||
assert_eq!(cache.view().cold_entries, 0);
|
||||
assert!(cache.get_tiered(&first, &settings).await.is_none());
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lru_demotion_persists_and_survives_restart() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = cold_settings();
|
||||
settings.validate().unwrap();
|
||||
let shutdown = CancellationToken::new();
|
||||
let cache = Cache::start(
|
||||
11,
|
||||
Some(dir.path().to_path_buf()),
|
||||
settings.clone(),
|
||||
shutdown.clone(),
|
||||
);
|
||||
let first = fill(&cache, &settings, "/one", "first-payload").await;
|
||||
let _second = fill(&cache, &settings, "/two", "second-payload").await;
|
||||
wait_for_cold(&cache, 1).await;
|
||||
assert_eq!(cache.view().entries, 1);
|
||||
assert_eq!(cache.view().cold_entries, 1);
|
||||
let cold = cache.dashboard(Some("cold-test"), 0, 50);
|
||||
assert!(cold
|
||||
.objects
|
||||
.iter()
|
||||
.any(|row| row.path == "/one" && row.tier == "cold"));
|
||||
|
||||
shutdown.cancel();
|
||||
drop(cache);
|
||||
|
||||
let restart_shutdown = CancellationToken::new();
|
||||
let restarted = Cache::start(
|
||||
11,
|
||||
Some(dir.path().to_path_buf()),
|
||||
settings.clone(),
|
||||
restart_shutdown.clone(),
|
||||
);
|
||||
assert_eq!(restarted.view().cold_entries, 1);
|
||||
let hit = restarted
|
||||
.get_tiered(&first, &settings)
|
||||
.await
|
||||
.expect("cold object should be readable after restart");
|
||||
let payload = hit.response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(payload, Bytes::from_static(b"first-payload"));
|
||||
assert_eq!(restarted.view().cold_hits, 1);
|
||||
assert_eq!(restarted.view().cold_promotions, 1);
|
||||
restart_shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cold_restart_preserves_transformed_validators_stale_metadata_and_no_transform() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut settings = cold_settings();
|
||||
settings.max_entries = 8;
|
||||
let shutdown = CancellationToken::new();
|
||||
let cache = Cache::start(
|
||||
11,
|
||||
Some(dir.path().to_path_buf()),
|
||||
settings.clone(),
|
||||
shutdown.clone(),
|
||||
);
|
||||
|
||||
let transformed_ticket = cache
|
||||
.ticket_scoped(
|
||||
11,
|
||||
"cold-test",
|
||||
"http",
|
||||
"cold.example",
|
||||
"origin@127.0.0.1:8080",
|
||||
"/transformed.json",
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut transformed = Response::new(Body::from(r#"{"a":1}"#));
|
||||
transformed.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static(
|
||||
"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("content-length", HeaderValue::from_static("7"));
|
||||
transformed
|
||||
.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")),
|
||||
});
|
||||
transformed
|
||||
.extensions_mut()
|
||||
.insert(crate::transform::Applied {
|
||||
kind: crate::transform::Kind::Json,
|
||||
original_bytes: 9,
|
||||
transformed_bytes: 7,
|
||||
});
|
||||
let transformed_plan = plan(
|
||||
StatusCode::OK,
|
||||
transformed.headers(),
|
||||
&settings,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
cache
|
||||
.wrap(
|
||||
transformed,
|
||||
transformed_ticket.clone(),
|
||||
transformed_plan,
|
||||
&settings,
|
||||
)
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let protected_ticket = cache
|
||||
.ticket_scoped(
|
||||
11,
|
||||
"cold-test",
|
||||
"http",
|
||||
"cold.example",
|
||||
"origin@127.0.0.1:8080",
|
||||
"/protected.css",
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let original_body = "body { color: red; }";
|
||||
let mut protected = Response::new(Body::from(original_body));
|
||||
protected.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static("public, max-age=120, immutable, no-transform"),
|
||||
);
|
||||
protected
|
||||
.headers_mut()
|
||||
.insert("etag", HeaderValue::from_static("\"origin-css\""));
|
||||
protected.extensions_mut().insert(crate::compression::Skip);
|
||||
let protected_plan = plan(
|
||||
StatusCode::OK,
|
||||
protected.headers(),
|
||||
&settings,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
cache
|
||||
.wrap(
|
||||
protected,
|
||||
protected_ticket.clone(),
|
||||
protected_plan,
|
||||
&settings,
|
||||
)
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(cache.flush_hot_to_cold().await, 2);
|
||||
wait_for_cold(&cache, 2).await;
|
||||
shutdown.cancel();
|
||||
drop(cache);
|
||||
|
||||
let restart_shutdown = CancellationToken::new();
|
||||
let restarted = Cache::start(
|
||||
11,
|
||||
Some(dir.path().to_path_buf()),
|
||||
settings.clone(),
|
||||
restart_shutdown.clone(),
|
||||
);
|
||||
assert_eq!(restarted.view().cold_entries, 2);
|
||||
// COLD entries intentionally keep validators and headers on disk to bound RAM.
|
||||
// Inspect them through the same verified metadata loader used on COLD hits.
|
||||
let (key, transformed) = {
|
||||
let cold = restarted.cold.lock().unwrap();
|
||||
cold.entries
|
||||
.iter()
|
||||
.find(|(_, entry)| entry.path == "/transformed.json")
|
||||
.map(|(key, entry)| (*key, entry.clone()))
|
||||
.expect("transformed cold entry")
|
||||
};
|
||||
assert_eq!(transformed.stale_while_revalidate, 30);
|
||||
assert_eq!(transformed.stale_if_error, 90);
|
||||
assert!(transformed.has_validator);
|
||||
assert!(transformed.transformed);
|
||||
let metadata = read_cold_metadata(&key, &transformed)
|
||||
.await
|
||||
.expect("persisted transformed metadata");
|
||||
assert_eq!(metadata.origin_etag.as_ref().unwrap(), "\"origin-v1\"");
|
||||
assert_eq!(
|
||||
metadata.origin_last_modified.as_ref().unwrap(),
|
||||
"Mon, 26 Jan 2026 23:11:40 GMT"
|
||||
);
|
||||
assert_eq!(metadata.headers["etag"], "\"rustproxy-representation\"");
|
||||
|
||||
let transformed_hit = restarted
|
||||
.get_tiered(&transformed_ticket, &settings)
|
||||
.await
|
||||
.expect("transformed object after restart");
|
||||
assert_eq!(
|
||||
transformed_hit.response.headers()["etag"],
|
||||
"\"rustproxy-representation\""
|
||||
);
|
||||
assert!(transformed_hit.transformed);
|
||||
assert!(transformed_hit
|
||||
.response
|
||||
.extensions()
|
||||
.get::<crate::transform::CachedTransformed>()
|
||||
.is_some());
|
||||
assert_eq!(
|
||||
transformed_hit
|
||||
.response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes(),
|
||||
Bytes::from_static(br#"{"a":1}"#)
|
||||
);
|
||||
|
||||
let protected_hit = restarted
|
||||
.get_tiered(&protected_ticket, &settings)
|
||||
.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!(protected_hit
|
||||
.response
|
||||
.extensions()
|
||||
.get::<crate::compression::Skip>()
|
||||
.is_some());
|
||||
assert_eq!(
|
||||
protected_hit
|
||||
.response
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap()
|
||||
.to_bytes(),
|
||||
Bytes::from_static(b"body { color: red; }")
|
||||
);
|
||||
restart_shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cold_policy_can_disable_lru_demotion() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut settings = cold_settings();
|
||||
settings.cold_on_lru = false;
|
||||
let shutdown = CancellationToken::new();
|
||||
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;
|
||||
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||
let view = cache.view();
|
||||
assert_eq!(view.entries, 1);
|
||||
assert_eq!(view.cold_entries, 0);
|
||||
assert_eq!(view.evictions, 1);
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cold_policy_can_disable_idle_demotion() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut settings = cold_settings();
|
||||
settings.max_entries = 8;
|
||||
settings.cold_on_idle = false;
|
||||
let shutdown = CancellationToken::new();
|
||||
let cache = Cache::start(
|
||||
11,
|
||||
Some(dir.path().to_path_buf()),
|
||||
settings.clone(),
|
||||
shutdown.clone(),
|
||||
);
|
||||
fill(&cache, &settings, "/idle", "idle-payload").await;
|
||||
{
|
||||
let mut store = cache.store.lock().unwrap();
|
||||
for entry in store.entries.values_mut() {
|
||||
entry.last_access = Instant::now() - Duration::from_secs(600);
|
||||
}
|
||||
}
|
||||
cache.demote_idle();
|
||||
assert_eq!(cache.view().entries, 1);
|
||||
assert_eq!(cache.view().cold_entries, 0);
|
||||
|
||||
settings.cold_on_idle = true;
|
||||
cache.configure(settings.clone());
|
||||
cache.demote_idle();
|
||||
wait_for_cold(&cache, 1).await;
|
||||
assert_eq!(cache.view().entries, 0);
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cold_policy_can_demote_by_total_hot_residency_age() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut settings = cold_settings();
|
||||
settings.max_entries = 8;
|
||||
settings.cold_on_idle = false;
|
||||
settings.cold_on_age = true;
|
||||
settings.cold_max_hot_seconds = 5;
|
||||
let shutdown = CancellationToken::new();
|
||||
let cache = Cache::start(
|
||||
11,
|
||||
Some(dir.path().to_path_buf()),
|
||||
settings.clone(),
|
||||
shutdown.clone(),
|
||||
);
|
||||
fill(&cache, &settings, "/aged", "aged-payload").await;
|
||||
{
|
||||
let mut store = cache.store.lock().unwrap();
|
||||
for entry in store.entries.values_mut() {
|
||||
entry.resident_since = Instant::now() - Duration::from_secs(60);
|
||||
entry.last_access = Instant::now();
|
||||
}
|
||||
}
|
||||
cache.demote_idle();
|
||||
wait_for_cold(&cache, 1).await;
|
||||
assert_eq!(cache.view().entries, 0);
|
||||
assert_eq!(cache.view().cold_entries, 1);
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn graceful_shutdown_flush_moves_hot_objects_and_releases_resident_bytes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut settings = cold_settings();
|
||||
settings.max_entries = 8;
|
||||
let shutdown = CancellationToken::new();
|
||||
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;
|
||||
assert_eq!(cache.view().entries, 2);
|
||||
assert!(cache.view().resident_bytes > 0);
|
||||
assert_eq!(cache.view().cold_entries, 0);
|
||||
|
||||
assert_eq!(cache.flush_hot_to_cold().await, 2);
|
||||
let view = cache.view();
|
||||
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.cold_entries, 2);
|
||||
shutdown.cancel();
|
||||
|
||||
let restart_shutdown = CancellationToken::new();
|
||||
let restarted = Cache::start(
|
||||
11,
|
||||
Some(dir.path().to_path_buf()),
|
||||
settings.clone(),
|
||||
restart_shutdown.clone(),
|
||||
);
|
||||
assert_eq!(restarted.view().cold_entries, 2);
|
||||
restart_shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managed_update_flush_ignores_shutdown_toggle_when_cold_tier_is_enabled() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut settings = cold_settings();
|
||||
settings.max_entries = 8;
|
||||
settings.cold_on_shutdown = false;
|
||||
let shutdown = CancellationToken::new();
|
||||
let cache = Cache::start(
|
||||
11,
|
||||
Some(dir.path().to_path_buf()),
|
||||
settings.clone(),
|
||||
shutdown.clone(),
|
||||
);
|
||||
fill(&cache, &settings, "/update", "update-payload").await;
|
||||
assert_eq!(cache.flush_hot_to_cold().await, 0);
|
||||
assert_eq!(cache.view().entries, 1);
|
||||
assert_eq!(cache.flush_hot_to_cold_for_update().await, 1);
|
||||
let view = cache.view();
|
||||
assert_eq!(view.entries, 0);
|
||||
assert_eq!(view.resident_bytes, 0);
|
||||
assert_eq!(view.cold_entries, 1);
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn graceful_shutdown_flush_can_be_disabled() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut settings = cold_settings();
|
||||
settings.max_entries = 8;
|
||||
settings.cold_on_shutdown = false;
|
||||
let shutdown = CancellationToken::new();
|
||||
let cache = Cache::start(
|
||||
11,
|
||||
Some(dir.path().to_path_buf()),
|
||||
settings.clone(),
|
||||
shutdown.clone(),
|
||||
);
|
||||
fill(&cache, &settings, "/one", "first-payload").await;
|
||||
assert_eq!(cache.flush_hot_to_cold().await, 0);
|
||||
assert_eq!(cache.view().entries, 1);
|
||||
assert_eq!(cache.view().cold_entries, 0);
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cold_cache_uses_two_hex_character_shards() {
|
||||
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.clone(),
|
||||
shutdown.clone(),
|
||||
);
|
||||
fill(&cache, &settings, "/one", "first-payload").await;
|
||||
fill(&cache, &settings, "/two", "second-payload").await;
|
||||
wait_for_cold(&cache, 1).await;
|
||||
let root = dir.path().join(&settings.cold_path);
|
||||
let mut shards = Vec::new();
|
||||
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_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| {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
name.ends_with(".json") || name.ends_with(".body")
|
||||
})
|
||||
}));
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_flat_cold_cache_is_not_migrated() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let settings = cold_settings();
|
||||
let root = dir.path().join(&settings.cold_path);
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
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(),
|
||||
);
|
||||
assert_eq!(cache.view().cold_entries, 0);
|
||||
assert!(!root.join("legacy.body").exists());
|
||||
assert!(!root.join("legacy.json").exists());
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_cold_cleanup_reports_removed_objects_and_freed_bytes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
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(),
|
||||
);
|
||||
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();
|
||||
for _ in 0..100 {
|
||||
let current = cache.cold_cleanup_job(job.id).unwrap();
|
||||
if current.status == "done" {
|
||||
assert_eq!(current.removed_entries, 1);
|
||||
assert!(current.freed_bytes > 0);
|
||||
assert_eq!(cache.view().cold_entries, 1);
|
||||
shutdown.cancel();
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("cold cleanup did not finish");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn manual_cold_cleanup_is_single_flight() {
|
||||
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 _held = cache.cleanup_slots.clone().try_acquire_owned().unwrap();
|
||||
let result = cache.start_cold_cleanup(ColdCleanupRequest {
|
||||
unused_for_seconds: None,
|
||||
oldest: Some(1),
|
||||
hits_zero: false,
|
||||
});
|
||||
assert!(result.is_err());
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cold_cleanup_skips_an_entry_while_a_read_is_pinned() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
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(),
|
||||
);
|
||||
fill(&cache, &settings, "/one", "first-payload").await;
|
||||
fill(&cache, &settings, "/two", "second-payload").await;
|
||||
wait_for_cold(&cache, 1).await;
|
||||
|
||||
let key = {
|
||||
let mut cold = cache.cold.lock().unwrap();
|
||||
let key = *cold.entries.keys().next().unwrap();
|
||||
cold.entries.get_mut(&key).unwrap().pinned_reads = 1;
|
||||
key
|
||||
};
|
||||
let request = ColdCleanupRequest {
|
||||
unused_for_seconds: None,
|
||||
oldest: Some(1),
|
||||
hits_zero: false,
|
||||
};
|
||||
let result = cache
|
||||
.remove_cold_candidates(Some(&request), None, None, 1)
|
||||
.await;
|
||||
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;
|
||||
let result = cache
|
||||
.remove_cold_candidates(Some(&request), None, None, 1)
|
||||
.await;
|
||||
assert_eq!(result.removed_entries, 1);
|
||||
assert!(!cache.cold.lock().unwrap().entries.contains_key(&key));
|
||||
shutdown.cancel();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_cold_read_guard_does_not_unpin_a_replacement_with_the_same_key() {
|
||||
let cache = Cache::new(1);
|
||||
let key = [7_u8; 32];
|
||||
let template = ColdEntry {
|
||||
base_key: key,
|
||||
vary: Vec::new(),
|
||||
revision: 1,
|
||||
scope: "app".to_owned(),
|
||||
scheme: "https".to_owned(),
|
||||
authority: "example.test".to_owned(),
|
||||
domain: "example.test".to_owned(),
|
||||
path: "/asset".to_owned(),
|
||||
target: "https://example.test/asset".to_owned(),
|
||||
tags: Vec::new(),
|
||||
created_at: SystemTime::now(),
|
||||
body_path: PathBuf::from("old.body"),
|
||||
meta_path: PathBuf::from("old.json"),
|
||||
body_bytes: 1,
|
||||
body_sha256: [0u8; 32],
|
||||
disk_bytes: 2,
|
||||
stored_at: SystemTime::now(),
|
||||
last_access: SystemTime::now(),
|
||||
age_at_store_seconds: 0,
|
||||
resident_age_at_store_seconds: 0,
|
||||
ttl: 60,
|
||||
stale_while_revalidate: 0,
|
||||
stale_if_error: 0,
|
||||
must_revalidate: false,
|
||||
proxy_revalidate: false,
|
||||
has_validator: false,
|
||||
hits: 0,
|
||||
transformed: false,
|
||||
sequence: 1,
|
||||
generation: 1,
|
||||
skip_compression: false,
|
||||
requires_revalidation: false,
|
||||
pinned_reads: 1,
|
||||
};
|
||||
cache
|
||||
.cold
|
||||
.lock()
|
||||
.unwrap()
|
||||
.entries
|
||||
.insert(key, template.clone());
|
||||
let old_guard = ColdReadGuard {
|
||||
cache: &cache,
|
||||
key,
|
||||
generation: 1,
|
||||
};
|
||||
|
||||
let mut replacement = template;
|
||||
replacement.sequence = 2;
|
||||
replacement.generation = 2;
|
||||
replacement.pinned_reads = 1;
|
||||
cache.cold.lock().unwrap().entries.insert(key, replacement);
|
||||
drop(old_guard);
|
||||
|
||||
let cold = cache.cold.lock().unwrap();
|
||||
let replacement = cold.entries.get(&key).unwrap();
|
||||
assert_eq!(replacement.generation, 2);
|
||||
assert_eq!(replacement.pinned_reads, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cold_path_cannot_escape_data_directory() {
|
||||
let mut settings = Settings::default();
|
||||
settings.cold_path = "../outside".into();
|
||||
assert!(settings.validate().is_err());
|
||||
settings.cold_path = "/tmp/outside".into();
|
||||
assert!(settings.validate().is_err());
|
||||
settings.cold_path = "cache/cold".into();
|
||||
assert!(settings.validate().is_ok());
|
||||
}
|
||||
}
|
||||
Vendored
+1480
-4
File diff suppressed because it is too large.
Load diff
Vendored
+948
-2
@@ -1,4 +1,950 @@
|
||||
mod v022_cache_tests {
|
||||
include!("v022_cache_tests/part_01.rs");
|
||||
include!("v022_cache_tests/part_02.rs");
|
||||
use super::*;
|
||||
use http_body_util::BodyExt;
|
||||
|
||||
fn active_settings() -> Settings {
|
||||
Settings {
|
||||
enabled: true,
|
||||
..Settings::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn store(
|
||||
cache: &Arc<Cache>,
|
||||
scope: &str,
|
||||
host: &str,
|
||||
target: &str,
|
||||
request_headers: HeaderMap,
|
||||
extra_response_headers: &[(&str, &str)],
|
||||
body: &'static str,
|
||||
) -> Ticket {
|
||||
let ticket = cache
|
||||
.ticket_scoped(
|
||||
22,
|
||||
scope,
|
||||
"http",
|
||||
host,
|
||||
"origin@127.0.0.1:8080",
|
||||
target,
|
||||
&request_headers,
|
||||
)
|
||||
.unwrap();
|
||||
let settings = active_settings();
|
||||
let mut response = Response::new(Body::from(body));
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static("public, max-age=120, stale-if-error=60"),
|
||||
);
|
||||
for (name, value) in extra_response_headers {
|
||||
response.headers_mut().insert(
|
||||
http::HeaderName::from_bytes(name.as_bytes()).unwrap(),
|
||||
HeaderValue::from_str(value).unwrap(),
|
||||
);
|
||||
}
|
||||
let plan = plan(
|
||||
StatusCode::OK,
|
||||
response.headers(),
|
||||
&settings,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
cache
|
||||
.wrap(response, ticket.clone(), plan, &settings)
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
ticket
|
||||
}
|
||||
|
||||
fn invalidation(
|
||||
operation: InvalidationOperation,
|
||||
selector: InvalidationSelector,
|
||||
value: &str,
|
||||
host: Option<&str>,
|
||||
vhost: Option<&str>,
|
||||
) -> InvalidationRequest {
|
||||
InvalidationRequest {
|
||||
operation,
|
||||
selector,
|
||||
value: value.to_owned(),
|
||||
host: host.map(str::to_owned),
|
||||
vhost: vhost.map(str::to_owned),
|
||||
ttl_seconds: None,
|
||||
confirm_wide: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn transformed_fill_retains_identical_content_and_releases_fill_gauge() {
|
||||
for body in [
|
||||
Bytes::from(vec![b'x'; 4096]),
|
||||
Bytes::from_static(b"unchanged static bytes"),
|
||||
] {
|
||||
let cache = Cache::new(22);
|
||||
let settings = active_settings();
|
||||
let ticket = cache.ticket(22, "http", "example.test", "origin", "/transform.js", &HeaderMap::new()).unwrap();
|
||||
let mut response = Response::new(Body::from(body.clone()));
|
||||
response.headers_mut().insert("cache-control", HeaderValue::from_static("public, max-age=120"));
|
||||
response.headers_mut().insert("content-length", HeaderValue::from_str(&body.len().to_string()).unwrap());
|
||||
response.extensions_mut().insert(crate::transform::Applied {
|
||||
kind: crate::transform::Kind::Javascript,
|
||||
original_bytes: body.len(),
|
||||
transformed_bytes: body.len(),
|
||||
});
|
||||
let policy = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
|
||||
assert_eq!(policy.expected_length, Some(body.len()));
|
||||
let downstream = cache.wrap(response, ticket.clone(), policy, &settings)
|
||||
.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(downstream, body);
|
||||
let cached = cache.get(&ticket).unwrap().response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(cached, body);
|
||||
assert_eq!(cache.fill_bytes_active.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uniquely_owned_transformed_frame_is_cached_without_body_copy() {
|
||||
let cache = Cache::new(22);
|
||||
let settings = active_settings();
|
||||
let ticket = cache.ticket(22, "http", "example.test", "origin", "/owned.js", &HeaderMap::new()).unwrap();
|
||||
let payload = Bytes::from(vec![b'z'; 8192]);
|
||||
let original_allocation = payload.as_ptr();
|
||||
let mut response = Response::new(Body::from(payload));
|
||||
response.headers_mut().insert("cache-control", HeaderValue::from_static("public, max-age=120"));
|
||||
response.headers_mut().insert("content-length", HeaderValue::from_static("8192"));
|
||||
response.extensions_mut().insert(crate::transform::Applied {
|
||||
kind: crate::transform::Kind::Javascript,
|
||||
original_bytes: 8192,
|
||||
transformed_bytes: 8192,
|
||||
});
|
||||
let policy = plan(StatusCode::OK, response.headers(), &settings, Duration::ZERO).unwrap();
|
||||
let downstream = cache.wrap(response, ticket.clone(), policy, &settings)
|
||||
.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(downstream.len(), 8192);
|
||||
assert_eq!(downstream.as_ptr(), original_allocation);
|
||||
let hit = cache.get(&ticket).unwrap();
|
||||
let cached = hit.response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(cached, downstream);
|
||||
assert_eq!(cached.as_ptr(), original_allocation, "cache fill copied the transformed body");
|
||||
assert_eq!(cache.fill_bytes_active.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cached_single_range_returns_206_and_preserves_validators() {
|
||||
let cache = Cache::new(22);
|
||||
let ticket = store(
|
||||
&cache,
|
||||
"range",
|
||||
"range.example",
|
||||
"/asset.bin",
|
||||
HeaderMap::new(),
|
||||
&[
|
||||
("etag", "\"asset-v1\""),
|
||||
("last-modified", "Mon, 26 Jan 2026 23:11:40 GMT"),
|
||||
],
|
||||
"abcdefghij",
|
||||
)
|
||||
.await;
|
||||
let mut request_headers = HeaderMap::new();
|
||||
request_headers.insert("range", HeaderValue::from_static("bytes=2-5"));
|
||||
let range = match request_range(&Method::GET, &request_headers) {
|
||||
RangeSelection::Single(range) => range,
|
||||
other => panic!("unexpected range selection: {other:?}"),
|
||||
};
|
||||
let hit = cache.get(&ticket).unwrap().apply_range(range, None);
|
||||
assert_eq!(hit.response.status(), StatusCode::PARTIAL_CONTENT);
|
||||
assert_eq!(hit.response.headers()["content-range"], "bytes 2-5/10");
|
||||
assert_eq!(hit.response.headers()["content-length"], "4");
|
||||
assert_eq!(hit.response.headers()["accept-ranges"], "bytes");
|
||||
assert_eq!(hit.response.headers()["etag"], "\"asset-v1\"");
|
||||
assert!(hit.response.headers().contains_key("age"));
|
||||
let body = hit.response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(body, Bytes::from_static(b"cdef"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cached_range_supports_suffix_open_unsatisfied_and_if_range() {
|
||||
let cache = Cache::new(22);
|
||||
let ticket = store(
|
||||
&cache,
|
||||
"range",
|
||||
"range.example",
|
||||
"/asset.bin",
|
||||
HeaderMap::new(),
|
||||
&[
|
||||
("etag", "\"asset-v1\""),
|
||||
("last-modified", "Mon, 26 Jan 2026 23:11:40 GMT"),
|
||||
],
|
||||
"abcdefghij",
|
||||
)
|
||||
.await;
|
||||
let cases = [
|
||||
(ByteRange { start: None, end: Some(3) }, "hij", "bytes 7-9/10"),
|
||||
(ByteRange { start: Some(7), end: None }, "hij", "bytes 7-9/10"),
|
||||
];
|
||||
for (range, expected, content_range) in cases {
|
||||
let hit = cache.get(&ticket).unwrap().apply_range(range, None);
|
||||
assert_eq!(hit.response.status(), StatusCode::PARTIAL_CONTENT);
|
||||
assert_eq!(hit.response.headers()["content-range"], content_range);
|
||||
let body = hit.response.into_body().collect().await.unwrap().to_bytes();
|
||||
assert_eq!(body, Bytes::copy_from_slice(expected.as_bytes()));
|
||||
}
|
||||
|
||||
let unsatisfied = cache.get(&ticket).unwrap().apply_range(
|
||||
ByteRange { start: Some(50), end: None },
|
||||
None,
|
||||
);
|
||||
assert_eq!(unsatisfied.response.status(), StatusCode::RANGE_NOT_SATISFIABLE);
|
||||
assert_eq!(unsatisfied.response.headers()["content-range"], "bytes */10");
|
||||
assert_eq!(
|
||||
unsatisfied.response.into_body().collect().await.unwrap().to_bytes().len(),
|
||||
0
|
||||
);
|
||||
|
||||
let mismatch = HeaderValue::from_static("\"asset-v2\"");
|
||||
let full = cache.get(&ticket).unwrap().apply_range(
|
||||
ByteRange { start: Some(0), end: Some(2) },
|
||||
Some(&mismatch),
|
||||
);
|
||||
assert_eq!(full.response.status(), StatusCode::OK);
|
||||
assert!(!full.response.headers().contains_key("content-range"));
|
||||
assert_eq!(
|
||||
full.response.into_body().collect().await.unwrap().to_bytes(),
|
||||
Bytes::from_static(b"abcdefghij")
|
||||
);
|
||||
|
||||
let matched = HeaderValue::from_static("\"asset-v1\"");
|
||||
let partial = cache.get(&ticket).unwrap().apply_range(
|
||||
ByteRange { start: Some(0), end: Some(2) },
|
||||
Some(&matched),
|
||||
);
|
||||
assert_eq!(partial.response.status(), StatusCode::PARTIAL_CONTENT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_parser_rejects_multipart_and_duplicate_fields_and_if_range_date_allows_older_representation() {
|
||||
let mut multipart = HeaderMap::new();
|
||||
multipart.insert("range", HeaderValue::from_static("bytes=0-1,4-5"));
|
||||
assert_eq!(
|
||||
request_range(&Method::GET, &multipart),
|
||||
RangeSelection::Unsupported
|
||||
);
|
||||
|
||||
let mut duplicate = HeaderMap::new();
|
||||
duplicate.append("range", HeaderValue::from_static("bytes=0-1"));
|
||||
duplicate.append("range", HeaderValue::from_static("bytes=4-5"));
|
||||
assert_eq!(
|
||||
request_range(&Method::GET, &duplicate),
|
||||
RangeSelection::Unsupported
|
||||
);
|
||||
|
||||
let mut response_headers = HeaderMap::new();
|
||||
response_headers.insert(
|
||||
"last-modified",
|
||||
HeaderValue::from_static("Mon, 26 Jan 2026 23:11:40 GMT"),
|
||||
);
|
||||
let newer_if_range = HeaderValue::from_static("Tue, 27 Jan 2026 23:11:40 GMT");
|
||||
assert!(if_range_matches(&response_headers, Some(&newer_if_range)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_miss_single_flight_waits_until_fill_is_published() {
|
||||
let cache = Cache::new(22);
|
||||
let mut settings = active_settings();
|
||||
settings.collapse_wait_timeout_ms = 500;
|
||||
let ticket = cache
|
||||
.ticket_scoped(
|
||||
22,
|
||||
"collapse",
|
||||
"http",
|
||||
"collapse.example",
|
||||
"origin@127.0.0.1:8080",
|
||||
"/same",
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let leader = match cache.claim_fill(&ticket, &settings).await {
|
||||
FillClaim::Leader(guard) => guard,
|
||||
_ => panic!("first request must become fill leader"),
|
||||
};
|
||||
let follower_cache = cache.clone();
|
||||
let follower_ticket = ticket.clone();
|
||||
let follower_settings = settings.clone();
|
||||
let follower = tokio::spawn(async move {
|
||||
match follower_cache
|
||||
.claim_fill(&follower_ticket, &follower_settings)
|
||||
.await
|
||||
{
|
||||
FillClaim::Waited => follower_cache.get(&follower_ticket).is_some(),
|
||||
_ => false,
|
||||
}
|
||||
});
|
||||
for _ in 0..50 {
|
||||
if cache.view().collapse_waiters == 1 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||
}
|
||||
assert_eq!(cache.view().collapse_waiters, 1);
|
||||
assert!(!follower.is_finished());
|
||||
|
||||
let mut response = Response::new(Body::from("collapsed-payload"));
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static("public, max-age=60"),
|
||||
);
|
||||
let plan = plan(
|
||||
StatusCode::OK,
|
||||
response.headers(),
|
||||
&settings,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
cache
|
||||
.wrap_with_fill(response, ticket.clone(), plan, &settings, Some(leader))
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(follower.await.unwrap());
|
||||
assert_eq!(cache.view().collapsed_fills, 1);
|
||||
assert_eq!(cache.view().collapse_waiters, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn single_flight_timeout_and_leader_drop_release_the_key() {
|
||||
let cache = Cache::new(22);
|
||||
let mut settings = active_settings();
|
||||
settings.collapse_wait_timeout_ms = 10;
|
||||
let ticket = cache
|
||||
.ticket(22, "http", "collapse.example", "origin", "/timeout", &HeaderMap::new())
|
||||
.unwrap();
|
||||
let leader = match cache.claim_fill(&ticket, &settings).await {
|
||||
FillClaim::Leader(guard) => guard,
|
||||
_ => panic!("missing leader"),
|
||||
};
|
||||
assert!(matches!(cache.claim_fill(&ticket, &settings).await, FillClaim::Timeout));
|
||||
assert_eq!(cache.view().collapse_timeouts, 1);
|
||||
drop(leader);
|
||||
assert!(matches!(
|
||||
cache.claim_fill(&ticket, &settings).await,
|
||||
FillClaim::Leader(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelled_single_flight_waiter_releases_waiter_metric() {
|
||||
let cache = Cache::new(22);
|
||||
let mut settings = active_settings();
|
||||
settings.collapse_wait_timeout_ms = 5_000;
|
||||
let ticket = cache
|
||||
.ticket(22, "http", "collapse.example", "origin", "/cancel", &HeaderMap::new())
|
||||
.unwrap();
|
||||
let leader = match cache.claim_fill(&ticket, &settings).await {
|
||||
FillClaim::Leader(guard) => guard,
|
||||
_ => panic!("missing leader"),
|
||||
};
|
||||
let follower_cache = cache.clone();
|
||||
let follower_ticket = ticket.clone();
|
||||
let follower_settings = settings.clone();
|
||||
let follower = tokio::spawn(async move {
|
||||
follower_cache
|
||||
.claim_fill(&follower_ticket, &follower_settings)
|
||||
.await
|
||||
});
|
||||
for _ in 0..50 {
|
||||
if cache.view().collapse_waiters == 1 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||
}
|
||||
assert_eq!(cache.view().collapse_waiters, 1);
|
||||
follower.abort();
|
||||
let _ = follower.await;
|
||||
tokio::task::yield_now().await;
|
||||
assert_eq!(cache.view().collapse_waiters, 0);
|
||||
drop(leader);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn single_flight_retries_after_another_vary_representation_is_published() {
|
||||
let cache = Cache::new(22);
|
||||
let mut settings = active_settings();
|
||||
settings.collapse_wait_timeout_ms = 500;
|
||||
let mut en_headers = HeaderMap::new();
|
||||
en_headers.insert("accept-language", HeaderValue::from_static("en"));
|
||||
let mut pl_headers = HeaderMap::new();
|
||||
pl_headers.insert("accept-language", HeaderValue::from_static("pl"));
|
||||
let en = cache
|
||||
.ticket(22, "http", "vary-collapse.example", "origin", "/page", &en_headers)
|
||||
.unwrap();
|
||||
let pl = cache
|
||||
.ticket(22, "http", "vary-collapse.example", "origin", "/page", &pl_headers)
|
||||
.unwrap();
|
||||
assert_eq!(en.key, pl.key, "Vary is learned from the response");
|
||||
let leader = match cache.claim_fill(&en, &settings).await {
|
||||
FillClaim::Leader(guard) => guard,
|
||||
_ => panic!("first representation must become leader"),
|
||||
};
|
||||
let follower_cache = cache.clone();
|
||||
let follower_ticket = pl.clone();
|
||||
let follower_settings = settings.clone();
|
||||
let follower = tokio::spawn(async move {
|
||||
assert!(matches!(
|
||||
follower_cache
|
||||
.claim_fill(&follower_ticket, &follower_settings)
|
||||
.await,
|
||||
FillClaim::Waited
|
||||
));
|
||||
assert!(follower_cache.get(&follower_ticket).is_none());
|
||||
matches!(
|
||||
follower_cache
|
||||
.claim_fill(&follower_ticket, &follower_settings)
|
||||
.await,
|
||||
FillClaim::Leader(_)
|
||||
)
|
||||
});
|
||||
for _ in 0..50 {
|
||||
if cache.view().collapse_waiters == 1 {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||
}
|
||||
|
||||
let mut response = Response::new(Body::from("hello"));
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static("public, max-age=60"),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("vary", HeaderValue::from_static("Accept-Language"));
|
||||
let plan = plan(
|
||||
StatusCode::OK,
|
||||
response.headers(),
|
||||
&settings,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
cache
|
||||
.wrap_with_fill(response, en, plan, &settings, Some(leader))
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(follower.await.unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_if_error_policy_is_configurable_by_error_class_and_status() {
|
||||
let mut settings = Settings::default();
|
||||
settings.stale_if_error_timeout = false;
|
||||
settings.stale_if_error_dns = false;
|
||||
settings.stale_if_error_statuses = vec![500, 503];
|
||||
assert!(!settings.allows_stale_error_class(StaleErrorClass::Timeout));
|
||||
assert!(!settings.allows_stale_error_class(StaleErrorClass::Dns));
|
||||
assert!(settings.allows_stale_error_class(StaleErrorClass::Connection));
|
||||
assert!(settings.allows_stale_error_class(StaleErrorClass::Tls));
|
||||
assert!(settings.allows_stale_error_status(StatusCode::INTERNAL_SERVER_ERROR));
|
||||
assert!(settings.allows_stale_error_status(StatusCode::SERVICE_UNAVAILABLE));
|
||||
assert!(!settings.allows_stale_error_status(StatusCode::BAD_GATEWAY));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proactive_refresh_guards_deduplicate_and_bound_parallel_work() {
|
||||
let cache = Cache::new(22);
|
||||
let ticket = cache
|
||||
.ticket(22, "http", "refresh.example", "origin", "/asset", &HeaderMap::new())
|
||||
.unwrap();
|
||||
let mut settings = active_settings();
|
||||
settings.proactive_refresh_enabled = true;
|
||||
settings.proactive_refresh_percent = 100;
|
||||
settings.proactive_refresh_max_inflight = 1;
|
||||
let scheduled = cache
|
||||
.begin_proactive_schedule(&ticket, &settings)
|
||||
.expect("first schedule");
|
||||
assert!(cache.begin_proactive_schedule(&ticket, &settings).is_none());
|
||||
let running = cache
|
||||
.try_begin_proactive_refresh(&settings)
|
||||
.expect("first inflight refresh");
|
||||
assert!(cache.try_begin_proactive_refresh(&settings).is_none());
|
||||
assert_eq!(cache.view().proactive_inflight, 1);
|
||||
drop(running);
|
||||
drop(scheduled);
|
||||
assert!(cache.begin_proactive_schedule(&ticket, &settings).is_some());
|
||||
assert_eq!(cache.view().proactive_inflight, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proactive_refresh_pending_tasks_are_bounded() {
|
||||
let cache = Cache::new(22);
|
||||
let mut settings = active_settings();
|
||||
settings.proactive_refresh_enabled = true;
|
||||
settings.proactive_refresh_percent = 100;
|
||||
settings.proactive_refresh_max_inflight = 1;
|
||||
let mut guards = Vec::new();
|
||||
for index in 0..64 {
|
||||
let ticket = cache
|
||||
.ticket(
|
||||
22,
|
||||
"http",
|
||||
"refresh.example",
|
||||
"origin",
|
||||
&format!("/asset-{index}"),
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
guards.push(
|
||||
cache
|
||||
.begin_proactive_schedule(&ticket, &settings)
|
||||
.expect("schedule below pending limit"),
|
||||
);
|
||||
}
|
||||
let overflow = cache
|
||||
.ticket(
|
||||
22,
|
||||
"http",
|
||||
"refresh.example",
|
||||
"origin",
|
||||
"/overflow",
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(cache.begin_proactive_schedule(&overflow, &settings).is_none());
|
||||
assert!(cache.view().proactive_skipped > 0);
|
||||
drop(guards);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_revalidation_set_has_a_hard_limit() {
|
||||
let cache = Cache::new(22);
|
||||
let base = Revalidation {
|
||||
headers: HeaderMap::new(),
|
||||
body: Bytes::new(),
|
||||
body_allocated: 0,
|
||||
_transient_leases: Vec::new(),
|
||||
hits: 0,
|
||||
transformed: false,
|
||||
storage_key: [0; 32],
|
||||
resident_age_at_lookup: Duration::ZERO,
|
||||
skip_compression: false,
|
||||
reusable_without_validation: false,
|
||||
age: 0,
|
||||
ttl: 0,
|
||||
stale_if_error: 0,
|
||||
must_revalidate: false,
|
||||
proxy_revalidate: false,
|
||||
origin_etag: None,
|
||||
origin_last_modified: None,
|
||||
};
|
||||
let mut guards = Vec::new();
|
||||
for index in 0..BACKGROUND_REVALIDATION_LIMIT {
|
||||
let mut cached = base.clone();
|
||||
cached.storage_key[0..8].copy_from_slice(&(index as u64).to_be_bytes());
|
||||
guards.push(
|
||||
cache
|
||||
.begin_background_revalidation(&cached)
|
||||
.expect("revalidation below hard limit"),
|
||||
);
|
||||
}
|
||||
let mut overflow = base;
|
||||
overflow.storage_key = [0xff; 32];
|
||||
assert!(cache.begin_background_revalidation(&overflow).is_none());
|
||||
drop(guards);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn selective_purge_exact_prefix_tag_cache_key_and_query_are_precise() {
|
||||
let cache = Cache::new(22);
|
||||
let one = store(
|
||||
&cache,
|
||||
"app",
|
||||
"purge.example",
|
||||
"/asset.js?v=1",
|
||||
HeaderMap::new(),
|
||||
&[("cache-tag", "release:one")],
|
||||
"one",
|
||||
)
|
||||
.await;
|
||||
let two = store(
|
||||
&cache,
|
||||
"app",
|
||||
"purge.example",
|
||||
"/asset.js?v=2",
|
||||
HeaderMap::new(),
|
||||
&[("cache-tag", "release:two")],
|
||||
"two",
|
||||
)
|
||||
.await;
|
||||
let result = cache
|
||||
.invalidate(invalidation(
|
||||
InvalidationOperation::Purge,
|
||||
InvalidationSelector::Exact,
|
||||
"/asset.js?v=1",
|
||||
Some("purge.example"),
|
||||
Some("app"),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(result.affected_entries, 1);
|
||||
assert!(cache.get(&one).is_none());
|
||||
assert!(cache.get(&two).is_some());
|
||||
|
||||
let three = store(
|
||||
&cache,
|
||||
"app",
|
||||
"purge.example",
|
||||
"/assets/a.css",
|
||||
HeaderMap::new(),
|
||||
&[("cache-tag", "release:three")],
|
||||
"three",
|
||||
)
|
||||
.await;
|
||||
let four = store(
|
||||
&cache,
|
||||
"app",
|
||||
"purge.example",
|
||||
"/assets/b.css",
|
||||
HeaderMap::new(),
|
||||
&[("cache-tag", "release:four")],
|
||||
"four",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
cache
|
||||
.invalidate(invalidation(
|
||||
InvalidationOperation::Purge,
|
||||
InvalidationSelector::Prefix,
|
||||
"/assets/",
|
||||
Some("purge.example"),
|
||||
Some("app"),
|
||||
))
|
||||
.unwrap()
|
||||
.affected_entries,
|
||||
2
|
||||
);
|
||||
assert!(cache.get(&three).is_none());
|
||||
assert!(cache.get(&four).is_none());
|
||||
|
||||
let tagged = store(
|
||||
&cache,
|
||||
"app",
|
||||
"purge.example",
|
||||
"/tagged",
|
||||
HeaderMap::new(),
|
||||
&[("cache-tag", "release:tagged")],
|
||||
"tagged",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
cache
|
||||
.invalidate(invalidation(
|
||||
InvalidationOperation::Purge,
|
||||
InvalidationSelector::Tag,
|
||||
"release:tagged",
|
||||
Some("purge.example"),
|
||||
Some("app"),
|
||||
))
|
||||
.unwrap()
|
||||
.affected_entries,
|
||||
1
|
||||
);
|
||||
assert!(cache.get(&tagged).is_none());
|
||||
|
||||
let keyed = store(
|
||||
&cache,
|
||||
"app",
|
||||
"purge.example",
|
||||
"/by-key",
|
||||
HeaderMap::new(),
|
||||
&[],
|
||||
"keyed",
|
||||
)
|
||||
.await;
|
||||
let key = key_hex(&keyed.key);
|
||||
assert_eq!(
|
||||
cache
|
||||
.invalidate(invalidation(
|
||||
InvalidationOperation::Purge,
|
||||
InvalidationSelector::CacheKey,
|
||||
&key,
|
||||
Some("purge.example"),
|
||||
Some("app"),
|
||||
))
|
||||
.unwrap()
|
||||
.affected_entries,
|
||||
1
|
||||
);
|
||||
assert!(cache.get(&keyed).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn selective_purge_is_a_barrier_for_a_fill_started_before_the_purge() {
|
||||
let cache = Cache::new(22);
|
||||
let settings = active_settings();
|
||||
let old_ticket = cache
|
||||
.ticket_scoped(
|
||||
22,
|
||||
"app",
|
||||
"http",
|
||||
"purge.example",
|
||||
"origin@127.0.0.1:8080",
|
||||
"/late",
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let result = cache
|
||||
.invalidate(invalidation(
|
||||
InvalidationOperation::Purge,
|
||||
InvalidationSelector::Tag,
|
||||
"release:late",
|
||||
Some("purge.example"),
|
||||
Some("app"),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(result.affected_entries, 0);
|
||||
|
||||
let mut response = Response::new(Body::from("old-fill"));
|
||||
response.headers_mut().insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static("public, max-age=60"),
|
||||
);
|
||||
response
|
||||
.headers_mut()
|
||||
.insert("cache-tag", HeaderValue::from_static("release:late"));
|
||||
let plan = plan(
|
||||
StatusCode::OK,
|
||||
response.headers(),
|
||||
&settings,
|
||||
Duration::ZERO,
|
||||
)
|
||||
.unwrap();
|
||||
cache
|
||||
.wrap(response, old_ticket, plan, &settings)
|
||||
.into_body()
|
||||
.collect()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(cache.view().entries, 0, "pre-purge fill must not republish");
|
||||
|
||||
let new_ticket = store(
|
||||
&cache,
|
||||
"app",
|
||||
"purge.example",
|
||||
"/late",
|
||||
HeaderMap::new(),
|
||||
&[("cache-tag", "release:late")],
|
||||
"new-fill",
|
||||
)
|
||||
.await;
|
||||
assert!(cache.get(&new_ticket).is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn purge_of_one_vary_variant_removes_the_whole_variant_family() {
|
||||
let cache = Cache::new(22);
|
||||
let mut en_headers = HeaderMap::new();
|
||||
en_headers.insert("accept-language", HeaderValue::from_static("en"));
|
||||
let mut pl_headers = HeaderMap::new();
|
||||
pl_headers.insert("accept-language", HeaderValue::from_static("pl"));
|
||||
let en = store(
|
||||
&cache,
|
||||
"app",
|
||||
"vary.example",
|
||||
"/localized",
|
||||
en_headers,
|
||||
&[("vary", "Accept-Language"), ("cache-tag", "variant:en")],
|
||||
"hello",
|
||||
)
|
||||
.await;
|
||||
let pl = store(
|
||||
&cache,
|
||||
"app",
|
||||
"vary.example",
|
||||
"/localized",
|
||||
pl_headers,
|
||||
&[("vary", "Accept-Language"), ("cache-tag", "variant:pl")],
|
||||
"czesc",
|
||||
)
|
||||
.await;
|
||||
let result = cache
|
||||
.invalidate(invalidation(
|
||||
InvalidationOperation::Purge,
|
||||
InvalidationSelector::Tag,
|
||||
"variant:en",
|
||||
Some("vary.example"),
|
||||
Some("app"),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(result.affected_entries, 2);
|
||||
assert!(cache.get(&en).is_none());
|
||||
assert!(cache.get(&pl).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ban_is_lazy_blocks_old_object_allows_new_fill_and_can_be_removed() {
|
||||
let cache = Cache::new(22);
|
||||
let old = store(
|
||||
&cache,
|
||||
"app",
|
||||
"ban.example",
|
||||
"/page",
|
||||
HeaderMap::new(),
|
||||
&[],
|
||||
"old",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(cache.view().entries, 1);
|
||||
let result = cache
|
||||
.invalidate(invalidation(
|
||||
InvalidationOperation::Ban,
|
||||
InvalidationSelector::Exact,
|
||||
"/page",
|
||||
Some("ban.example"),
|
||||
Some("app"),
|
||||
))
|
||||
.unwrap();
|
||||
let ban_id = result.ban_id.unwrap();
|
||||
assert_eq!(result.affected_entries, 0);
|
||||
assert_eq!(cache.view().entries, 1, "BAN must not scan storage eagerly");
|
||||
assert!(cache.get(&old).is_none());
|
||||
assert_eq!(cache.view().entries, 0);
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(2)).await;
|
||||
let fresh = store(
|
||||
&cache,
|
||||
"app",
|
||||
"ban.example",
|
||||
"/page",
|
||||
HeaderMap::new(),
|
||||
&[],
|
||||
"fresh",
|
||||
)
|
||||
.await;
|
||||
assert!(cache.get(&fresh).is_some(), "old BAN must not reject newer objects");
|
||||
assert_eq!(cache.bans().len(), 1);
|
||||
assert!(cache.remove_ban(ban_id));
|
||||
assert!(cache.bans().is_empty());
|
||||
assert!(!cache.remove_ban(ban_id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn regex_and_root_prefix_require_explicit_wide_confirmation() {
|
||||
let cache = Cache::new(22);
|
||||
store(
|
||||
&cache,
|
||||
"app",
|
||||
"wide.example",
|
||||
"/one",
|
||||
HeaderMap::new(),
|
||||
&[],
|
||||
"one",
|
||||
)
|
||||
.await;
|
||||
let regex = invalidation(
|
||||
InvalidationOperation::Purge,
|
||||
InvalidationSelector::Regex,
|
||||
".*",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert!(cache.invalidate(regex.clone()).is_err());
|
||||
let scoped_regex = invalidation(
|
||||
InvalidationOperation::Purge,
|
||||
InvalidationSelector::Regex,
|
||||
"^/one$",
|
||||
Some("wide.example"),
|
||||
Some("app"),
|
||||
);
|
||||
assert!(cache.invalidate(scoped_regex).is_err());
|
||||
let mut confirmed = regex;
|
||||
confirmed.confirm_wide = true;
|
||||
assert_eq!(cache.invalidate(confirmed).unwrap().affected_entries, 1);
|
||||
|
||||
let prefix = invalidation(
|
||||
InvalidationOperation::Ban,
|
||||
InvalidationSelector::Prefix,
|
||||
"/",
|
||||
Some("wide.example"),
|
||||
Some("app"),
|
||||
);
|
||||
assert!(cache.invalidate(prefix.clone()).is_err());
|
||||
let mut confirmed_prefix = prefix;
|
||||
confirmed_prefix.confirm_wide = true;
|
||||
assert!(cache.invalidate(confirmed_prefix).unwrap().ban_id.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn many_distinct_selective_purges_do_not_retain_tombstones_forever() {
|
||||
let cache = Cache::new(22);
|
||||
store(
|
||||
&cache,
|
||||
"app",
|
||||
"memory.example",
|
||||
"/stable",
|
||||
HeaderMap::new(),
|
||||
&[],
|
||||
"unchanged",
|
||||
)
|
||||
.await;
|
||||
// A production cache may see an unbounded stream of PURGEs for URLs
|
||||
// that will never be cached again. The invalidated-key index must
|
||||
// not remember all of those removed objects indefinitely.
|
||||
for index in 0..512 {
|
||||
let target = format!("/unique-{index}");
|
||||
let old = store(
|
||||
&cache,
|
||||
"app",
|
||||
"memory.example",
|
||||
&target,
|
||||
HeaderMap::new(),
|
||||
&[],
|
||||
"x",
|
||||
)
|
||||
.await;
|
||||
assert!(cache.ticket_current(&old));
|
||||
let result = cache
|
||||
.invalidate(invalidation(
|
||||
InvalidationOperation::Purge,
|
||||
InvalidationSelector::Exact,
|
||||
&target,
|
||||
Some("memory.example"),
|
||||
Some("app"),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(result.affected_entries, 1);
|
||||
assert!(!cache.ticket_current(&old), "pre-purge ticket must remain stale");
|
||||
let state = cache.store.lock().unwrap_or_else(|e| e.into_inner());
|
||||
assert!(
|
||||
state.key_generations.len() <= 1,
|
||||
"key generations must not accumulate for already purged URLs"
|
||||
);
|
||||
}
|
||||
// A new ticket after the invalidation barrier is valid and can fill
|
||||
// the same URL again, preserving ordinary cache behavior.
|
||||
let new = store(
|
||||
&cache,
|
||||
"app",
|
||||
"memory.example",
|
||||
"/unique-0",
|
||||
HeaderMap::new(),
|
||||
&[],
|
||||
"new",
|
||||
)
|
||||
.await;
|
||||
assert!(cache.ticket_current(&new));
|
||||
assert!(cache.get(&new).is_some());
|
||||
let stable = cache
|
||||
.ticket_scoped(
|
||||
22,
|
||||
"app",
|
||||
"http",
|
||||
"memory.example",
|
||||
"origin@127.0.0.1:8080",
|
||||
"/stable",
|
||||
&HeaderMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(cache.get(&stable).is_some(), "unrelated entries must survive");
|
||||
}
|
||||
}
|
||||
+905
-3
@@ -1,5 +1,907 @@
|
||||
mod tests {
|
||||
include!("tests/part_01.rs");
|
||||
include!("tests/part_02.rs");
|
||||
include!("tests/part_03.rs");
|
||||
use super::*;
|
||||
fn policy() -> ValidationPolicy {
|
||||
ValidationPolicy {
|
||||
tls_addr: None,
|
||||
cert_dir: None,
|
||||
data_dir: None,
|
||||
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
|
||||
admin_addr: "127.0.0.1:9090".parse().unwrap(),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn missing_http2_settings_use_release_defaults() {
|
||||
let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
|
||||
assert_eq!(config.http2, crate::settings::Http2Connections::default());
|
||||
config.http2.validate().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_unicode_prefix_survives_serialization_and_recompile() {
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.vhosts[0].routes[0].path_prefix = "/caf%C3%A9".into();
|
||||
let first = compile(config, 0, &policy()).unwrap();
|
||||
let saved = serde_json::to_string(&first.envelope.config).unwrap();
|
||||
let second = compile(serde_json::from_str(&saved).unwrap(), 1, &policy()).unwrap();
|
||||
assert!(second.select("localhost", "/caf\u{e9}/hello").is_some());
|
||||
}
|
||||
#[test]
|
||||
fn web_server_main_source_needs_no_backend_pool() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.html"), b"hello").unwrap();
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.web_services.push(crate::web_server::Service {
|
||||
id: "site".into(),
|
||||
name: "Site".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
index: vec!["index.html".into()],
|
||||
directory_listing: false,
|
||||
..Default::default()
|
||||
});
|
||||
config.vhosts[0].source = crate::vhost::Source::WebServer {
|
||||
web_service_id: "site".into(),
|
||||
};
|
||||
config.vhosts[0].backends.clear();
|
||||
config.vhosts[0].routes.clear();
|
||||
let snapshot = compile(config, 1, &policy()).unwrap();
|
||||
let route = snapshot.select("localhost", "/").unwrap();
|
||||
assert!(matches!(&route.source, CompiledSource::WebServer(_)));
|
||||
assert!(route.pool.is_none());
|
||||
assert!(route.backend.is_none());
|
||||
assert!(snapshot.pools.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_server_and_reverse_proxy_can_share_one_vhost() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.html"), b"hello").unwrap();
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.web_services.push(crate::web_server::Service {
|
||||
id: "site".into(),
|
||||
name: "Site".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
index: vec!["index.html".into()],
|
||||
directory_listing: false,
|
||||
..Default::default()
|
||||
});
|
||||
let host = &mut config.vhosts[0];
|
||||
host.source = crate::vhost::Source::WebServer {
|
||||
web_service_id: "site".into(),
|
||||
};
|
||||
host.routes = vec![crate::vhost::PathRoute {
|
||||
id: "api".into(),
|
||||
path_prefix: "/api".into(),
|
||||
source: crate::vhost::Source::ReverseProxy,
|
||||
..Default::default()
|
||||
}];
|
||||
let snapshot = compile(config, 1, &policy()).unwrap();
|
||||
let root = snapshot.select("localhost", "/").unwrap();
|
||||
let api = snapshot.select("localhost", "/api/test").unwrap();
|
||||
assert!(matches!(&root.source, CompiledSource::WebServer(_)));
|
||||
assert!(matches!(&api.source, CompiledSource::ReverseProxy));
|
||||
assert!(api.pool.is_some());
|
||||
assert!(api.backend.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_web_service_can_be_reused_by_multiple_vhosts() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.html"), b"shared").unwrap();
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.web_services.push(crate::web_server::Service {
|
||||
id: "shared-site".into(),
|
||||
name: "Shared site".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
index: vec!["index.html".into()],
|
||||
directory_listing: false,
|
||||
..Default::default()
|
||||
});
|
||||
let first = &mut config.vhosts[0];
|
||||
first.source = crate::vhost::Source::WebServer {
|
||||
web_service_id: "shared-site".into(),
|
||||
};
|
||||
first.backends.clear();
|
||||
first.routes.clear();
|
||||
|
||||
let mut second = first.clone();
|
||||
second.id = "shared-second".into();
|
||||
second.frontend.hosts = vec!["second.localhost".into()];
|
||||
config.vhosts.push(second);
|
||||
|
||||
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")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_web_service_reference_is_rejected() {
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.vhosts[0].source = crate::vhost::Source::WebServer {
|
||||
web_service_id: "missing".into(),
|
||||
};
|
||||
let error = compile(config, 1, &policy()).unwrap_err().to_string();
|
||||
assert!(error.contains("unknown Web Service"), "{error}");
|
||||
}
|
||||
|
||||
#[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();
|
||||
assert_eq!(source, crate::vhost::Source::ReverseProxy);
|
||||
let route: crate::vhost::PathRoute =
|
||||
serde_json::from_value(serde_json::json!({"id":"legacy"})).unwrap();
|
||||
assert_eq!(route.source, crate::vhost::Source::ReverseProxy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_normalization() {
|
||||
assert_eq!(request_host("EXAMPLE.com.:8080").unwrap(), "example.com");
|
||||
for invalid in [
|
||||
"",
|
||||
"a..b",
|
||||
"*.test",
|
||||
"a/b",
|
||||
"user@host",
|
||||
"-bad.test",
|
||||
"a:99999",
|
||||
] {
|
||||
assert!(request_host(invalid).is_err(), "{invalid}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn wildcard_host_patterns_are_normalized_and_exact_hosts_win() {
|
||||
assert_eq!(
|
||||
normalize_host_pattern("*.Example.TEST.").unwrap(),
|
||||
"*.example.test"
|
||||
);
|
||||
for invalid in ["*", "foo.*.test", "*.com", "*.*.example.test"] {
|
||||
assert!(normalize_host_pattern(invalid).is_err(), "{invalid}");
|
||||
}
|
||||
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].id = "wildcard".into();
|
||||
c.vhosts[0].frontend.hosts = vec!["*.example.test".into()];
|
||||
let mut exact = c.vhosts[0].clone();
|
||||
exact.id = "exact".into();
|
||||
exact.frontend.hosts = vec!["api.example.test".into()];
|
||||
c.vhosts.push(exact);
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.select_on("api.example.test", "/", local, false)
|
||||
.unwrap()
|
||||
.id,
|
||||
"exact/main"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.select_on("www.example.test", "/", local, false)
|
||||
.unwrap()
|
||||
.id,
|
||||
"wildcard/main"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn logging_vhost_resolution_reuses_router_host_precedence() {
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.vhosts[0].id = "wildcard".into();
|
||||
config.vhosts[0].frontend.hosts = vec!["*.example.test".into()];
|
||||
let mut exact = config.vhosts[0].clone();
|
||||
exact.id = "exact".into();
|
||||
exact.frontend.hosts = vec!["api.example.test".into()];
|
||||
config.vhosts.push(exact);
|
||||
let snapshot = compile(config, 1, &policy()).unwrap();
|
||||
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
||||
|
||||
let routed = snapshot
|
||||
.resolve_logging_vhost(
|
||||
local,
|
||||
false,
|
||||
Some("wildcard"),
|
||||
Some("api.example.test"),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(routed.vhost, "wildcard");
|
||||
assert_eq!(routed.matched_by, "router");
|
||||
|
||||
let exact = snapshot
|
||||
.resolve_logging_vhost(local, false, None, Some("API.EXAMPLE.TEST:8080"), None, None)
|
||||
.unwrap();
|
||||
assert_eq!(exact.vhost, "exact");
|
||||
assert_eq!(exact.matched_by, "authority");
|
||||
|
||||
let wildcard = snapshot
|
||||
.resolve_logging_vhost(local, false, None, None, Some("www.example.test:8080"), None)
|
||||
.unwrap();
|
||||
assert_eq!(wildcard.vhost, "wildcard");
|
||||
assert_eq!(wildcard.matched_by, "host");
|
||||
|
||||
let sni = snapshot
|
||||
.resolve_logging_vhost(local, false, None, None, None, Some("api.example.test"))
|
||||
.unwrap();
|
||||
assert_eq!(sni.vhost, "exact");
|
||||
assert_eq!(sni.matched_by, "sni");
|
||||
|
||||
assert!(snapshot
|
||||
.resolve_logging_vhost(local, false, None, None, None, Some("unknown.test"))
|
||||
.is_none());
|
||||
assert!(snapshot
|
||||
.resolve_logging_vhost(local, false, None, None, None, None)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reusable_acl_profile_reference_is_compiled_and_unknown_reference_fails() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.acl_profiles = serde_json::from_value(serde_json::json!([{
|
||||
"id":"office", "name":"Office",
|
||||
"acl":{"default":"deny","rules":[{"network":"10.0.0.0/8","action":"allow"}]},
|
||||
"country_access":{"mode":"block","countries":["RU"]}
|
||||
}]))
|
||||
.unwrap();
|
||||
c.vhosts[0].acl_profile_id = Some("office".into());
|
||||
let snapshot = compile(c.clone(), 1, &policy()).unwrap();
|
||||
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
||||
let frontend = snapshot.frontend("localhost", local, false).unwrap();
|
||||
assert_eq!(frontend.acl_profile_id.as_deref(), Some("office"));
|
||||
assert!(frontend
|
||||
.profile_acl
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.allows("10.2.3.4".parse().unwrap()));
|
||||
assert!(!frontend
|
||||
.profile_acl
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.allows("192.0.2.1".parse().unwrap()));
|
||||
assert!(!frontend.profile_country_access.allows(Some("RU")));
|
||||
|
||||
c.vhosts[0].acl_profile_id = Some("missing".into());
|
||||
assert!(compile(c, 2, &policy()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc8441_is_advertised_only_on_opted_in_h2_capable_listener() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].websockets = true;
|
||||
c.vhosts[0].websocket_rfc8441 = true;
|
||||
let error = compile(c.clone(), 1, &policy()).unwrap_err().to_string();
|
||||
assert!(error.contains("HTTP/2-capable listener"), "{error}");
|
||||
|
||||
c.listeners.endpoints[0].binding.h2c = true;
|
||||
let snapshot = compile(c.clone(), 2, &policy()).unwrap();
|
||||
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
||||
assert!(snapshot.websocket_rfc8441_on(local, false));
|
||||
assert!(!snapshot.websocket_rfc8441_on(local, true));
|
||||
|
||||
c.vhosts[0].websocket_rfc8441 = false;
|
||||
let snapshot = compile(c, 3, &policy()).unwrap();
|
||||
assert!(!snapshot.websocket_rfc8441_on(local, false));
|
||||
}
|
||||
#[test]
|
||||
fn force_ssl_requires_plain_http_and_chooses_https_target() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].force_ssl = true;
|
||||
let error = compile(c.clone(), 1, &policy()).unwrap_err().to_string();
|
||||
assert!(
|
||||
error.contains("at least one selected TLS listener"),
|
||||
"{error}"
|
||||
);
|
||||
|
||||
c.listeners.endpoints.push(crate::settings::NamedListener {
|
||||
id: "https-a".into(),
|
||||
name: "HTTPS A".into(),
|
||||
scope: crate::settings::ListenerScope::Internal,
|
||||
enabled: true,
|
||||
binding: crate::vhost::Listener {
|
||||
address: "127.0.0.1:8443".parse().unwrap(),
|
||||
tls: true,
|
||||
http2: true,
|
||||
http2_settings: Some(crate::settings::Http2Connections::default()),
|
||||
h2c: false,
|
||||
http3: None,
|
||||
},
|
||||
additional_addresses: vec![],
|
||||
});
|
||||
c.listeners.endpoints.push(crate::settings::NamedListener {
|
||||
id: "https-b".into(),
|
||||
name: "HTTPS B".into(),
|
||||
scope: crate::settings::ListenerScope::Internal,
|
||||
enabled: true,
|
||||
binding: crate::vhost::Listener {
|
||||
address: "127.0.0.1:9443".parse().unwrap(),
|
||||
tls: true,
|
||||
http2: true,
|
||||
http2_settings: Some(crate::settings::Http2Connections::default()),
|
||||
h2c: false,
|
||||
http3: None,
|
||||
},
|
||||
additional_addresses: vec![],
|
||||
});
|
||||
c.vhosts[0].frontend.listener_ids = vec!["http".into(), "https-a".into(), "https-b".into()];
|
||||
let error = compile(c.clone(), 2, &policy()).unwrap_err().to_string();
|
||||
assert!(error.contains("cannot choose an HTTPS target"), "{error}");
|
||||
|
||||
c.listeners
|
||||
.endpoints
|
||||
.iter_mut()
|
||||
.find(|item| item.id == "https-b")
|
||||
.unwrap()
|
||||
.binding
|
||||
.address = "127.0.0.1:443".parse().unwrap();
|
||||
let snapshot = compile(c.clone(), 3, &policy()).unwrap();
|
||||
let frontend = snapshot
|
||||
.frontend("localhost", "127.0.0.1:8080".parse().unwrap(), false)
|
||||
.unwrap();
|
||||
assert_eq!(frontend.https_redirect_port, Some(443));
|
||||
|
||||
c.vhosts[0].frontend.listener_ids = vec!["https-a".into(), "https-b".into()];
|
||||
let error = compile(c, 4, &policy()).unwrap_err().to_string();
|
||||
assert!(error.contains("plain HTTP listener"), "{error}");
|
||||
}
|
||||
#[test]
|
||||
fn backend_tls_server_name_keeps_network_origin_and_changes_verified_request_name() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into();
|
||||
c.vhosts[0].backends[0].allow_private = true;
|
||||
c.vhosts[0].backends[0].tls_server_name = Some("Backend.Unitra.Internal.".into());
|
||||
let s = compile(c, 1, &policy()).unwrap();
|
||||
let backend = &s.pools[0].members[0].backend;
|
||||
assert_eq!(backend.origin, "https://10.87.20.2:443");
|
||||
assert_eq!(
|
||||
backend.request_origin,
|
||||
"https://backend.unitra.internal:443"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn tls_server_name_is_https_dns_only() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].backends[0].tls_server_name = Some("backend.internal".into());
|
||||
assert!(compile(c.clone(), 1, &policy()).is_err());
|
||||
c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into();
|
||||
c.vhosts[0].backends[0].tls_server_name = Some("10.87.20.2".into());
|
||||
assert!(compile(c, 1, &policy()).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn backend_tls_verification_defaults_off_and_can_be_enabled_explicitly() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
assert!(!c.vhosts[0].backends[0].tls_verify);
|
||||
assert!(compile(c.clone(), 1, &policy()).is_ok());
|
||||
|
||||
c.vhosts[0].backends[0].url = "https://10.87.20.2:443".into();
|
||||
c.vhosts[0].backends[0].allow_private = true;
|
||||
c.vhosts[0].backends[0].tls_ca_pem = Some("not a certificate".into());
|
||||
assert!(compile(c.clone(), 1, &policy()).is_ok());
|
||||
|
||||
c.vhosts[0].backends[0].tls_verify = true;
|
||||
assert!(compile(c, 1, &policy()).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn redirect_hosts_share_listener_routing_without_opening_vhost_routes() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.redirects = serde_json::from_value(serde_json::json!([{
|
||||
"id":"legacy","name":"Legacy","frontend":{"hosts":["old.localhost"],"listener_ids":["http"]},
|
||||
"rules":[{"id":"root","target":"https://new.localhost/","status_code":308}]
|
||||
}])).unwrap();
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
||||
let (_host, rule) = snapshot
|
||||
.redirect_on("old.localhost", "/a", local, false)
|
||||
.unwrap();
|
||||
assert_eq!(rule.id, "root");
|
||||
assert!(snapshot
|
||||
.select_on("old.localhost", "/a", local, false)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_locations_override_but_main_vhost_remains_fallback() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
// The demo document still contains the pre-0.16 synthetic root route.
|
||||
// Validation must normalize it away and keep the main backend internally.
|
||||
let mut custom = c.vhosts[0].routes[0].clone();
|
||||
custom.id = "test".into();
|
||||
custom.path_prefix = "/test".into();
|
||||
custom.response = Some(crate::vhost::LocationResponse {
|
||||
status: 200,
|
||||
content_type: "text/plain".into(),
|
||||
body: "ok".into(),
|
||||
});
|
||||
c.vhosts[0].routes.push(custom);
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/test").unwrap().id,
|
||||
"demo/test"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/test/a").unwrap().id,
|
||||
"demo/test"
|
||||
);
|
||||
assert_eq!(snapshot.select("localhost", "/").unwrap().id, "demo/main");
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/abc").unwrap().id,
|
||||
"demo/main"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/static/a.js").unwrap().id,
|
||||
"demo/main"
|
||||
);
|
||||
assert!(snapshot.envelope.config.vhosts[0]
|
||||
.routes
|
||||
.iter()
|
||||
.all(|route| !route.is_legacy_main_fallback()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deliberate_root_custom_location_can_override_main_fallback() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
let root = &mut c.vhosts[0].routes[0];
|
||||
root.response = Some(crate::vhost::LocationResponse {
|
||||
status: 204,
|
||||
content_type: "text/plain".into(),
|
||||
body: String::new(),
|
||||
});
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/anything").unwrap().id,
|
||||
"demo/root"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_admin_listener_backend_is_marked_for_guarded_control_plane_use() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.administration.listen = Some("0.0.0.0:9090".parse().unwrap());
|
||||
c.administration.tls = true;
|
||||
c.vhosts[0].backends[0].url = "https://127.0.0.1:9090".into();
|
||||
c.vhosts[0].backends[0].allow_private = true;
|
||||
c.vhosts[0].backends[0].accept_local_listener_risk = true;
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
assert!(snapshot.pools[0].members[0].backend.targets_admin_listener);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backend_diagnostic_flags_compile_to_frontend() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].backend_header = true;
|
||||
c.vhosts[0].backend_timing_header = true;
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
||||
let frontend = snapshot.frontend("localhost", local, false).unwrap();
|
||||
assert!(frontend.backend_header);
|
||||
assert!(frontend.backend_timing_header);
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn path_boundaries() {
|
||||
assert!(prefix_matches("/api", "/api"));
|
||||
assert!(prefix_matches("/api", "/api/users"));
|
||||
assert!(!prefix_matches("/api", "/apix"));
|
||||
assert!(prefix_matches("/", "/anything"));
|
||||
}
|
||||
#[test]
|
||||
fn invalid_per_backend_health_policy_rejected_without_global_override() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].backends[0].health_check = Some(crate::balance::HealthCheck {
|
||||
mode: crate::balance::HealthMode::Http,
|
||||
interval_ms: 300,
|
||||
timeout_ms: 400,
|
||||
..Default::default()
|
||||
});
|
||||
assert!(compile(c.clone(), 1, &policy()).is_err());
|
||||
c.vhosts[0].backends[0].health_check.as_mut().unwrap().timeout_ms = 200;
|
||||
assert!(compile(c, 1, &policy()).is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn private_requires_opt_in_and_metadata_always_denied() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
assert!(compile(c.clone(), 1, &policy()).is_ok());
|
||||
c.vhosts[0].backends[0].allow_private = false;
|
||||
assert!(compile(c.clone(), 1, &policy()).is_err());
|
||||
c.vhosts[0].backends[0].allow_private = true;
|
||||
for target in [
|
||||
"http://169.254.169.254",
|
||||
"http://100.100.100.200",
|
||||
"http://0.0.0.0",
|
||||
"http://127.0.0.1:9090",
|
||||
"http://[::ffff:169.254.169.254]",
|
||||
"http://[fe80::1]",
|
||||
"http://user:pass@127.0.0.1:9000",
|
||||
] {
|
||||
c.vhosts[0].backends[0].url = target.into();
|
||||
assert!(compile(c.clone(), 1, &policy()).is_err(), "{target}");
|
||||
}
|
||||
c.vhosts[0].backends[0].url = "http://127.0.0.1:9090".into();
|
||||
c.vhosts[0].backends[0].accept_local_listener_risk = true;
|
||||
assert!(compile(c.clone(), 1, &policy()).is_ok());
|
||||
c.vhosts[0].backends[0].allow_private = false;
|
||||
assert!(compile(c, 1, &policy()).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn longest_prefix_and_aliases() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
let mut specific = c.vhosts[0].routes[0].clone();
|
||||
specific.id = "api".into();
|
||||
specific.path_prefix = "/api".into();
|
||||
c.vhosts[0].routes.push(specific);
|
||||
let s = compile(c, 7, &policy()).unwrap();
|
||||
assert_eq!(s.select("localhost", "/api/x").unwrap().id, "demo/api");
|
||||
assert_eq!(s.select("demo.localhost", "/apix").unwrap().id, "demo/main");
|
||||
assert!(s.select("unknown", "/").is_none());
|
||||
assert_eq!(s.envelope.revision, 7);
|
||||
}
|
||||
#[test]
|
||||
fn nginx_style_location_precedence_exact_priority_regex_prefix() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].routes[0].id = "root".into();
|
||||
|
||||
let mut prefix = c.vhosts[0].routes[0].clone();
|
||||
prefix.id = "api-prefix".into();
|
||||
prefix.path_prefix = "/api".into();
|
||||
|
||||
let mut regex = c.vhosts[0].routes[0].clone();
|
||||
regex.id = "api-regex".into();
|
||||
regex.match_type = crate::vhost::PathMatch::Regex;
|
||||
regex.path_prefix = r"^/api/v[0-9]+/".into();
|
||||
|
||||
let mut exact = c.vhosts[0].routes[0].clone();
|
||||
exact.id = "api-exact".into();
|
||||
exact.match_type = crate::vhost::PathMatch::Exact;
|
||||
exact.path_prefix = "/api/v1/fixed".into();
|
||||
|
||||
let mut trailing_exact = c.vhosts[0].routes[0].clone();
|
||||
trailing_exact.id = "trailing-exact".into();
|
||||
trailing_exact.match_type = crate::vhost::PathMatch::Exact;
|
||||
trailing_exact.path_prefix = "/trailing/".into();
|
||||
|
||||
c.vhosts[0]
|
||||
.routes
|
||||
.extend([prefix.clone(), regex, exact, trailing_exact]);
|
||||
let snapshot = compile(c.clone(), 1, &policy()).unwrap();
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/api/v1/fixed").unwrap().id,
|
||||
"demo/api-exact"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/api/v2/users").unwrap().id,
|
||||
"demo/api-regex"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/api/plain").unwrap().id,
|
||||
"demo/api-prefix"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/trailing/").unwrap().id,
|
||||
"demo/trailing-exact"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/trailing").unwrap().id,
|
||||
"demo/main"
|
||||
);
|
||||
|
||||
c.vhosts[0].routes.retain(|route| route.id != "api-prefix");
|
||||
prefix.match_type = crate::vhost::PathMatch::PrefixPriority;
|
||||
prefix.id = "api-priority".into();
|
||||
c.vhosts[0].routes.push(prefix);
|
||||
let snapshot = compile(c, 2, &policy()).unwrap();
|
||||
assert_eq!(
|
||||
snapshot.select("localhost", "/api/v2/users").unwrap().id,
|
||||
"demo/api-priority"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn location_rewrites_are_normalized_and_regex_captures_expand() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
let route = &mut c.vhosts[0].routes[0];
|
||||
route.match_type = crate::vhost::PathMatch::Regex;
|
||||
route.path_prefix = r"^/api/v([0-9]+)(/.*)$".into();
|
||||
route.rewrite = crate::vhost::PathRewrite {
|
||||
mode: crate::vhost::RewriteMode::Regex,
|
||||
value: "/service/${1}${2}".into(),
|
||||
};
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
let selected = snapshot.select("localhost", "/api/v42/items").unwrap();
|
||||
assert_eq!(
|
||||
selected
|
||||
.rewritten_path("/api/v42/items")
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("/service/42/items")
|
||||
);
|
||||
assert_eq!(
|
||||
selected
|
||||
.rewritten_path("/api/v42/Ubuntu 14.04 LTS")
|
||||
.unwrap()
|
||||
.as_deref(),
|
||||
Some("/service/42/Ubuntu 14.04 LTS")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn custom_location_response_is_validated() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].routes[0].response = Some(crate::vhost::LocationResponse {
|
||||
status: 200,
|
||||
content_type: "application/json".into(),
|
||||
body: r#"{"ok":true}"#.into(),
|
||||
});
|
||||
assert!(compile(c.clone(), 1, &policy()).is_ok());
|
||||
c.vhosts[0].routes[0].response.as_mut().unwrap().status = 199;
|
||||
assert!(compile(c.clone(), 1, &policy()).is_err());
|
||||
c.vhosts[0].routes[0].response.as_mut().unwrap().status = 200;
|
||||
c.vhosts[0].routes[0]
|
||||
.response
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.content_type = "text/plain\r\nx-bad: 1".into();
|
||||
assert!(compile(c, 1, &policy()).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn local_response_hosts_reserve_frontends_and_do_not_require_backends() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.not_found_hosts = serde_json::from_value(serde_json::json!([{
|
||||
"id":"missing",
|
||||
"name":"Missing site",
|
||||
"frontend":{"hosts":["missing.localhost"],"listener_ids":["http"]},
|
||||
"page":{"kind":"html","status":404,"html":"<h1>Not found</h1>"}
|
||||
}]))
|
||||
.unwrap();
|
||||
let snapshot = compile(c.clone(), 1, &policy()).unwrap();
|
||||
let local: SocketAddr = "127.0.0.1:8080".parse().unwrap();
|
||||
let host = snapshot
|
||||
.not_found_host_on("missing.localhost", local, false)
|
||||
.unwrap();
|
||||
assert_eq!(host.id, "missing");
|
||||
assert_eq!(host.page.status, Some(404));
|
||||
|
||||
c.not_found_hosts[0].frontend.hosts = vec!["localhost".into()];
|
||||
assert!(compile(c, 2, &policy()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflicts_are_rejected() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
let mut duplicate = c.vhosts[0].routes[0].clone();
|
||||
duplicate.id = "other".into();
|
||||
c.vhosts[0].routes.push(duplicate);
|
||||
assert!(compile(c, 0, &policy()).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn tls12_only_vhost_override_is_valid_without_http3_listener() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].tls_policy = Some(crate::tls::ProtocolPolicy {
|
||||
tls12: true,
|
||||
tls13: false,
|
||||
cipher_suites: vec![],
|
||||
});
|
||||
assert!(compile(c, 1, &policy()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisted_remote_administration_requires_tls() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.administration.listen = Some("192.0.2.10:9090".parse().unwrap());
|
||||
assert!(compile(c.clone(), 1, &policy()).is_err());
|
||||
c.administration.tls = true;
|
||||
assert!(compile(c, 1, &policy()).is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn legacy_startup_remote_administration_is_left_to_cli_risk_gate() {
|
||||
let mut p = policy();
|
||||
p.admin_addr = "0.0.0.0:9090".parse().unwrap();
|
||||
let c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
assert!(c.administration.listen.is_none());
|
||||
assert!(compile(c, 1, &p).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn www_redirect_claims_companion_hostname_and_keeps_first_host_canonical() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].frontend.hosts = vec!["example.test".into()];
|
||||
c.vhosts[0].www_redirect = true;
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
assert!(snapshot.select("www.example.test", "/path").is_some());
|
||||
let frontend = snapshot
|
||||
.frontend("www.example.test", "127.0.0.1:8080".parse().unwrap(), false)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
frontend.www_redirect.as_ref(),
|
||||
Some(&("www.example.test".into(), "example.test".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_domains_share_vhost_and_selected_canonical_redirect() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].frontend.hosts = vec![
|
||||
"suba.local".into(), "subb.local".into(), "site.test".into(),
|
||||
];
|
||||
c.vhosts[0].www_redirect = true;
|
||||
c.vhosts[0].www_redirect_target = Some("site.test".into());
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
for hostname in ["suba.local", "subb.local", "site.test", "www.site.test"] {
|
||||
assert!(snapshot.select(hostname, "/same").is_some(), "{hostname}");
|
||||
}
|
||||
for hostname in ["suba.local", "subb.local", "www.site.test"] {
|
||||
let frontend = snapshot.frontend(hostname, "127.0.0.1:8080".parse().unwrap(), false).unwrap();
|
||||
assert_eq!(frontend.www_redirect.as_ref(),
|
||||
Some(&("www.site.test".into(), "site.test".into())));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implicit_www_target_is_claimed_and_other_domain_stays_independent() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].frontend.hosts = vec!["suba.local".into(), "subb.local".into()];
|
||||
c.vhosts[0].www_redirect = true;
|
||||
c.vhosts[0].www_redirect_target = Some("www.subb.local".into());
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
for host in ["suba.local", "subb.local", "www.subb.local"] {
|
||||
assert!(snapshot.select(host, "/path").is_some(), "{host}");
|
||||
}
|
||||
assert!(snapshot.select("www.suba.local", "/path").is_none());
|
||||
let frontend = snapshot.frontend("subb.local", "127.0.0.1:8080".parse().unwrap(), false).unwrap();
|
||||
assert_eq!(frontend.www_redirect.as_ref(),
|
||||
Some(&(String::from("subb.local"), String::from("www.subb.local"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_www_redirect_target_must_belong_to_vhost() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].frontend.hosts = vec!["suba.local".into()];
|
||||
c.vhosts[0].www_redirect = true;
|
||||
c.vhosts[0].www_redirect_target = Some("elsewhere.local".into());
|
||||
assert!(compile(c, 1, &policy()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_health_check_defaults_host_to_first_exact_vhost_dns_name() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].frontend.hosts = vec!["app.example.test".into()];
|
||||
c.vhosts[0].load_balancing.health_check.mode = crate::balance::HealthMode::Http;
|
||||
c.vhosts[0].load_balancing.health_check.host = None;
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
assert_eq!(
|
||||
snapshot.pools[0].settings.health_check.host.as_deref(),
|
||||
Some("app.example.test")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_member_http_health_inherits_vhost_dns_even_when_global_health_is_tcp() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].frontend.hosts = vec!["app.example.test".into()];
|
||||
assert_eq!(c.vhosts[0].load_balancing.health_check.mode, crate::balance::HealthMode::Tcp);
|
||||
c.vhosts[0].backends[0].health_check = Some(crate::balance::HealthCheck {
|
||||
mode: crate::balance::HealthMode::Http, path: "/healthz".into(), ..Default::default()
|
||||
});
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
let pool = &snapshot.pools[0];
|
||||
assert_eq!(pool.settings.health_for(&pool.members[0].definition).host.as_deref(), Some("app.example.test"));
|
||||
}
|
||||
#[test]
|
||||
fn explicit_http_health_check_host_override_wins_over_vhost_hostname() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].frontend.hosts = vec!["app.example.test".into()];
|
||||
c.vhosts[0].load_balancing.health_check.mode = crate::balance::HealthMode::Http;
|
||||
c.vhosts[0].load_balancing.health_check.host = Some("health.example.test".into());
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
assert_eq!(
|
||||
snapshot.pools[0].settings.health_check.host.as_deref(),
|
||||
Some("health.example.test")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_location_redirect_supports_separate_capture_regex_and_query() {
|
||||
let mut c = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
c.vhosts[0].routes = vec![crate::vhost::PathRoute {
|
||||
id: "archives".into(),
|
||||
path_prefix: r"\.(?:tar\.gz|zip?)$".into(),
|
||||
match_type: crate::vhost::PathMatch::RegexInsensitive,
|
||||
response: None,
|
||||
redirect: Some(crate::vhost::LocationRedirect {
|
||||
status: 301,
|
||||
target: "https://storage.example.test/o/$1".into(),
|
||||
rewrite_pattern: Some(r"^/(.*)$".into()),
|
||||
preserve_query: true,
|
||||
}),
|
||||
..Default::default()
|
||||
}];
|
||||
let snapshot = compile(c, 1, &policy()).unwrap();
|
||||
let route = snapshot.select("localhost", "/repo/file.tar.gz").unwrap();
|
||||
let uri: http::Uri = "/repo/file.tar.gz?download=1".parse().unwrap();
|
||||
let location = route
|
||||
.redirect_location("/repo/file.tar.gz", &uri)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
location,
|
||||
"https://storage.example.test/o/repo/file.tar.gz?download=1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_php_fpm_profile_is_reused_and_detach_restores_local_configuration() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join("index.php"), b"<?php echo 'ok';").unwrap();
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.php_fpm_profiles.push(crate::web_server::PhpFpmProfile {
|
||||
id: "php-default".into(),
|
||||
name: "Default PHP pool".into(),
|
||||
php: crate::web_server::Php {
|
||||
enabled: true,
|
||||
endpoint: "tcp:127.0.0.1:9000".into(),
|
||||
..Default::default()
|
||||
},
|
||||
});
|
||||
let service = crate::web_server::Service {
|
||||
id: "site-one".into(),
|
||||
name: "Site One".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php_profile_id: Some("php-default".into()),
|
||||
// Standalone settings remain distinct and disabled.
|
||||
php: crate::web_server::Php::default(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut second = service.clone();
|
||||
second.id = "site-two".into();
|
||||
second.name = "Site Two".into();
|
||||
config.web_services.extend([service, second]);
|
||||
config.vhosts[0].source = crate::vhost::Source::WebServer {
|
||||
web_service_id: "site-one".into(),
|
||||
};
|
||||
config.vhosts[0].routes.clear();
|
||||
config.vhosts[0].backends.clear();
|
||||
|
||||
let snapshot = compile(config, 1, &policy()).unwrap();
|
||||
for id in ["site-one", "site-two"] {
|
||||
let compiled = snapshot.web_php_services.get(id).unwrap();
|
||||
assert!(compiled.php.is_some(), "{id} must use the shared FPM profile");
|
||||
}
|
||||
assert_eq!(snapshot.envelope.config.php_fpm_profiles.len(), 1);
|
||||
assert!(!snapshot.envelope.config.web_services[0].php.enabled);
|
||||
assert!(!snapshot.envelope.config.web_services[1].php.enabled);
|
||||
|
||||
let serialized = serde_json::to_string(&snapshot.envelope.config).unwrap();
|
||||
let mut detached: Config = serde_json::from_str(&serialized).unwrap();
|
||||
detached.web_services[0].php_profile_id = None;
|
||||
let snapshot = compile(detached, 2, &policy()).unwrap();
|
||||
assert!(snapshot.web_php_services.get("site-one").unwrap().php.is_none());
|
||||
assert!(snapshot.web_php_services.get("site-two").unwrap().php.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_php_fpm_profile_reference_and_duplicate_ids_are_rejected() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
|
||||
config.web_services.push(crate::web_server::Service {
|
||||
id: "site".into(),
|
||||
name: "Site".into(),
|
||||
document_root: tmp.path().to_path_buf(),
|
||||
php_profile_id: Some("missing".into()),
|
||||
..Default::default()
|
||||
});
|
||||
let err = compile(config.clone(), 1, &policy()).unwrap_err().to_string();
|
||||
assert!(err.contains("unknown PHP-FPM profile"), "{err}");
|
||||
config.web_services[0].php_profile_id = None;
|
||||
let profile = crate::web_server::PhpFpmProfile {
|
||||
id: "duplicate".into(),
|
||||
name: "Duplicate".into(),
|
||||
php: Default::default(),
|
||||
};
|
||||
config.php_fpm_profiles = vec![profile.clone(), profile];
|
||||
let err = compile(config, 1, &policy()).unwrap_err().to_string();
|
||||
assert!(err.contains("duplicate PHP-FPM profile"), "{err}");
|
||||
}
|
||||
}
|
||||
+13
-9
@@ -29,8 +29,10 @@ struct V4Range {
|
||||
}
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct V6Range {
|
||||
start: u128,
|
||||
end: u128,
|
||||
// Network byte order preserves the numeric ordering of IPv6 addresses
|
||||
// without the 16-byte alignment/padding of u128 fields.
|
||||
start: [u8; 16],
|
||||
end: [u8; 16],
|
||||
country: [u8; 2],
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -48,7 +50,7 @@ impl Default for DatabaseFormat {
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Backend {
|
||||
Csv { ipv4: Vec<V4Range>, ipv6: Vec<V6Range> },
|
||||
Csv { ipv4: Box<[V4Range]>, ipv6: Box<[V6Range]> },
|
||||
Mmdb(mmdb::Database),
|
||||
}
|
||||
|
||||
@@ -66,7 +68,7 @@ pub struct View {
|
||||
}
|
||||
impl Database {
|
||||
pub fn empty() -> Self {
|
||||
Self { backend: Backend::Csv { ipv4: Vec::new(), ipv6: Vec::new() } }
|
||||
Self { backend: Backend::Csv { ipv4: Box::new([]), ipv6: Box::new([]) } }
|
||||
}
|
||||
pub fn load(directory: &Path) -> Result<Self> {
|
||||
if directory.join("country.mmdb").is_file() {
|
||||
@@ -81,7 +83,9 @@ impl Database {
|
||||
let ipv4 = parse_v4(&directory.join("user-country-ipv4.csv"))?;
|
||||
let ipv6 = parse_v6(&directory.join("user-country-ipv6.csv"))?;
|
||||
ensure!(!ipv4.is_empty() && !ipv6.is_empty(), "GeoIP country database is empty");
|
||||
Ok(Self { backend: Backend::Csv { ipv4, ipv6 } })
|
||||
// Convert to exactly sized immutable slices: the CSV parser may have
|
||||
// doubled Vec capacity many times during loading.
|
||||
Ok(Self { backend: Backend::Csv { ipv4: ipv4.into_boxed_slice(), ipv6: ipv6.into_boxed_slice() } })
|
||||
}
|
||||
DatabaseFormat::Mmdb => Ok(Self {
|
||||
backend: Backend::Mmdb(mmdb::Database::load(&directory.join("country.mmdb"), MAX_FILE_BYTES)?),
|
||||
@@ -118,7 +122,7 @@ impl Database {
|
||||
Backend::Csv { ipv4, ipv6 } => {
|
||||
let code = match ip {
|
||||
IpAddr::V4(ip) => find_v4(ipv4, u32::from(ip)),
|
||||
IpAddr::V6(ip) => find_v6(ipv6, u128::from(ip)),
|
||||
IpAddr::V6(ip) => find_v6(ipv6, u128::from(ip).to_be_bytes()),
|
||||
}?;
|
||||
Some(String::from_utf8_lossy(&code).into_owned())
|
||||
}
|
||||
@@ -227,8 +231,8 @@ fn parse_v6(path: &Path) -> Result<Vec<V6Range>> {
|
||||
previous_end = Some(end);
|
||||
if let Some(country) = normalize_country_code(country) {
|
||||
result.push(V6Range {
|
||||
start,
|
||||
end,
|
||||
start: start.to_be_bytes(),
|
||||
end: end.to_be_bytes(),
|
||||
country,
|
||||
});
|
||||
ensure!(result.len() <= MAX_RANGES, "too many GeoIP IPv6 ranges");
|
||||
@@ -243,7 +247,7 @@ fn find_v4(ranges: &[V4Range], ip: u32) -> Option<[u8; 2]> {
|
||||
let item = ranges.get(index)?;
|
||||
(ip <= item.end).then_some(item.country)
|
||||
}
|
||||
fn find_v6(ranges: &[V6Range], ip: u128) -> Option<[u8; 2]> {
|
||||
fn find_v6(ranges: &[V6Range], ip: [u8; 16]) -> Option<[u8; 2]> {
|
||||
let index = ranges
|
||||
.partition_point(|item| item.start <= ip)
|
||||
.checked_sub(1)?;
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn compact_ipv6_ranges_preserve_numeric_order() {
|
||||
assert_eq!(std::mem::size_of::<V6Range>(), 34);
|
||||
assert_eq!(std::mem::size_of::<V4Range>(), 12);
|
||||
let ranges = [
|
||||
V6Range { start: 0u128.to_be_bytes(), end: 127u128.to_be_bytes(), country: *b"PL" },
|
||||
V6Range { start: 256u128.to_be_bytes(), end: 65535u128.to_be_bytes(), country: *b"DE" },
|
||||
V6Range { start: (1u128 << 120).to_be_bytes(), end: u128::MAX.to_be_bytes(), country: *b"US" },
|
||||
];
|
||||
for (address, expected) in [
|
||||
(0, Some(*b"PL")), (127, Some(*b"PL")), (128, None),
|
||||
(255, None), (256, Some(*b"DE")), (65535, Some(*b"DE")),
|
||||
(65536, None), (1u128 << 120, Some(*b"US")), (u128::MAX, Some(*b"US")),
|
||||
] {
|
||||
assert_eq!(find_v6(&ranges, address.to_be_bytes()), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_lookup_handles_gaps() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
|
||||
+36
-4
@@ -8,26 +8,58 @@ use std::collections::BTreeMap;
|
||||
|
||||
pub const MAX_HTML_BYTES: usize = 32 * 1024;
|
||||
pub const ERROR_CODES: [u16; 11] = [400, 401, 403, 404, 408, 413, 429, 500, 502, 503, 504];
|
||||
pub const BUILTIN_ERROR_CODES: [u16; 6] = [401, 403, 404, 429, 503, 504];
|
||||
pub const BUILTIN_ERROR_CODES: [u16; 12] = [400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503, 504];
|
||||
|
||||
/// Built-in operational gateway pages. They are deliberately not marked as
|
||||
/// `PageServed`, so an administrator-configured vhost/global error page can
|
||||
/// still replace them in the normal response policy pass.
|
||||
pub fn builtin_gateway_error(status: StatusCode) -> Option<&'static str> {
|
||||
match status {
|
||||
StatusCode::BAD_REQUEST => Some(include_str!("admin/web/error_400.html")),
|
||||
StatusCode::UNAUTHORIZED => Some(include_str!("admin/web/error_401.html")),
|
||||
StatusCode::FORBIDDEN => Some(include_str!("admin/web/error_403.html")),
|
||||
StatusCode::NOT_FOUND => Some(include_str!("admin/web/error_404.html")),
|
||||
StatusCode::REQUEST_TIMEOUT => Some(include_str!("admin/web/error_408.html")),
|
||||
StatusCode::GONE => Some(include_str!("admin/web/error_410.html")),
|
||||
StatusCode::PAYLOAD_TOO_LARGE => Some(include_str!("admin/web/error_413.html")),
|
||||
StatusCode::TOO_MANY_REQUESTS => Some(include_str!("admin/web/error_429.html")),
|
||||
StatusCode::INTERNAL_SERVER_ERROR => Some(include_str!("admin/web/error_500.html")),
|
||||
StatusCode::BAD_GATEWAY => Some(include_str!("admin/web/error_502.html")),
|
||||
StatusCode::SERVICE_UNAVAILABLE => Some(include_str!("admin/web/error_503.html")),
|
||||
StatusCode::GATEWAY_TIMEOUT => Some(include_str!("admin/web/error_504.html")),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn builtin_application_page(status: StatusCode) -> String {
|
||||
if let Some(page) = builtin_gateway_error(status) { return page.to_owned(); }
|
||||
let reason = status.canonical_reason().unwrap_or("Application response");
|
||||
format!(r#"<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{code} {reason}</title><style>body{{font-family:system-ui,sans-serif;max-width:42rem;margin:12vh auto;padding:0 1.5rem;color:#1f2937}}h1{{font-size:4rem;margin:0}}p{{font-size:1.1rem;color:#4b5563}}</style></head><body><h1>{code}</h1><p>{reason}</p><p>RustProxy</p></body></html>"#, code=status.as_u16(), reason=reason)
|
||||
if status == StatusCode::OK {
|
||||
return include_str!("admin/web/status_200.html").to_owned();
|
||||
}
|
||||
if let Some(page) = builtin_gateway_error(status) {
|
||||
return page.to_owned();
|
||||
}
|
||||
// A deliberately generic status still uses the *same* visual language
|
||||
// as the packaged pages, rather than the former bright bare 200/4xx page.
|
||||
// Code and canonical reason originate from http::StatusCode, never a request.
|
||||
let reason = status.canonical_reason().unwrap_or("Response");
|
||||
format!(r#"<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{code} - {reason} - RustProxy</title>
|
||||
<style>
|
||||
body {{ margin: 12vh auto; padding: 24px; max-width: 620px; background: #131615; color: #e5e9e6; font: 15px/1.6 system-ui, sans-serif }}
|
||||
h1 {{ font-size: 24px; margin: 0 0 8px }}
|
||||
p {{ margin: 0; color: #b9c3bc }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>{code} - {reason}</h1>
|
||||
<p>RustProxy generated this response.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>"#, code = status.as_u16(), reason = reason)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
|
||||
|
||||
+15
-1
@@ -77,7 +77,21 @@ mod tests {
|
||||
assert!(builtin_gateway_error(StatusCode::GATEWAY_TIMEOUT)
|
||||
.unwrap()
|
||||
.contains("504 - Gateway timeout"));
|
||||
assert!(builtin_gateway_error(StatusCode::BAD_GATEWAY).is_none());
|
||||
for (code, label) in [
|
||||
(StatusCode::BAD_REQUEST, "400 - Bad request"),
|
||||
(StatusCode::REQUEST_TIMEOUT, "408 - Request timeout"),
|
||||
(StatusCode::GONE, "410 - Gone"),
|
||||
(StatusCode::PAYLOAD_TOO_LARGE, "413 - Payload too large"),
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "500 - Internal server error"),
|
||||
(StatusCode::BAD_GATEWAY, "502 - Bad gateway"),
|
||||
] {
|
||||
let page = builtin_gateway_error(code).unwrap();
|
||||
assert!(page.contains(label));
|
||||
assert!(page.contains("background: #131615"));
|
||||
}
|
||||
assert!(builtin_gateway_error(StatusCode::OK).is_none());
|
||||
assert!(builtin_application_page(StatusCode::OK).contains("200 - OK"));
|
||||
assert!(builtin_application_page(StatusCode::CREATED).contains("background: #131615"));
|
||||
}
|
||||
#[test]
|
||||
fn validated_bounded_pages_and_ip_detection() {
|
||||
|
||||
+1396
-4
File diff suppressed because it is too large.
Load diff
@@ -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.31.7"
|
||||
assert manifest["package"]["version"] == "0.31.8"
|
||||
assert manifest["dependencies"]["quinn"]["version"] == "0.11.12"
|
||||
h3dep = manifest["dependencies"]["h3"]
|
||||
assert h3dep["package"] == "hpx-h3" and h3dep["version"] == "=2.5.20" 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.31.7"' in lock
|
||||
assert 'name = "rust-proxy"\nversion = "0.31.8"' 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.31.7",
|
||||
"CHANGELOG.md": "## 0.31.7",
|
||||
"ROADMAP.md": "## 0.31.7",
|
||||
"AGENTS.md": "Current source version: 0.31.7.",
|
||||
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.31.7",
|
||||
"README.md": "# RustProxy 0.31.8",
|
||||
"CHANGELOG.md": "## 0.31.8",
|
||||
"ROADMAP.md": "## 0.31.8",
|
||||
"AGENTS.md": "Current source version: 0.31.8.",
|
||||
"docs/TEST_REPORT.md": "# Verification report - RustProxy 0.31.8",
|
||||
}.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"
|
||||
@@ -285,7 +285,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.31.7"', '"/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.31.8"', '"/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
|
||||
@@ -1179,7 +1179,8 @@ for memory_marker_0230 in [
|
||||
]:
|
||||
assert memory_marker_0230 in server_source or memory_marker_0230 in metrics_source, memory_marker_0230
|
||||
cache_fill_0230 = rust_source(root, 'src/cache/fill.rs')
|
||||
assert 'let body_allocation = self.data.capacity().max(self.data.len());' in cache_fill_0230
|
||||
assert 'let allocated = self.data.capacity().max(self.data.len());' in cache_fill_0230
|
||||
assert 'shared_allocated' in cache_fill_0230 and 'try_into_mut()' in cache_fill_0230
|
||||
assert 'let cost = hot_entry_cost(' in cache_fill_0230
|
||||
assert 'fn hot_entry_cost(' in cache_source
|
||||
for marker_0221 in [
|
||||
@@ -1394,7 +1395,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.31.7"' in openapi
|
||||
assert '"RuntimeInformation"' in openapi and '"version": "0.31.8"' in openapi
|
||||
|
||||
# 0.24.1 compiler and hostname-cardinality regressions.
|
||||
tls_0241 = rust_source(root, "src/tls.rs")
|
||||
@@ -1633,5 +1634,5 @@ for marker_0282 in [
|
||||
assert 'Zewnętrzne ścieżki plików' in (root / 'langs/pl_pl.json').read_text()
|
||||
assert 'setfacl -m u:rustproxy:--x /etc/ssl/' in (root / 'docs/INSTALL.md').read_text()
|
||||
|
||||
print("PASS: 0.31.7 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, external-packaging/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-v9 migrations.")
|
||||
print("PASS: 0.31.8 manifest/package, shared system-statistics/runtime metadata, Settings logging, retained H2, external-packaging/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-v9 migrations.")
|
||||
print("NOT VERIFIED: Rust parsing, types, compilation, linking or runtime behavior. Run ./dev.sh test.")
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check the 0.31.7 dependency/source migration; optionally require a resolved lock.
|
||||
"""Check the 0.31.8 dependency/source migration; optionally require a resolved lock.
|
||||
|
||||
This is not a substitute for rustc, cargo metadata or runtime tests.
|
||||
"""
|
||||
@@ -14,7 +14,7 @@ parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--require-lock', action='store_true', help='Require the post-upgrade Cargo.lock produced by cargo update')
|
||||
args = parser.parse_args()
|
||||
manifest = tomllib.loads((ROOT / 'Cargo.toml').read_text())
|
||||
assert manifest['package']['version'] == '0.31.7'
|
||||
assert manifest['package']['version'] == '0.31.8'
|
||||
assert manifest['package']['rust-version'] == '1.99'
|
||||
deps = manifest['dependencies']
|
||||
expected_requirements = {
|
||||
@@ -78,4 +78,4 @@ if missing:
|
||||
print(f'PENDING: lockfile still requires cargo update ({details})')
|
||||
else:
|
||||
print('PASS: pinned key packages found in post-upgrade lock; cargo --locked verification is still required')
|
||||
print('PASS: 0.31.7 source dependency APIs, manifest, TLS features, toolchain images and Swagger pin (static only)')
|
||||
print('PASS: 0.31.8 source dependency APIs, manifest, TLS features, toolchain images and Swagger pin (static only)')
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env node
|
||||
// Runs the real Docker VHosts dashboard module against a minimal DOM + API fixture.
|
||||
// This is a JS-only regression, not a compiled RustProxy or live agent test.
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
|
||||
const dockerSource = readFileSync('src/admin/web/docker.js', 'utf8');
|
||||
const coreSource = readFileSync('src/admin/web/app/core.js', 'utf8');
|
||||
const english = JSON.parse(readFileSync('langs/en_US.json', 'utf8')).translations;
|
||||
const polish = JSON.parse(readFileSync('langs/pl_pl.json', 'utf8')).translations;
|
||||
for (const translations of [english, polish]) {
|
||||
assert.ok(translations.dockerNoObservation);
|
||||
assert.ok(translations.dockerAgentConnectionFailed);
|
||||
assert.ok(translations.dockerUnknownPollError);
|
||||
assert.doesNotMatch(translations.dockerNoObservation.toLowerCase(), /refresh|odśwież/);
|
||||
}
|
||||
|
||||
class FakeNode {
|
||||
constructor(tag, text = '', cls = '') {
|
||||
this.tagName = tag;
|
||||
this.content = String(text ?? '');
|
||||
this.className = cls;
|
||||
this.children = [];
|
||||
this.dataset = {};
|
||||
this.attrs = {};
|
||||
this.events = {};
|
||||
this.hidden = false;
|
||||
this.open = false;
|
||||
this.classList = {
|
||||
toggle: (name, value) => {
|
||||
const parts = new Set(this.className.split(' ').filter(Boolean));
|
||||
if (value) parts.add(name); else parts.delete(name);
|
||||
this.className = [...parts].join(' ');
|
||||
},
|
||||
add: name => { this.className += ` ${name}`; },
|
||||
remove: name => { this.className = this.className.split(' ').filter(part => part !== name).join(' '); },
|
||||
};
|
||||
}
|
||||
get textContent() { return this.content + this.children.map(child => child.textContent || '').join(''); }
|
||||
set textContent(value) { this.content = String(value ?? ''); this.children = []; }
|
||||
append(...nodes) { this.children.push(...nodes.filter(Boolean)); }
|
||||
replaceChildren(...nodes) { this.children = nodes.filter(Boolean); }
|
||||
setAttribute(name, value) { this.attrs[name] = value; }
|
||||
addEventListener(name, fn) { this.events[name] = fn; }
|
||||
descendants() { return this.children.flatMap(child => [child, ...child.descendants()]); }
|
||||
querySelectorAll(selector) {
|
||||
const part = selector.split(' ').at(-1);
|
||||
if (part.startsWith('.')) return this.descendants().filter(child => child.className.split(' ').includes(part.slice(1)));
|
||||
return [];
|
||||
}
|
||||
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; }
|
||||
}
|
||||
const node = (tag, text, cls) => new FakeNode(tag, text, cls);
|
||||
const destination = node('div');
|
||||
const elements = new Map([
|
||||
['docker-discovery-list', destination],
|
||||
['overview-refresh', node('button')],
|
||||
['page-label', node('span')],
|
||||
['page-title', node('h1')],
|
||||
['page-description', node('p')]
|
||||
]);
|
||||
const agent = {
|
||||
id: 'docker1', name: 'Docker One', enabled: true,
|
||||
base_domain: 'apps.example.test', endpoint: 'https://192.0.2.2:9876',
|
||||
tls_fingerprint_sha256: 'A'.repeat(64), poll_interval_seconds: 30,
|
||||
assignments: []
|
||||
};
|
||||
const config = { docker_agents: { agents: [agent] }, listeners: { endpoints: [] }, tls: { certificates: [] } };
|
||||
const storage = new Map();
|
||||
const requests = [];
|
||||
let nextResult = { observations: [], pools: [], pool_routes: [] };
|
||||
let refreshSpec;
|
||||
let syncs = 0, manuals = 0;
|
||||
const coordinator = {
|
||||
register(id, spec) { assert.equal(id, 'docker'); refreshSpec = spec; },
|
||||
sync() { syncs++; },
|
||||
manual(id) { assert.equal(id, 'docker'); manuals++; return Promise.resolve(); },
|
||||
setPanel(name) { this.panel = name; },
|
||||
};
|
||||
const ctx = {
|
||||
el: node,
|
||||
$: name => elements.get(name) || null,
|
||||
t: name => english[name] || name,
|
||||
document: {
|
||||
activeElement: null,
|
||||
querySelectorAll(selector) {
|
||||
if (selector === '#docker-discovery-list .docker-agent-card') return destination.children;
|
||||
return [];
|
||||
}
|
||||
},
|
||||
window: { rustProxyRefresh: coordinator, rustProxySettings: {} },
|
||||
rustProxyRefresh: coordinator,
|
||||
localStorage: { getItem: key => storage.get(key) || null, setItem: (key, value) => storage.set(key, value) },
|
||||
authenticated: true, authEpoch: 1,
|
||||
currentPanel: 'docker-vhosts', envelope: { config },
|
||||
settingsDirty: new Set(), busy: false,
|
||||
api: async (path, args = {}) => { requests.push({ path, args }); return nextResult; },
|
||||
toast(message) { throw new Error(`Unexpected toast: ${message}`); },
|
||||
console,
|
||||
};
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(dockerSource, ctx, { filename: 'src/admin/web/docker.js' });
|
||||
assert(refreshSpec, 'register Docker refresh with central scheduler');
|
||||
assert.equal(refreshSpec.interval(), 5);
|
||||
ctx.window.rustProxyDocker.render();
|
||||
assert.equal(manuals, 1, 'deep-link bootstrap fetches a snapshot after configuration loads');
|
||||
assert.equal(syncs, 1, 'deep-link bootstrap syncs waiting-state scheduler');
|
||||
assert.equal(refreshSpec.active(), true, 'bootstrap retries cached status without requiring local Live view');
|
||||
assert.match(destination.textContent, /WAITING/);
|
||||
assert.match(destination.textContent, /Waiting for the first agent check/);
|
||||
assert.doesNotMatch(destination.textContent, /Last Docker agent check failed/);
|
||||
assert.doesNotMatch(destination.textContent, /Agent connection failed/);
|
||||
assert.equal(destination.querySelector('.docker-agent-error'), null, 'pending must not be styled as a failure');
|
||||
let tlsPill = destination.querySelectorAll('.pill').find(item => item.textContent === 'TLS PINNED');
|
||||
assert.ok(tlsPill);
|
||||
assert.ok(!tlsPill.className.split(' ').includes('live'), 'configured fingerprint does not prove successful handshake');
|
||||
|
||||
await ctx.window.rustProxyDocker.refresh(false);
|
||||
assert.equal(requests.at(-1).path, '/api/docker');
|
||||
assert.notEqual(requests.at(-1).args.method, 'POST', 'automatic status fetch never polls the agent');
|
||||
assert.equal(refreshSpec.active(), true, 'retry while the initial server-side poll is not complete');
|
||||
|
||||
nextResult = { observations: [{ agent_id: 'docker1', ok: false, error: 'connection refused', checked_at_unix_ms: 1, inventory: null }], pools: [], pool_routes: [] };
|
||||
await ctx.window.rustProxyDocker.refresh(false);
|
||||
assert.match(destination.textContent, /Last Docker agent check failed/);
|
||||
assert.match(destination.textContent, /connection refused/);
|
||||
assert.doesNotMatch(destination.textContent, /TLS certificate SHA-256:/, 'pinned fingerprint is configuration, not the reason for an arbitrary failure');
|
||||
assert.ok(destination.querySelector('.docker-agent-error'), 'a genuine failed observation must be visible');
|
||||
assert.equal(refreshSpec.active(), false, 'local Live-off stops automatic refresh after first status, even if it is a failure');
|
||||
|
||||
nextResult = { observations: [{ agent_id: 'docker1', ok: true, error: null, checked_at_unix_ms: Date.now(), inventory: {
|
||||
agent_version: '0.22.3', hostname: 'docker1', addresses: [], services: [], public_ip: '192.0.2.2', certificate_sha256: 'A'.repeat(64)
|
||||
} }], pools: [], pool_routes: [] };
|
||||
await ctx.window.rustProxyDocker.refresh(false);
|
||||
assert.match(destination.textContent, /CONNECTED/);
|
||||
assert.doesNotMatch(destination.textContent, /Last Docker agent check failed/);
|
||||
assert.equal(refreshSpec.active(), false, 'local Live-off remains off with a successful observation');
|
||||
tlsPill = destination.querySelectorAll('.pill').find(item => item.textContent === 'TLS PINNED');
|
||||
assert.ok(tlsPill.className.split(' ').includes('live'), 'pin may be marked verified after a successful poll');
|
||||
storage.set('rustproxy.docker.live-view', '1');
|
||||
assert.equal(refreshSpec.active(), true, 'Live view still polls normally');
|
||||
storage.delete('rustproxy.docker.live-view');
|
||||
|
||||
// Execute the real panel-navigation function with side effects disabled.
|
||||
const from = coreSource.indexOf('function setPanel(name, navigate = true) {');
|
||||
const to = coreSource.indexOf('function bytes(value)', from);
|
||||
assert(from >= 0 && to > from);
|
||||
ctx.PANEL_PATHS = { overview: '/', 'docker-vhosts': '/docker-vhosts' };
|
||||
vm.runInContext(coreSource.slice(from, to), ctx);
|
||||
ctx.currentPanel = 'overview';
|
||||
ctx.setPanel('docker-vhosts', false);
|
||||
assert.equal(manuals, 2, 'opening Docker VHosts requests a new cached snapshot even with Live view off');
|
||||
ctx.setPanel('docker-vhosts', false);
|
||||
assert.equal(manuals, 2, 'rendering the same panel does not duplicate one-shot requests');
|
||||
ctx.setPanel('overview', false);
|
||||
ctx.setPanel('docker-vhosts', false);
|
||||
assert.equal(manuals, 3, 're-entering the page reads the newest cached status');
|
||||
|
||||
ctx.currentPanel = 'overview';
|
||||
assert.equal(refreshSpec.active(), false, 'no bootstrap activity outside Docker VHosts');
|
||||
ctx.currentPanel = 'docker-vhosts';
|
||||
config.docker_agents.agents = [{ ...agent, enabled: false }];
|
||||
ctx.window.rustProxyDocker.render();
|
||||
assert.equal(refreshSpec.active(), false, 'disabled agents do not cause polling');
|
||||
assert.doesNotMatch(destination.textContent, /Last Docker agent check failed/);
|
||||
console.log('PASS: 0.31.8 Docker startup awaiting/poll failure/success, pinned TLS semantics, Live-off bootstrap and return-navigation refresh (real JS, simulated DOM/API).');
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rust-independent documentation/Reader consistency checks for the 0.31.7 release."""
|
||||
"""Rust-independent documentation/Reader consistency checks for the 0.31.8 release."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
from rust_source import rust_source
|
||||
@@ -51,7 +51,7 @@ 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.31.7")
|
||||
assert readme.startswith("# RustProxy 0.31.8")
|
||||
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
|
||||
|
||||
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
html = (ROOT / 'src/admin/web/index.html').read_text()
|
||||
assert '0.31.7' in (ROOT / "Cargo.toml").read_text()
|
||||
assert '0.31.8' in (ROOT / "Cargo.toml").read_text()
|
||||
assert '<a class="brand" href="/"' in html, 'logo must remain a true full-page navigation'
|
||||
assert 'addEventListener("click"' not in html.split('<a class="brand" href="/"',1)[1].split('</a>',1)[0]
|
||||
body = (ROOT / 'src/proxy/cache_helpers.rs').read_text()
|
||||
|
||||
@@ -168,7 +168,7 @@ reset = invalidation.index("store.key_generations.clear();", barrier)
|
||||
assert "store.key_generations.capacity() > 256" in invalidation[barrier:reset]
|
||||
matcher = invalidation.index("let affected = self.purge_matcher(&matcher);", reset)
|
||||
assert start < barrier < reset < matcher
|
||||
require("src/cache/tests/v022_cache_tests/part_02.rs", "many_distinct_selective_purges_do_not_retain_tombstones_forever")
|
||||
require("src/cache/tests/v022_cache_tests.rs", "many_distinct_selective_purges_do_not_retain_tombstones_forever")
|
||||
|
||||
# Repeated hot-path analytics labels should use borrowed lookups instead of
|
||||
# allocating a String for every request in an already populated top table.
|
||||
|
||||
@@ -76,13 +76,13 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
|
||||
raise ValueError(f"Conflicting Rust modules: {flat.relative_to(root)} and {module.relative_to(root)}")
|
||||
for path in ["src/auth/mod.rs", "src/auth/store.rs", "src/backup/snapshot.rs", "src/upstream.rs", "src/tls.rs", "src/config/internal_routing.rs", "src/proxy/websocket.rs", "src/proxy/error_fallback.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/vhost/error_fallback.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/rates.js", "src/admin/web/vhosts/health.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/session-resume.js", "src/admin/web/app/preferences.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/admin/web/drafts.js", "src/pages.rs", "tools/dev/mock_server.py", "tools/dev/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/CHANGELOG_TECHNICAL_ARCHIVE.md", "docs/ROADMAP_HISTORY.md", "tools/checks/docs_check.py", "tools/checks/web_server_contracts.py", "tools/checks/rust_source.py", "tools/checks/rust_module_contracts.py", "tools/checks/source_module_contracts.py", "scripts/runtime/acme_provider.py", "src/basic_auth.rs", "scripts/deploy/deploy.py", "scripts/deploy/deploy_core.py", "scripts/deploy/deploy_runtime.py", "scripts/migration/migrate_npm.py", "scripts/migration/migrate_npm_core.py", "scripts/migration/migrate_npm_api.py", "docs/NPM_MIGRATION.md", "docs/STREAMS.md", "install.sh", "install-debian13.sh", "update.sh", "build.rs", "scripts/build/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", "grafana/rustproxy-overview.json"]:
|
||||
if not (root / path).is_file():
|
||||
raise ValueError(f"Incomplete RustProxy 0.31.7 source tree: missing {path}")
|
||||
raise ValueError(f"Incomplete RustProxy 0.31.8 source tree: missing {path}")
|
||||
# The Reader uses compile-time include_str! for its bundled Markdown. A
|
||||
# dangling registration is a real compiler error, unlike an omitted old
|
||||
# release audit. Match both reader_doc! and the expanded include_str! form.
|
||||
registry = root / "src/admin/documentation/documents.rs"
|
||||
if not registry.is_file():
|
||||
raise ValueError("Incomplete RustProxy 0.31.7 source tree: missing src/admin/documentation/documents.rs")
|
||||
raise ValueError("Incomplete RustProxy 0.31.8 source tree: missing src/admin/documentation/documents.rs")
|
||||
embedded_docs = set(re.findall(
|
||||
r'"(docs/[A-Za-z0-9_.-]+\.md)"', registry.read_text(encoding="utf-8")
|
||||
))
|
||||
@@ -91,7 +91,7 @@ def check(root: Path, repair_known: bool = False) -> list[str]:
|
||||
raise ValueError(f"Missing embedded Reader document: {doc_path}; restore the file or remove its Reader registration")
|
||||
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.31.7 entry point; extract the complete package, not individual files.")
|
||||
raise ValueError("src/lib.rs is not the 0.31.8 entry point; extract the complete package, not individual files.")
|
||||
return messages
|
||||
|
||||
|
||||
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env python3
|
||||
"""0.31.8 source-only regression; does NOT claim Rust compilation or memory profiling."""
|
||||
from pathlib import Path
|
||||
import json
|
||||
import re
|
||||
import tomllib
|
||||
from html.parser import HTMLParser
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
def read(rel): return (ROOT / rel).read_text(encoding='utf-8')
|
||||
assert tomllib.loads(read('Cargo.toml'))['package']['version'] == '0.31.8'
|
||||
assert 'name = "rust-proxy"\nversion = "0.31.8"' in read('Cargo.lock')
|
||||
assert json.loads(read('src/admin/openapi.json'))['info']['version'] == '0.31.8'
|
||||
|
||||
pages = read('src/pages.rs')
|
||||
for code in (400, 401, 403, 404, 408, 410, 413, 429, 500, 502, 503, 504):
|
||||
file = f'src/admin/web/error_{code}.html'
|
||||
markup = read(file)
|
||||
assert markup.lstrip().startswith('<!doctype html>'), file
|
||||
assert 'background: #131615' in markup and 'max-width: 620px' in markup, file
|
||||
assert re.search(rf'<h1>{code} - [^<]+</h1>', markup), file
|
||||
assert f'include_str!("admin/web/error_{code}.html")' in pages, file
|
||||
assert 'status_200.html' in pages and '<h1>200 - OK</h1>' in read('src/admin/web/status_200.html')
|
||||
assert 'RustProxy generated this response.' in pages and 'background: #131615' in pages
|
||||
assert 'matches!(status, StatusCode::NO_CONTENT | StatusCode::RESET_CONTENT | StatusCode::NOT_MODIFIED)' in pages
|
||||
|
||||
geoip = read('src/geoip.rs')
|
||||
assert 'ipv4: Box<[V4Range]>, ipv6: Box<[V6Range]>' in geoip
|
||||
assert 'ipv4.into_boxed_slice()' in geoip and 'ipv6.into_boxed_slice()' in geoip
|
||||
assert 'start: [u8; 16]' in geoip and 'end: [u8; 16]' in geoip
|
||||
assert 'u128::from(ip).to_be_bytes()' in geoip and 'ip <= item.end' in geoip
|
||||
assert 'size_of::<V6Range>(), 34' in read('src/geoip/tests.rs')
|
||||
|
||||
cached = read('src/cache/fill.rs') + read('src/cache/lookup/tiered.rs')
|
||||
for marker in ('bytes.try_into_mut()', 'mutable.capacity()', 'candidate.shared = Some(bytes.clone())',
|
||||
'shared_allocated', 'candidate.fill_gauge.grow(allocation', 'if transformed { 0 }',
|
||||
'candidate.data.extend_from_slice(data)'):
|
||||
assert marker in cached, marker
|
||||
|
||||
q = read('src/admin/file_logs.rs') + read('src/admin/file_logs_query.rs')
|
||||
for marker in ('sort_by: Option<String>', 'sort_descending: Option<bool>', 'valid_raw_field_key(field)',
|
||||
'fn sort_file_log_records(', 'stored_log_sort_key(&record, field)',
|
||||
'file_log_field(record, field)', 'None, Some(_)', 'sort_file_log_records(&mut records'):
|
||||
assert marker in q, marker
|
||||
assert q.index('sort_file_log_records(&mut records') < q.index('let page = records')
|
||||
print('PASS: 0.31.8 versions, 13 matching HTML pages, GeoIP packed/boxed sources, cache ownership/accounting and bounded server sorting (static only).')
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env node
|
||||
// JS-only regression of the Stored Logs sorting interaction (no Rust server).
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import vm from 'node:vm';
|
||||
const read = name => fs.readFileSync(name, 'utf8');
|
||||
const ui = read('src/admin/web/file-logs.js');
|
||||
const css = read('src/admin/web/style.css');
|
||||
const api = JSON.parse(read('src/admin/openapi.json'));
|
||||
const params = api.paths['/api/file-logs/query'].get.parameters.map(param => param.name);
|
||||
assert.ok(params.includes('sort_by') && params.includes('sort_descending'));
|
||||
assert.ok(api.components.schemas.AdminPreferences.properties.file_log_sort);
|
||||
assert.ok(api.components.schemas.AdminPreferences.properties.file_log_descending);
|
||||
assert.match(ui, /params\.set\("sort_by", sortBy\)/);
|
||||
assert.match(ui, /params\.set\("sort_descending", String\(sortDescending\)\)/);
|
||||
assert.match(css, /file-log-sort-button/);
|
||||
|
||||
class FakeNode {
|
||||
constructor(tag, text = '', cls = '') { this.tag = tag; this.textContent = text; this.className = cls; this.children = []; this.dataset = {}; this.attrs = {}; this.events = {}; }
|
||||
setAttribute(name, value) { this.attrs[name] = value; }
|
||||
append(...nodes) { this.children.push(...nodes); }
|
||||
replaceChildren(...nodes) { this.children = nodes; }
|
||||
addEventListener(name, handler) { this.events[name] = handler; }
|
||||
click() { this.events.click?.(); }
|
||||
}
|
||||
const row = new FakeNode('tr');
|
||||
const sort = ui.slice(ui.indexOf(' function syncHeaders() {'), ui.indexOf(' function syncColumnPicker()'));
|
||||
assert.ok(sort.startsWith(' function syncHeaders() {') && sort.includes('file-log-sort-button'));
|
||||
const ctx = {
|
||||
el: (tag, text, cls) => new FakeNode(tag, text, cls),
|
||||
$: () => ({querySelector: () => row}),
|
||||
orderedColumns: () => ['time', 'status'],
|
||||
visibleColumns: new Set(['time', 'status']),
|
||||
specialLabels: new Map([['time', 'time'], ['status', 'status']]),
|
||||
columnLabel: id => id,
|
||||
t: key => key,
|
||||
sortBy: 'time', sortDescending: true, selectedIndex: 3, offset: 100,
|
||||
saved: 0, refreshed: 0,
|
||||
savePreferences() { this.saved++; },
|
||||
refresh() { this.refreshed++; },
|
||||
};
|
||||
// Methods capture explicit counters rather than relying on vm's `this` binding.
|
||||
ctx.savePreferences = () => { ctx.saved++; };
|
||||
ctx.refresh = reset => { if (reset) ctx.refreshed++; };
|
||||
vm.createContext(ctx);
|
||||
vm.runInContext(`${sort}\nglobalThis.draw = syncHeaders;`, ctx);
|
||||
ctx.draw();
|
||||
assert.equal(row.children.length, 2);
|
||||
assert.equal(row.children[0].attrs['aria-sort'], 'descending');
|
||||
assert.equal(row.children[1].attrs['aria-sort'], 'none');
|
||||
row.children[1].children[0].click();
|
||||
assert.equal(ctx.sortBy, 'status');
|
||||
assert.equal(ctx.sortDescending, false);
|
||||
assert.equal(ctx.offset, 0);
|
||||
assert.equal(ctx.saved, 1);
|
||||
assert.equal(ctx.refreshed, 1);
|
||||
assert.equal(row.children[1].attrs['aria-sort'], 'ascending');
|
||||
row.children[1].children[0].click();
|
||||
assert.equal(ctx.sortDescending, true);
|
||||
assert.equal(row.children[1].attrs['aria-sort'], 'descending');
|
||||
assert.equal(ctx.saved, 2);
|
||||
assert.equal(ctx.refreshed, 2);
|
||||
console.log('PASS: 0.31.8 Stored Logs headers sort by column, toggle direction, reset pagination, persist preferences and request server sorting.');
|
||||
@@ -135,7 +135,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.31.7"' in (target / "Cargo.toml").read_text(encoding="utf-8")
|
||||
assert 'version = "0.31.8"' 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()
|
||||
@@ -146,7 +146,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.31.7"
|
||||
assert manifest["to_version"] == "0.31.8"
|
||||
assert manifest["removed_obsolete_runtime_state"] is True
|
||||
assert not legacy.exists()
|
||||
assert not (backup / "legacy-data").exists()
|
||||
@@ -617,7 +617,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.31.7"' in openapi_0241
|
||||
assert '"RuntimeInformation"' in openapi_0241 and '"version": "0.31.8"' 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
|
||||
@@ -1102,7 +1102,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.31.7"', '"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.31.8"', '"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"']:
|
||||
@@ -1216,6 +1216,14 @@ def reproducible_archive_smoke(tmp: Path) -> None:
|
||||
assert "RustProxy/tools/checks/source_module_contracts.py" in names
|
||||
for name in names:
|
||||
if name.startswith("RustProxy/") and name.endswith(".rs"):
|
||||
relative = Path(name).parts[1:]
|
||||
is_test = relative[0] == "tests" or (
|
||||
relative[0] == "src" and (
|
||||
relative[-1] == "tests.rs" or relative[-1].endswith("_tests.rs")
|
||||
or "tests" in relative[1:-1]
|
||||
)
|
||||
)
|
||||
if not is_test:
|
||||
assert len(archive.read(name).decode("utf-8").splitlines()) <= 500, name
|
||||
if name.startswith("RustProxy/src/admin/web/") and name.endswith(".js"):
|
||||
assert len(archive.read(name).decode("utf-8").splitlines()) <= 500, name
|
||||
@@ -1307,7 +1315,7 @@ def main() -> None:
|
||||
source_contract_smoke()
|
||||
container_contract_smoke()
|
||||
grafana_contract_smoke()
|
||||
print("PASS: 0.31.7 SQLite/schema-9/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, external Debian-packaging separation, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.")
|
||||
print("PASS: 0.31.8 SQLite/schema-9/upgrade, local-response 404 hosts, Prometheus/Grafana diagnostics, external Debian-packaging separation, cache/dashboard/logging/Nagios, retained admin/H3/stream/TLS contracts and reproducible source archives.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"""Static structure contracts for the split Rust source tree.
|
||||
|
||||
These checks are intentionally compiler-independent. They prevent the module split
|
||||
from regressing into large monoliths, keep unit tests out of implementation files,
|
||||
from regressing into large implementation monoliths, allow full-sized test files,
|
||||
and catch the class of dangling-attribute/broken-include mistakes that can otherwise
|
||||
surface only at cargo check time.
|
||||
"""
|
||||
@@ -19,8 +19,12 @@ MAX_LINES = 500
|
||||
|
||||
|
||||
def is_test_source(path: Path) -> bool:
|
||||
relative = path.relative_to(SRC)
|
||||
return path.name == "tests.rs" or path.stem.endswith("_tests") or "tests" in relative.parts
|
||||
relative = path.relative_to(ROOT)
|
||||
if relative.parts[0] == "tests":
|
||||
return True
|
||||
return relative.parts[0] == "src" and (
|
||||
path.name == "tests.rs" or path.stem.endswith("_tests") or "tests" in relative.parts[1:]
|
||||
)
|
||||
|
||||
|
||||
include_re = re.compile(r'include!\(\s*"([^"]+\.rs)"\s*\)')
|
||||
@@ -89,10 +93,12 @@ def delimiters_balanced(text: str) -> bool:
|
||||
return not stack and state in {"code", "line"}
|
||||
|
||||
|
||||
# The 500-line rule applies to every Rust source in the repository, including integration tests.
|
||||
# The 500-line rule applies to production Rust sources, not unit/integration tests.
|
||||
for path in sorted(ROOT.rglob("*.rs")):
|
||||
if any(part in {"target", ".git"} for part in path.parts):
|
||||
continue
|
||||
if is_test_source(path):
|
||||
continue
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
assert len(lines) <= MAX_LINES, f"{path.relative_to(ROOT)} has {len(lines)} lines (max {MAX_LINES})"
|
||||
|
||||
@@ -126,6 +132,22 @@ for path in sorted(SRC.rglob("*.rs")):
|
||||
f"test cfg in implementation must target a separate test file: {path.relative_to(ROOT)}"
|
||||
)
|
||||
|
||||
# Unit tests are intentionally consolidated rather than mechanically numbered.
|
||||
for folder in (
|
||||
"admin/tests", "balance/tests", "config/tests", "web_server/tests",
|
||||
"cache/tests/tests", "cache/tests/v022_cache_tests", "cache/tests/cold_tier_tests",
|
||||
):
|
||||
assert not (SRC / folder).exists(), f"obsolete split-test directory: {folder}"
|
||||
for relative in (
|
||||
"admin/tests.rs", "balance/tests.rs", "config/tests.rs", "web_server/tests.rs",
|
||||
"cache/tests/tests.rs", "cache/tests/v022_cache_tests.rs", "cache/tests/cold_tier_tests.rs",
|
||||
):
|
||||
assert (SRC / relative).is_file(), f"missing consolidated test file: {relative}"
|
||||
assert 'include_str!("web/login-keys.json")' in (SRC / "admin/tests.rs").read_text(), (
|
||||
"admin test asset path must be relative to the consolidated test file"
|
||||
)
|
||||
assert (SRC / "admin/web/login-keys.json").is_file()
|
||||
|
||||
# Explicit regressions from the first Web Server split.
|
||||
response = (SRC / "web_server/response.rs").read_text(encoding="utf-8")
|
||||
assert not response.rstrip().endswith("#[derive(Debug, Clone)]"), "dangling derive returned in Web Server response module"
|
||||
@@ -150,16 +172,21 @@ server_tests = (SRC / "server/serve/tests.rs").read_text(encoding="utf-8")
|
||||
assert server_tests.count("serve_inner(") == server_tests.count("Http2Connections::default()"), (
|
||||
"direct serve_inner test calls must pass the Http2Connections argument"
|
||||
)
|
||||
cache_policy_tests = (SRC / "cache/tests/tests/part_03.rs").read_text(encoding="utf-8")
|
||||
assert "let plan = plan(" not in cache_policy_tests, "cache policy test shadows the plan() function"
|
||||
cold_tests = (SRC / "cache/tests/cold_tier_tests/part_02.rs").read_text(encoding="utf-8")
|
||||
cache_policy_tests = (SRC / "cache/tests/tests.rs").read_text(encoding="utf-8")
|
||||
# Older cache tests legitimately use `let plan = plan(...)`. The regression
|
||||
# applies to the Vary/revalidation group formerly stored in part_03.rs.
|
||||
revalidation_tests = cache_policy_tests.split(
|
||||
"async fn vary_selects_variants_without_fragmenting_the_primary_hash", 1
|
||||
)[1]
|
||||
assert "let plan = plan(" not in revalidation_tests, "cache policy test shadows the plan() function"
|
||||
cold_tests = (SRC / "cache/tests/cold_tier_tests.rs").read_text(encoding="utf-8")
|
||||
for field in ("scheme:", "authority:", "target:", "tags:", "created_at:"):
|
||||
assert field in cold_tests, f"ColdEntry test fixture is missing current field {field}"
|
||||
admin_tests = (SRC / "admin/tests/part_02.rs").read_text(encoding="utf-8")
|
||||
admin_tests = (SRC / "admin/tests.rs").read_text(encoding="utf-8")
|
||||
assert "let configuration = router\n .clone()\n .oneshot(" in admin_tests, (
|
||||
"admin persistence test must retain Router before Tower::oneshot consumes a clone"
|
||||
)
|
||||
web_tests_1 = (SRC / "web_server/tests/part_01.rs").read_text(encoding="utf-8")
|
||||
web_tests_1 = (SRC / "web_server/tests.rs").read_text(encoding="utf-8")
|
||||
assert "ETAG, EXPIRES," not in web_tests_1, "unused EXPIRES import returned in Web Server tests"
|
||||
|
||||
print(f"PASS: Rust modules are <= {MAX_LINES} lines, test code is isolated, include! targets resolve, and split-module/all-target regressions are guarded.")
|
||||
print(f"PASS: production Rust modules are <= {MAX_LINES} lines (tests exempt), numbered test fragments are absent, include! targets resolve, and module regressions are guarded.")
|
||||
@@ -21,7 +21,7 @@ const openapi = json('src/admin/openapi.json');
|
||||
const en = json('langs/en_US.json').translations;
|
||||
const pl = json('langs/pl_pl.json').translations;
|
||||
|
||||
assert.match(manifest, /version = "0\.31\.7"/);
|
||||
assert.match(manifest, /version = "0\.31\.8"/);
|
||||
assert.match(lib, /pub mod user_agent;/);
|
||||
assert.match(model, /user_agent_blocking: crate::user_agent::Policy/);
|
||||
assert.match(vhost, /user_agent_blocking: crate::user_agent::Policy/);
|
||||
@@ -52,7 +52,7 @@ assert.match(fileLogs, /localStorage\.setItem\(CONSOLIDATION_STORAGE_KEY/);
|
||||
assert.match(fileLogs, /aggregate_min_count/);
|
||||
assert.match(fileLogs, /file-log-aggregate-copy/);
|
||||
|
||||
assert.equal(openapi.info.version, '0.31.7');
|
||||
assert.equal(openapi.info.version, '0.31.8');
|
||||
assert.ok(openapi.components.schemas.Config.properties.user_agent_blocking);
|
||||
assert.ok(openapi.components.schemas.Vhost.properties.user_agent_blocking);
|
||||
assert.ok(openapi.components.schemas.FileLogSource.properties.group_id);
|
||||
|
||||
@@ -43,7 +43,7 @@ const code = js.slice(js.indexOf(' const runtime = latestStatus?.runtime || {
|
||||
const elements = new Map();
|
||||
const dollar = id => { if (!elements.has(id)) elements.set(id, { textContent: '' }); return elements.get(id); };
|
||||
const status = {
|
||||
version: '0.31.7', uptime_seconds: 4201,
|
||||
version: '0.31.8', uptime_seconds: 4201,
|
||||
runtime: {
|
||||
platform: 'Linux', operating_system: 'Debian GNU/Linux 13', kernel_version: '6.12.0', architecture: 'x86_64',
|
||||
statistics_source: 'systemstat+sysinfo', data_directory: '/var/lib/rustproxy', database_path: '/var/lib/rustproxy/config.sqlite3',
|
||||
@@ -63,7 +63,7 @@ const context = {
|
||||
vm.runInNewContext(code, context);
|
||||
assert.equal(dollar('runtime-operating-system').textContent, 'Debian GNU/Linux 13');
|
||||
assert.equal(dollar('runtime-kernel-version').textContent, '6.12.0');
|
||||
assert.equal(dollar('runtime-version').textContent, '0.31.7');
|
||||
assert.equal(dollar('runtime-version').textContent, '0.31.8');
|
||||
assert.equal(dollar('runtime-logical-cpus').textContent, '8');
|
||||
assert.equal(dollar('runtime-cpu-usage').textContent, '17.2%');
|
||||
assert.equal(dollar('runtime-python').textContent, 'Available · 3.13.5');
|
||||
@@ -124,4 +124,4 @@ for (const key of ['python_version','pip_version','certbot_version']) {
|
||||
assert.ok(runtimeSchema.properties.certificate_runtime.properties[key]);
|
||||
assert.equal(runtimeSchema.properties.certificate_runtime.properties[key].nullable, true);
|
||||
}
|
||||
console.log('PASS: 0.31.7 legend palette, About cleanup, system tab/navigation, read-only API schema and simulated status rendering.');
|
||||
console.log('PASS: 0.31.8 legend palette, About cleanup, system tab/navigation, read-only API schema and simulated status rendering.');
|
||||
Reference in new issue
Block a user