From d3b492874bafacccbd1cf31690ea2970539a7471 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 31 Oct 2026 23:42:58 +0100 Subject: [PATCH] v0.35.0 --- .gitignore | 1 + AGENTS.md | 2 +- CHANGELOG.md | 9 +- Cargo.lock | 2 +- Cargo.toml | 4 +- README.md | 8 +- ROADMAP.md | 11 + SHA256SUMS | 184 +++--- build-release.sh | 68 +++ build.rs | 5 +- docs/ACCESS.md | 4 +- docs/CUSTOM_LOCATIONS.md | 2 +- docs/README.md | 4 +- src/acl.rs | 20 +- src/acl/dynamic/mod.rs | 101 +++- src/acl/tests.rs | 24 +- src/admin/certificates.rs | 4 +- src/admin/dashboard_presets.rs | 10 +- src/admin/file_logs_query.rs | 4 +- src/admin/openapi.json | 2 +- src/admin/preferences_tests.rs | 40 +- src/admin/tests.rs | 22 +- src/auth_provider/choice.rs | 33 +- src/auth_provider/mod.rs | 139 +++-- src/authelia/tests.rs | 13 +- src/authentik/tests.rs | 11 +- src/backup/archive.rs | 8 +- src/backup/snapshot.rs | 10 +- src/balance/health.rs | 15 +- src/basic_auth/mod.rs | 3 +- src/cache/cache_regression_tests.rs | 36 +- src/cache/cold/cold_tier_tests.rs | 6 +- src/cache/fill.rs | 4 +- src/cache/lookup/hot.rs | 4 +- src/cache/lookup/tiered.rs | 4 +- src/cache/model/cache.rs | 1 + src/cache/model/maintenance.rs | 4 +- src/cache/model/settings.rs | 8 +- src/cache/model/views.rs | 10 +- src/cache/v022_cache_tests.rs | 10 +- src/cache/varnish_compatible_tests.rs | 8 +- src/config/compile.rs | 15 +- src/config/model.rs | 12 +- src/config/routing.rs | 6 +- src/config/tests.rs | 11 +- src/diagnostics.rs | 2 +- src/docker/client.rs | 2 +- src/docker/model.rs | 2 +- src/docker/reconcile.rs | 4 +- src/forward_auth/mod.rs | 2 + src/forwarding.rs | 7 +- src/geoip.rs | 9 +- src/geoip/mmdb.rs | 2 +- src/geoip/update_tests.rs | 18 +- src/lib.rs | 7 +- src/logging/format.rs | 1 + src/logging/model.rs | 8 +- src/logging/tests.rs | 28 +- src/main.rs | 5 + src/oidc/mod.rs | 666 ++++++++++++++++----- src/oidc/tests.rs | 157 +++-- src/oidc/token.rs | 199 ++++-- src/prometheus/tests.rs | 2 +- src/proxy/body/mod.rs | 2 +- src/proxy/error_fallback.rs | 1 + src/proxy/forward/cache_fast_path_tests.rs | 2 +- src/proxy/forward/upstream.rs | 4 +- src/proxy/headers/authority_tests.rs | 52 +- src/proxy/identity.rs | 23 +- src/proxy/upstream_helpers.rs | 2 +- src/secret_store/tests.rs | 18 +- src/server/sendfile.rs | 6 +- src/settings/tests.rs | 12 +- src/stream/model.rs | 2 +- src/stream/pool.rs | 6 +- src/system_stats/mod.rs | 22 +- src/tinyauth/mod.rs | 1 + src/tls/certificates.rs | 2 +- src/tls/external_paths_tests.rs | 10 +- src/tls/server_config.rs | 8 +- src/tls/tests.rs | 44 +- src/tls/types.rs | 12 +- src/transform/css.rs | 2 +- src/transform/model.rs | 15 +- src/transform/tests.rs | 12 +- src/upstream.rs | 2 +- src/upstream/client_tests.rs | 32 +- src/vhost/model.rs | 8 +- src/vhost/tests.rs | 12 +- src/vhost/validation.rs | 2 +- src/web_server/fastcgi/protocol.rs | 1 - src/web_server/htaccess/parse.rs | 12 +- src/web_server/mod.rs | 4 +- src/web_server/response/file.rs | 12 +- src/web_server/tests.rs | 5 +- 95 files changed, 1628 insertions(+), 743 deletions(-) create mode 100644 build-release.sh diff --git a/.gitignore b/.gitignore index dd80a0b..cd304ee 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ __pycache__/ problems.txt todo/ scripts/routeros-oci-build/.env +dist diff --git a/AGENTS.md b/AGENTS.md index cca5257..ec7f1dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # RustProxy source and documentation maintenance -Current source version: **0.34.16**. The Cargo package and binary are named `rust-proxy`. +Current source version: **0.35.0**. The Cargo package and binary are named `rust-proxy`. Before editing, read [README.md](README.md), [ROADMAP.md](ROADMAP.md), the relevant [documentation guide](docs/README.md) and the actual code and tests. Historical release experiments are not current specifications. Use the bundled `src/admin/openapi.json` for API routes and payloads; the source implementation is authoritative for runtime behavior. diff --git a/CHANGELOG.md b/CHANGELOG.md index 495173e..d1c4ec1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,13 @@ # Changelog -Release history focused on **features, settings, default behavior, compatibility and fixes**. Changes to internal code structure and preliminary performance experiments are intentionally omitted. Historical descriptions reflect their respective releases; consult the current documentation for the settings available in 0.34.16. +Release history focused on **features, settings, default behavior, compatibility and fixes**. Changes to internal code structure and preliminary performance experiments are intentionally omitted. Historical descriptions reflect their respective releases; consult the current documentation for the settings available in 0.35.0. + +## 0.35.0 — encoded-slash proxy compatibility and release cleanup (source candidate) + +- **Encoded slash compatibility:** `%2F` and `%2f` are accepted in request paths. RustProxy decodes them to `/` only for routing/security decisions and applies the same duplicate-slash normalization as literal `/`. +- **Preserve-mode forwarding:** when a location does not rewrite the path, the original encoded request target is retained for the upstream, including query strings. Applications that use encoded slashes inside path parameters therefore receive the same target the client sent. +- **Path hardening retained:** encoded `..` traversal after slash normalization, encoded backslash, `%`, `?`, `#`, `;`, controls, malformed percent escapes and invalid UTF-8 remain rejected. +- **Release gate cleanup:** source/tests are adjusted for the current Rust/Clippy gate without weakening runtime security checks. ## 0.34.16 — optional OIDC central login (source candidate) diff --git a/Cargo.lock b/Cargo.lock index b9004c8..2cc1695 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1925,7 +1925,7 @@ dependencies = [ [[package]] name = "rust-proxy" -version = "0.34.16" +version = "0.35.0" dependencies = [ "anyhow", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 3d0ccd7..7d503e3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "rust-proxy" -version = "0.34.16" +version = "0.35.0" edition = "2021" -rust-version = "1.99" +rust-version = "1.99.1" description = "RustProxy - independent reverse proxy with local administration" publish = false diff --git a/README.md b/README.md index d190bb7..cbce0c6 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,13 @@ -# RustProxy 0.34.16 +# RustProxy 0.35.0 RustProxy combines a reverse proxy, static/PHP web server, certificate management and traffic monitoring in one service with a browser administration panel. It supports several domains and backends without requiring a separate proxy configuration language. +## Changes in 0.35.0 + +- Accept encoded path separators `%2F` / `%2f` for reverse-proxy requests. Routing, ACLs and other security policy evaluate the normalized path with real `/` separators, while Preserve-mode forwarding keeps the client request target encoded for the upstream. +- Keep path hardening fail-closed: encoded backslash, percent, query/fragment delimiters, semicolon, control bytes, malformed escapes, invalid UTF-8 and dot-segment traversal remain rejected. +- Refresh release-gate compatibility with the supported Rust/Clippy toolchain and retain the complete test suite as a required release check. + ## Changes in 0.34.16 - Separated **identity provider** from **integration method** in Vhost Authentication: Tinyauth, Authelia or authentik can use Forward Auth or OIDC; Pocket ID and compatible custom providers use OIDC. HTTP Basic remains its own method. The `authentication` field stores the pair, with legacy Vhost JSON still supported. diff --git a/ROADMAP.md b/ROADMAP.md index e69de29..dc9f94b 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -0,0 +1,11 @@ +# RustProxy roadmap + +## Current release validation + +- Keep the 0.35.0 source tree reproducible and keep documentation, OpenAPI and tests in sync with runtime behavior. +- Complete the Rust build, test and Clippy gates with the supported toolchain before production release. +- Validate the 0.35.0 OIDC flow against real supported identity providers before production deployment. + +## Future work + +No additional feature work is committed in this source candidate. Add future items here only when their scope and verification requirements are defined. diff --git a/SHA256SUMS b/SHA256SUMS index 4a0da7a..88e17d6 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -1,15 +1,15 @@ 61bf40a0572889dc8b0dda9eae4e93e44b0de9364b3c798106c85700ffb37c23 .dockerignore 4140a2956264b44f259e6a37fc00ba02097bbe68b09296e04d3d243c1287129b .env.example 9e6356475bf587091bab72246ec9e325b498f7e48a6220e9a18baa50d0a385a6 .gitignore -92250158a2241c61d4c61aa840025fd5dce9aec3d406d7f199a8673c7ff0cebb AGENTS.md -8a7f5b8690547b6f2aeeeed884ee7025d5201bb03695b04813b3ecdc80d47103 CHANGELOG.md -3deb7f5702d36446f069cc9c5927bdcbfdc306887bec6f45fbc9507295b6c5c9 Cargo.lock -024fe1c84a50255b7b32ea12110285c60b6386daba02a57843324baee5ae94fb Cargo.toml +1e42af1a3e5784b3d75721e05643ce56646c44db6ac61a093d223a236a77d652 AGENTS.md +7c685507d29f629004de84d216381d91f80817f8cc58f3f78d3f71b8201fa0a8 CHANGELOG.md +106ed2f623b6a680c3e354265a3064cb4119d394475d8564b15a98888e8f7789 Cargo.lock +7e6757ad805d645a606c2df831adc056953fd9d502b7f06619172d3f9c5468e2 Cargo.toml 25c7f3c8162cbf5cc156439774118a81dae9fdc52b611ec7e40c230fc99a772c Dockerfile -801fe7dca4daa5b2ed5499490604fbd7d3cd3292f9985a77d2fe181270361d93 README.md -e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 ROADMAP.md +f766680963504b682bbafdc6df03479e302333d61c697c14cf52253c60060ab3 README.md +635191ae82c5c40f01ed58acf5c3f76c70e7dcd402a81aa6fbd595bb5d122454 ROADMAP.md da8311e3db945290666324a2f24bf4521b365272d7ac3f862ded1be993eea646 UPDATE_CHECK.toml -ac07b3bdd46c787989e93f55ebd6ea3d7f5b150ef36f2422e2b58df3f6b7543a build.rs +868538a8c3dff7144647929383f7b74a72a8333ecdc9a799250d402f8a115ee1 build.rs 2c6a0a5cb088399e2cc912073069eb334ec6615eee31734d2f0c161f1415fb6a deploy/README.md 13d514de5042a230cacf8c10abe1ef183c94ac799a89b8e2c111152ac7c69b48 deploy/rustproxy.service c111f5b20484707b80efb7dd9176acbaa27b63ac78db497261ac234821e1fade dev.sh @@ -27,7 +27,7 @@ d660f46f7ef8c539dd3f5486a31d5a1254b9737be8a278c7f9352c254558f8ac docker-agent/m e52b31d0abf65ae3034f6cb367cca113633014ce8d1d799ad4dd5624d2c7b8bb docker-agent/network.go 634790f35ff4627df2a78cec2ed371f6e9ec9df00afb386075595fc7f3ed72eb docker-agent/rustproxy-docker-agent.service 5220d669171ec3ad65f50e0bc8de5178dadaa4904def8e1a4f61002aa9e9a55b docker-agent/server.go -26af69f0dca733c7659bfdb7c9e07f2521aaaa83913735d952a33d1bda85b95c docs/ACCESS.md +4565ea51a6bc3d30ff938db561e0dcda50fa8e75b6eab5ff47130f9ea87dca95 docs/ACCESS.md 147755fb33e9e91098a4d2dfbe6df8b0d92d602e002d568e64a0f815641be2c2 docs/ACME_PROVIDERS.md a7e307a2d6bff1ee3d6fbdef14e873d95acb691ac8f4730cebab8f25335f6425 docs/API.md 2362ab34020d4e0dcd70e280e9539ef7764b63212856ee08a3a55906c48f6421 docs/API_ACCESS.md @@ -35,7 +35,7 @@ a7e307a2d6bff1ee3d6fbdef14e873d95acb691ac8f4730cebab8f25335f6425 docs/API.md 3219aae15fd2e10157343ecdad8b175bd9735f39bccb31a5cb811553c13084dd docs/CACHE.md 3cdf36e6345f4bbbae846d975fbdbe7a87afea93563d24c06e3733eae6809417 docs/CONFIGURATION.md 80bcc666736b10f6202386d0342e8d6feeba858d77aea310bf3ec6de01c2bfb3 docs/CONTAINER.md -dc670d912a6a946160c2f8528c9bb710ec38ebe406dcb7dcaf18fb2653f667b6 docs/CUSTOM_LOCATIONS.md +fec29ce846d6705eb45819d85d382f28e28a146b265feab2e4c7cce2a35e5883 docs/CUSTOM_LOCATIONS.md e350c5081085b79e579d1500fa07feee94a709adaf6dfde7b3d9af124a78d184 docs/DASHBOARD.md 11a08bf2110bbeabc924eb049a30177a216af5607ccdaf8be9178e751f78a2c4 docs/DOCKER_DISCOVERY.md c891ee5272e2e47e1a8f97133a413e2330844103d543fb5cbcc4d0724d59a1a5 docs/INSTALL.md @@ -44,7 +44,7 @@ c891ee5272e2e47e1a8f97133a413e2330844103d543fb5cbcc4d0724d59a1a5 docs/INSTALL.m 888cdc8c56e10b8a7615012e192e085bf2899d0ec9b3ddfb5731e7de6ab5bcde docs/MIGRATION.md 27ba3ed337963d9cd05ba382666eed9c414f58c52e5897b4c1c45603f412b456 docs/MONITORING.md d5894c07c68ec4230987767198434abee13b0e525842fea21bcbe752fa82742b docs/PHP_FPM.md -5ccd0cfe0af7e5290b0e6e7aa0e18f7b3fe4ff6cee64cc4acb836701e35396e2 docs/README.md +3d55bc72b30dbb08e16db1c39009ed181e3b846b306b1d1f479cf5fee68983d9 docs/README.md 490a6dbe870f2b1fd4ec7816768dde6c86fa7957fc9de2d2f075e123c9222222 docs/RESPONSE_DELIVERY.md a7c5b01ad1e6aeb27bccf1085504854b70ed4bf020b481915aafcc4a8aeb1f54 docs/SECURITY.md bbef60ff1771da9f48af812987766e1e479408873ae2f079b13f1d5c62ecc6ff docs/STREAMS.md @@ -119,10 +119,10 @@ e69813fb1d3a4cdcfb7243964cc323fc21a603087bf2e84188fa42b9d3cee4b5 scripts/runtim 046b4282550f5cb4e53906665c91c7442be70fc68fd0743466fd832582f7c979 scripts/test_stream_tcp.py f0b48ec0cc4411755cc0b4128eebce710375afa99344cb76198cf31074f8eb63 scripts/test_stream_udp.py 22e880e98c7b9d700c7a4c02b9b1d543e5dbd1f4ecdca42813a1083165b32a19 scripts/version.py -a6b2c019978e420dae9c4dea3ea46402b51411a589468b75aada8ace6fda9275 src/acl.rs -68650bf15c8d1147bcfd49b81fb313171d417100758c1b86a6260c4eeb3f765c src/acl/dynamic/mod.rs +78efae6c43732c0f5527d467bf07d1f890da361925416c89b58bc449d852fc2b src/acl.rs +1d55ecb2e104be1d3b19623a1380584724c106ed234d2e79fd2a2fef1d891ee3 src/acl/dynamic/mod.rs 1eee2ea2c8dd6068dd0bbb058a06d26436f14767d8efbc2010bbdb4e3e523672 src/acl/dynamic/tests.rs -75120cd3115aea6fc40665086617eab9816f08f556cef8db370806195c7641bf src/acl/tests.rs +50d9a0009cdcee6dd34a6215755d98f38ea18fa39b9faf070d1a7649eeac6aae src/acl/tests.rs 6189029ba493982acc38c9dc1f47fa1f0c933dec5ecfb95b60f8be83d33b5aa3 src/acme.rs b9eff08fddf9b8787aa04e72288ea2ca0033965e4563d060ca51f267e4963918 src/acme/jobs.rs f7f737aa28d2d209f4f20a221885e3bb3596dfc8e3c8c9a22ba53bb975c52df8 src/acme/model.rs @@ -137,26 +137,26 @@ b28a08ea996befe10949676f40fab5f1036a274c7d0ab1175cf5c43c59849dc3 src/admin/acce 5f5b150bb8f51f57462e519650093354ae7b40fcb80c4380fd3391a33f4b30f2 src/admin/api_tokens.rs 34e851f725e8605e3c5d9946e56ac035996f3bd9d608169cba3408060d229c9b src/admin/certificate_api.rs 4a8b6406a44508b8ba102fef0d8309e871dbe0cbdbee1e6c8721baaca0c1aee1 src/admin/certificate_inspection_tests.rs -fe1934fe1ed611277a55485f187a4f62e1fbc3cd196676e64eb1deee4a4c0323 src/admin/certificates.rs +aefbef9839f9914ba05ea62bceff1463da3042b52ee851181ee39d4fbf7b91c3 src/admin/certificates.rs 521ac794501570cfa2e8e50c3a5cec2ca08ee4dbce8ac9c0e0310c09612f1062 src/admin/config_reload.rs b7b0d3f0df7683b031136c05b314e206f7864e70514fe25165e41fd7a9382854 src/admin/config_sections.rs -159c3970d25fa29acf2483fc9422b003e5d63884713f62baa8375e4a4020730e src/admin/dashboard_presets.rs +b1119d6e713fd3af967f0058b2af9ae5c3ccb9d0be66454872e1f6cbd1a7d552 src/admin/dashboard_presets.rs aae1ca0e70d987830bd63948d431f54302083148177c1b551565be12df7fe771 src/admin/documentation.rs da28dd57087df138913b784b71dca11d36c68100f2f100f452fbf4e4d7982433 src/admin/documentation/documents.rs 6ca14ae276c0e683903cfe326f6733cf33ea05f3abbefd5a22671ea160a58ea6 src/admin/documentation/tests.rs 1ce779b84aa8f47bdb3c34f83593c1f2f06d2235f0319847096cfcd035434168 src/admin/file_log_search.rs 059218501907355b22bc84e7e0c41ec72565af283f5e97170ce7707ea5874009 src/admin/file_logs.rs d58a2ed83319176de4e66790ed292447c9497ae031464ec55cfe27174acb639a src/admin/file_logs_io.rs -282352eda7957477e7da44f16b90984b26a4a67a8755833bc0a6b34eaf93e3af src/admin/file_logs_query.rs +77b8c05645969834cc8a58e249a7b60a57d6c47ad1c5cd92ffa5e4a9b5a8e73c src/admin/file_logs_query.rs 444d7715406ffd2305a72decf7fdf633aa4c2752cd881c8fa3b95ed971d79820 src/admin/logging_api.rs 243f4a9c3fa99781e19c3952354d2632c0ce65e1dafac729567cbe3c177f94cb src/admin/mod.rs -b95553b4b13696704c7530e335aebb39c39c46ab2e534b49447f4458f694f76f src/admin/openapi.json +ff1a354d515e9614f6c83c0207a05680ee7473aa9352d989f302f6ac0492faec src/admin/openapi.json 1f4ec45d146e00ef664fae37517f8fcb5455d47daec496c02b2fc15ddec49131 src/admin/operations.rs 257d93a29d0bda96462bcf695aba8d0ec0f9412c379e066ec46ae8ee034e3a53 src/admin/panel_assets.rs f5127855c2994d052678865df36d703b74f4a0afc633daf7718b10da18d26e37 src/admin/panel_content.rs b3c3c2d33c150eb42ce210c6074e5eff6a8af9391118012e413bc9323d3ae50c src/admin/preferences.rs 35a7f99c7448c90b8637f4b3a7becca138f1f378fe5dc3f87122151525bb20f9 src/admin/preferences_columns.rs -334095b28a7f9e523dfa820a3499d93e209a5bad23b776f6d21b80bc82aa2942 src/admin/preferences_tests.rs +b31595d9522962ae16dff83dd8bc063d31fb027cbf357e9953285d2e622daa26 src/admin/preferences_tests.rs 62ecbae5dee00aeeca3eb806e2605372471dfab971cde8f66f11e711df8fcea1 src/admin/public/mod.rs 7a00e4ab943f74d2b1bdcbfab7f9cd3f584e9f59536b4aca6c4c31f5f5508d12 src/admin/public/tests.rs 6930b4f4b45257772b321b20a9becdf9bc4610274852948ba38c2d0cdf02dfaf src/admin/refresh_preferences.rs @@ -166,7 +166,7 @@ e135431cb078ddb483165581123c64af8f00ea67acb3a395ec3e53c85d0d0d45 src/admin/sess 2d5b4c28fd0bd48b06890fa8ec44a2296de948c1f88eb2909f4b495710d4e13e src/admin/status_live.rs 8315c856cd50f1072593ecb30c274d42d82f078b5fb93e7c68a8857cd81b0841 src/admin/status_system.rs cadd7dca29b9aad8c77c7bc23dd249ea37a15adfa4d636a8ef00273a91ce35dc src/admin/tail.rs -1d3ea6803763827251317070329c8eaf86491d4a6adb8a90406f32009e4a9b1c src/admin/tests.rs +89a1e5dc0b50eb9124bd5ae3eb03c22d16655a83b856d9c6f2a9192356c34257 src/admin/tests.rs c18c369124368f58524652565102817ed69fd25b222b0c5e737ef7632b45dcfb src/admin/web/acl-picker.js 48fcd818c522fe80f2c7b2e218f87de6a7772ffd0a24e93502780b03ec78e335 src/admin/web/acme.js 86abf50f39862d821cd2e43ca425671aa85289272915132c5a9a5dd1fc4182e2 src/admin/web/api-tokens.js @@ -255,60 +255,60 @@ b2f948bcc3dbd24952c7b1b02d41d06757f49f0c38a78caf51f8f8a687d8480d src/auth/files 50ef648351271d78310e00caa6d246e0009171ed036a3e4e1144737df8c064c2 src/auth/mod.rs 25b9f78a7eee277902d375266bd0f023b79794bcdf646214fc161c5e6dab72d1 src/auth/store.rs 745226cabb007819370fc6aa680995a03a8a13fa057dacfab0fcc21d0c43bbe4 src/auth/tests.rs -0a4d6a10462d3b2895126161aec308bdad134071765e508408de047e69f0376b src/auth_provider/choice.rs +a4c14df927c22a7fa46023f25cfe48d3aa3c245bec97fce834999d9bcc8d75e1 src/auth_provider/choice.rs 514e99e0d3da21abd633d53872234bc4d7d0178583ab291e53cf4b1802fd5337 src/auth_provider/errors.rs -f0da85c578db94af8dd4db4724a712f52b17f998b63d5a937ef8d86d2f222f2b src/auth_provider/mod.rs +d3d9b756f8dd0444ce6937376f27fa2eae7eb4b9304a2ff039d272c929592bdb src/auth_provider/mod.rs 72a6e245cef078585bc39932033c05cd866ee518e9cb4e7b89bca87ce941e971 src/auth_provider/testing.rs 59e1af728770d28265198844b3dd5740328bcb1df96d170aae575d3d97ac42d8 src/authelia/mod.rs -f44b10fd3286b805008f746bee8164bd9c3ec7ebc86724314408f6703d6e1f41 src/authelia/tests.rs +36f87348cf1fbe557f6e94296d30e5b36ae4817b6a18071767d96ea21e43f498 src/authelia/tests.rs 0137e66fb7127f1ce023b1c6cfe3fac3fb1e8be17f1de722f8ef4e66d2594a13 src/authentik/mod.rs -fb244b3f44f9f4ba0be8c7572f43510afa0af7bb9c47759d0e9a93c8a96e1616 src/authentik/tests.rs +d4a388f0897e50ad26e1d8ea527eccdf656557abb4b88fe714cc2b1969d8715e src/authentik/tests.rs 32a25e53ac9542424debeaf1b637155bd4e60abd8692df082729d81232eb90b3 src/backup.rs -0f07cac0259e849773466a6fd468fbe84ea34bdb79745dae8ace286b8c707727 src/backup/archive.rs +5e8b1d7cf8280bec54521cec9e4a8cb9cdbed5deaca07e3a79c43d2336f22a80 src/backup/archive.rs 15213c87d4393739328ebf0eacfcb45f864ac12d7bd7509f750a241032f6c7e9 src/backup/ownership.rs -37f44cf3ecd71c7e817c76462c4c7de7ca6ecd68c0544f6429a861cdcde1fbdd src/backup/snapshot.rs +6edb5aabd210ff2d121e435815b5501c9eccbd7a8ba230c843c6335a3e24f94b src/backup/snapshot.rs 859cec0bcd604f205d7d8e14d1c810e33d97fd7cf00ddcc30877bcea7c5fc2b5 src/backup/tests.rs 5293e1b06f82654929805b53b9092a82b7503a54b34da3e882a999c5f14ee579 src/backup/validation.rs eec564a7ef886d18ab75289025a47d62bb702f07d63f92d2f83b7afeecebbb15 src/balance.rs -8f7975b488fad31f8d83ffedf8a5b6774b86b59290ce0711e779bf2496d5503f src/balance/health.rs +5006ece2fbb377c3944f67d9a7df9bed18861f010a7025d59259db5ae27a00b7 src/balance/health.rs 344631248c71164187497a10d177d8eaa3e2f4a08111520d2536ec89ae55e1e0 src/balance/lease_runtime.rs ebe2b95f8fc5904850a88207f0690449ced49b28dc14ce5608b706659e338fca src/balance/model.rs a3d052a928f73ea430480637b867ae874d447e24b1ac913a96a068a4dd74ff63 src/balance/pool_core.rs 477132dd381e23f1be197c32ff58a0925f41d3e6aa63fe77c10973cefb2b76f6 src/balance/pool_selection.rs d789bf40cf9433e2fd16409d112210fe93154588b1ac9bddc9c7f78e1db14426 src/balance/pool_view.rs 3e75a3cba5f1cd1af996ab71b6ad50f6aa05ac9cd97ee3cf88fce7c6b7271fbe src/balance/tests.rs -ca1699c931c9d80c3ba42f4f70f5c7260bf69ba3af40a64639311d461cf91e62 src/basic_auth/mod.rs +ae06d238b1451328c751a511476ef39c012f3a397393f939ca02f96ecd0119a6 src/basic_auth/mod.rs 9a8858b7ff461a4d961ba701ca3ed6eaec15c3f7f05eef8e0e30edc63617605a src/basic_auth/tests.rs -76b7db53f38c95e128fee503381efd3fdcc13340c124ef277faf0fe5c1c25c39 src/cache/cache_regression_tests.rs +e5afa832a49b3b742fa344957d19987a748f1f2a8771caf91ef607a1e4b5f02a src/cache/cache_regression_tests.rs 7def03ace07dc38a4806277d75b6ce673d33ddaaf1c4855fa0a3372be1cf9489 src/cache/cold.rs b75ff512a1c6344483e00d2c67578ceea6b5472c279da0246f3622daa68d7e16 src/cache/cold/cleanup.rs -b0ac49535454f50349b5d808c9ebf3a2506c94e63a47dbf802c47fdaacf9ed61 src/cache/cold/cold_tier_tests.rs +5fde9e9f77277d557372e4614264512804192bb51f3d2eae785988c17b2e7253 src/cache/cold/cold_tier_tests.rs 3c3897980c31a69d96f9295689d1ae19f2190da758c69ee7a0541f4171288bb8 src/cache/cold/demotion.rs 17b2a157b03f43f49ed05f1f3391aef025a453b748056c67229cb3ceaa53bb7a src/cache/cold/startup.rs 83095bc93132f306c537b31ba929669fedac99af3c4f133d0533b5ed7ac4e6ed src/cache/cold/worker.rs a36a4a255a0fd2c38449bbc462b9c0e21488eae108c70326ab2a0e4c7cee0f97 src/cache/collapse.rs 346fdb40d85627b583a01fa17c65e45c822dc7216198cdeadbb3d07904286fa3 src/cache/dashboard.rs be3d80cd340edb47755190a668e2f2a2776940665f95ae7110893b706eb94faa src/cache/dashboard_tests.rs -8b5a8c04a8ee115497635d2faf08eab5ca4db4225c6c70110675685346f9c0e5 src/cache/fill.rs +5c71cc2b2211b16b38093af878c7a566218d318ab4da4587d18bd21110321b96 src/cache/fill.rs 675d18afb53f25c56cba49bbb431fa39ecd9d7bdd192c7b39b0698225de7a2a2 src/cache/invalidation.rs c1357a13658e1ed5158084d3a41ba9ea906512774a5863ce9c56cb508d3fa6c8 src/cache/invalidation/ban.rs bbd9f372648d89b5e14d5b58029582d37f66a89752caf2c14eb1cbf8f24bb7a0 src/cache/invalidation/http_invalidation_tests.rs 6223eaea3f061b111330cc26b28d85b9b239bef30328025f2ef337291e22a445 src/cache/invalidation/invalidate.rs 9bae8be41d1e6b30cda51feff99fd77738940fa975bdd4ece7da75d936f07b47 src/cache/lifecycle.rs bd831807ca4720b3a41a6ea1d9a6c0ed2d935225e2dacbd9313be064f2e4a20e src/cache/lookup.rs -13ecc0fec5f9ea4c555bc23e7ad0aaec2ba10032e8f5fd2729675316edb28487 src/cache/lookup/hot.rs +030598212177bce1e2a21545c31f0fa21324380cfffc45765616136d2dfb641b src/cache/lookup/hot.rs 4148106b9baf70e41eeba24233b1edbe8dd6f927c11f2c7647e89fbbfb60555e src/cache/lookup/hot_hit_034_tests.rs e9c6ab0c2be9f96573f826e6a1b9e4c2b8b526f779e72e95a6463ae07e45ab55 src/cache/lookup/insert.rs -6943a1856da7aaffc9af335d0af7bc88cbb7a8ba9f67a83c91e4500ad431ce56 src/cache/lookup/tiered.rs +1d39820c30a633954071162aefb6eea32d4bb9348a92295212eb54c75e8bc399 src/cache/lookup/tiered.rs 639d5be6258699ec7e9f260ec33bd2c366203b6655bf0e98f30517c2e12ceeeb src/cache/mod.rs 0c8b0d550e94d4474dcdf991aa0340228dc5f2f904f39350e6e666a16f16fe87 src/cache/model.rs -c409174a9aaf20c69dc7b329108d1c26b5672a7341236cf639480df48800d262 src/cache/model/cache.rs +44b1da391c0ff51ffba6ae43ddfbadc171c240ec74c5bde630d3c5993b6ac15b src/cache/model/cache.rs 0ec4f6d0adea1ed80a3ca17c68c9c85502491da2904d9d690b4169ab9b025b21 src/cache/model/cold_metadata.rs a6558f1c927b2f2fa0e2536654343a172ebca93ef10363c341cccffa3de0293a src/cache/model/entry.rs ff1c3a1a3bc0d26218dcc943ef951c2a212c180bbf6e4bf1c542ffbf12a9f150 src/cache/model/http_invalidation.rs -f3f8c8116202bb66e3fc7facd0810fd0194838c73ac36e5d372a71549d8aeb6e src/cache/model/maintenance.rs -18a2ccfa74a959ec7b41754aaaae073169cf20d9f67e97e8b33191fc706eaaf3 src/cache/model/settings.rs -6aa7a03a52508f19ae1ed99036563157eec740b417031933540cab56d35b051a src/cache/model/views.rs +99f1ab9405b0c678bb2596465ed06a8edc290918114b678809ca75878081c1b7 src/cache/model/maintenance.rs +f3859df02872b6a99b7aee38c34d7b4c48966e4e32df827844507e44d68bd6bb src/cache/model/settings.rs +de09da0ef61d2e0642ec18f1e5048538df9dd17be8761f4d184c11d9cb2d3c8d src/cache/model/views.rs e26dce02df9c043329ca164fed215d271781bce8fb03240f528331bf3df6b9d7 src/cache/policy.rs 8437aa9097058b1ad19efedb6e2be50822a7f67f05ce3f7182a82665dd1be881 src/cache/policy/request.rs eb64c98d53eb38492810a989e348e5ef10c3c63ed60bc30a1f7e06d62916eb7a src/cache/policy/response.rs @@ -317,19 +317,19 @@ eb64c98d53eb38492810a989e348e5ef10c3c63ed60bc30a1f7e06d62916eb7a src/cache/poli e1d3f0512d6290b0ac4998f47398a0a7c404db249c5bbdbc4ad4767071013a72 src/cache/stats.rs 8a300f5dad44815401eb23f4f5a157893761852ba4f3005041e0eb3f16892ce3 src/cache/tests.rs 3558b10e6bd36a847afffa4ea888ac7cc0a842be128a8503ab18133e79d5e0c0 src/cache/tickets.rs -b7c063ed4c8ca96714e1dc5d279f38fc521da2132faad7bd4f40311352a71e7c src/cache/v022_cache_tests.rs -d87f99c48c05ededb0586626d3179391257f54363516119f1567fa0590546485 src/cache/varnish_compatible_tests.rs +a1132b980833b1b4ce2bba9aa681ff255a0e2d61faaa65a316860da503ea1e27 src/cache/v022_cache_tests.rs +4ba0a2e1c21a79cf68bf34cca1f2c2a3d7b20aef38f3b9f510743e60bc1d425f src/cache/varnish_compatible_tests.rs 5a8022f6f4721f9fabdc715e01127d9299d47ee9981a79d26df1981448487d20 src/compression/mod.rs 8de82d9188c9c337690c6ec4afdad9fff20651c358e34602a93d8bb8c3d2489a src/compression/tests.rs e695079333ebdc2a6e7b3337cfabef171b3a0a01da0597b77feff70d417f833e src/config.rs 9f6272bd6d1f403cf6324b12472fd5c7d911a387139b57105ba1819e48ea0a54 src/config/backend.rs -53409e9f92815cef254e138213b32f8d05a01ebc62e176064dc7463c86ce2d71 src/config/compile.rs +367b46f2b57143db17044378b1f5a55bab1eae3ad6ba40f388896995d1b2a13d src/config/compile.rs fbea01fea1213d98aea812c1b78d5db289e09200b98ae514d742235cd3fa70e1 src/config/internal_routing.rs -c21961205a7f58ca25dea553ce56590e3d8f4011bb657577421e773e130e5527 src/config/model.rs +0ebb02ae4f7403e347fed433d93ce9a4a46b1532cb9c4b2b4dc17885f871ed6f src/config/model.rs 75839458ddbc7480ec4492a95eeb67b617ba7d62f7b67e7a767a3453b74dbcbc src/config/route_policy_tests.rs -3c0ede1417e83720b010efa670fe8494e3ad0e9138065d725cbb9c969de6c559 src/config/routing.rs +365483b6aca6ce5880bec5235db869b1bbc2e2551945c99303cd005903fe6207 src/config/routing.rs dd9a6ad8f58e0cdd1ae74f3f972fee0ac14383cd8b8f518403b9a36c169f35bd src/config/snapshot.rs -8676b307dfcca462d9bdd934e3f1432b8b903f89542d502ae20f8a7b24742557 src/config/tests.rs +477cddaa1e1c0b9b605a2f550971a04cf30287519cba70cfbb2e1860e3a93ff2 src/config/tests.rs f35de0ee1516ece6bbfd2b2a7db7d1e0f5048b0ec47cf2a3765dffb799abfd93 src/config/transport_tests.rs e0f46d885ed81ce306a110560cdd2c217dadde054986d89fcec835ebf6939d65 src/db.rs c290d0cb02dfa5d2c60ecabac1cfb20c8884e3d14cca1944906c6d8e677f1784 src/db/api_tokens.rs @@ -338,24 +338,24 @@ c290d0cb02dfa5d2c60ecabac1cfb20c8884e3d14cca1944906c6d8e677f1784 src/db/api_tok 2ff744223934685439374572b83fd639bb3eabdbac168bea4a6501a6e9ce0ee0 src/db/history.rs c05a02618d476e4670b4f9930555f199cac776cdf09edb8fcc1a675695b8f99f src/db/tests.rs dada314e1709e960eb2e225b4d10c3cd12606c62c4bc2c0220aec1ba133e4ade src/demo.rs -f9e8539223b4f1d254ee960f59cd4dcf719627101dd9e0190fcd7d331b674fa4 src/diagnostics.rs +daf8f4dd4bc4dc4d1c69fa583165f88fbc299eee9c10edba75609424b0539ab9 src/diagnostics.rs 045593a42a6e4f632e5052c655788f0843faa313fc929265b89509f6dfff91ed src/docker.rs -93a8a3f6ba12761ddcbd19f22f32fb2c0e6948b660fb774ddd7ebdba9b6562a5 src/docker/client.rs -f69087a82cf353e9a92fc97ee96510dbe0382efa1c5e53821d16c455a3eb95a4 src/docker/model.rs -810415a436d842b79fa72392cc18977ad9c368d233ce43e135a60119722c9e77 src/docker/reconcile.rs +9a68e0d5bd98541314f1ab5a4c2e344e9837f315bd39ea3988ffab6a0f0425ab src/docker/client.rs +8feba6d9eb7fde798cbe8a9e339d6a5346cfef039980fce46727386717635eec src/docker/model.rs +f095e5e3c564df409874019c647f7cec2156bf5a845b5b84864879fd07cf80ce src/docker/reconcile.rs 2e8d4841665cc1d29e6262a1789bb64ced1cecd0036681cc1ef7b5d5bfe22065 src/docker/tests.rs -468ad6df59092d97e79b42e4387460488040b5b7ea99c7639946f799c6863966 src/forward_auth/mod.rs +0b6831d03841a1dd2d6ea73d8268d0b3fda678c6d9332447e269f1e45c60c0d3 src/forward_auth/mod.rs 6c25951cf69e972c47cdf815f22a5062916b2b768e39d55a26a5fc9e625a2344 src/forward_auth/tests.rs -b496edbfcb8fe7875a95835f8b10a0dbd46775227dbd1e5fee555b284c836216 src/forwarding.rs +fc575224ba4c76dd19894e4865d77529de8cbce62060cc5dfc53365d103790e2 src/forwarding.rs 08d2e08a798abb944a53570f0bdaa08f1266b1107677ceda070091af4c2fc121 src/forwarding/peer_formatting.rs b821e2efd2939cb13d01e402532f33feba6682acb6a7a3223be086c1cd171820 src/forwarding/tests.rs -c3bbae778cbbdc1bb299152813a2513c6c9f9840114b15bc2613908625d9a79a src/geoip.rs -f551ab3bece770e772fbad46eb88a1e8bf58db4569a22a57eb0fad99abc41e89 src/geoip/mmdb.rs +0541ad50b7bde1fd958fca59d2a4f9a0fde197bcaef68e6bf892ffdeaac46be2 src/geoip.rs +e3646c044604bbc169aeba549d973d37b8ac52f88bbab4a0ef74c6ea69681eaa src/geoip/mmdb.rs 1e8777b295e6a2de15b55548e8c4c3a4166b1c5af18d4d33bf067d8bece1b590 src/geoip/mmdb_tests.rs cfc75486e69c310fa6e7bf0448ed39a20cdad9dd8afe75e01592fc7c76a380db src/geoip/tests.rs 3376ea3d018faa5cb8a69da2f1d5b7cdae4bb4b8f28f373f8a0a2996410e1bb7 src/geoip/update.rs 86a16add6757a377264ae6afe071039851d5996e80baca9d4882b71889d7c2d2 src/geoip/update_http.rs -618d5f245a9c7a1a95e1d086cc01bd2aa4f0360f85094f1c2403a5c14a6607be src/geoip/update_tests.rs +8c72a10f07f82e2c0864551a3c95e261129c23cf6072f902d063564b6ea6770f src/geoip/update_tests.rs 61a2b01db9010f9debfb38b43a26bf588438a5e470dff938e4e54f9d7ba46732 src/headers/mod.rs 3e9a19c221fa7e63d097c185470b17313da7c200a73ce50f6c069b4dddd37a96 src/headers/tests.rs 1b291ef7b06647ce820111cc802874394e3600b4b2c86ee9a1fbf7eee6cb864a src/http3.rs @@ -364,14 +364,14 @@ cfc75486e69c310fa6e7bf0448ed39a20cdad9dd8afe75e01592fc7c76a380db src/geoip/test 49ce4452ef7973cafa2e4071e2e14be0e16000e931a6501e4dc613652e49e230 src/http3/identity.rs 0b41072f6bcf28725c80f471584cd55d4900424ce5d55ac7f6909813788c63fd src/http3/settings.rs 5c550075c7b0fe687ad02095c1cad57b46b579fe21dc5371525b6c77db459886 src/http3/tests.rs -06c072cad074030fb0e7ad0e2fbb19358ac1e552edce8c969e6cee501a854d27 src/lib.rs +a7383475f26996ca1a69da8483592bd13ab3f51c2a2bb3f381793d77be4d0b2a src/lib.rs e8399753a6ff49b7de19edb0a268bf871c9979a518354160cf07b4669051fcbb src/logging.rs e4be3ad758c3d93ce52dd3b1ab1a87810e5226339400ed997607a5b746354751 src/logging/error_runtime.rs -b2ed730006fdc080cf7eafc6d20bb89661c639631c89642d110e0c1319e431b5 src/logging/format.rs -e51524d45ddf54574bd33d71dd4ab18abbb642390fb6b6dda2a2c6e5439fe9a2 src/logging/model.rs +e9795eaa3e743c09f5ab0e18e6d359bc97a3aee55510c74923532d64df90c873 src/logging/format.rs +66c4596e644d9dd539d468575e2de6459bac2624fb1e1064cdae61a21c2e0240 src/logging/model.rs bf898b318feb9bd3dd2c335b6b1bad172da63fd5cefbff3e267421ce37c076fb src/logging/runtime.rs -a9e453c94fc37e37d9f1f461494fffd5f88e13e15f6cd4158464dee4fafb14bd src/logging/tests.rs -0e9993aa6679381d91f3a22068b30e1611439d7fe50900f49a4a73ffdfbdb3b1 src/main.rs +3d6ea5508bc9609c6ce99a3a1d3fec2fafb95a2a8de579a80262b73c6eed3a63 src/logging/tests.rs +b5efd83545aaefac91d49639ecab8f489a4e405dcf6089c9bc77246adb922d8a src/main.rs 87e69b46c548b861e9d90d9d2e42dfc2630891f4583800b1942f9065d25e29cd src/main/admin_runtime.rs 224da3127b5f846a8932fb7f4b6e23d0dd590dc0170506267381d66042c64666 src/main/bootstrap.rs 5a98d0e249338e73aa57ae2e352dc16db2f79c2d042c1544ce1c2022da76a31c src/main/proxy_runtime.rs @@ -385,9 +385,9 @@ f27f1f8bed769883d1a7ae6a48f89f7712394dfab65d05702b0f944f0a45bd8e src/metrics/vh ee401f943bd8b2706c33419ed69102269c08dcb4ceba6f4be2ac0861115874a7 src/metrics/vhost/tests.rs 9c96c3e4edb20dd0c36b5c373f1925dd60368e75853002206523072d418d5417 src/not_found/mod.rs fa2fc6c6987a8356a9da92cade10db62426d8863e9cef3c0d4324c45780c5b33 src/not_found/tests.rs -afb91130ba3312cb5c0251ffdf78e0cb6dcee823ca4de2a87d34cbc08466be1a src/oidc/mod.rs -cda94d19955f427bdbf64b820a691a03505d65c4017477a84c1c0bbb583417f4 src/oidc/tests.rs -aec2c4124d99fc474c2020501a3b9ba866835de9260fc8dcc9d0c5d61c67b223 src/oidc/token.rs +c19998732c2d65b464f43d17a58644db5bcf7b4a6dd4c31a65a091c57692a5bb src/oidc/mod.rs +c4dbe0f8ded237d45b6a3977bdc7544296e4408996815cae9e28af028419d0b7 src/oidc/tests.rs +992f49379e44361d98088d8f8ca1013d62846a54a5a35c089a5e0e1a9488e08d src/oidc/token.rs d32d757ace8753eccb842c24b7af77061141478d10fe0acdfb05263d19e9a64c src/pages/mod.rs a3474253e83d1548bdafcb696a80f064283062196d665bb6316952355d54b785 src/pages/tests.rs 5650a3f70162abd97021e0a51a92cac2c4dd39004426ef99dff36cbb0741aeac src/prometheus.rs @@ -395,39 +395,39 @@ a3474253e83d1548bdafcb696a80f064283062196d665bb6316952355d54b785 src/pages/test 2321ee667569803dfe6c92c0e98adba0ac8fcdc6a3eacd5857233f1acae93daf src/prometheus/docker_misc.rs cc909edf740a091f9057912f555dd888b644fa1cb49647fe0e2e1f5dba8ff5af src/prometheus/json.rs f9aab3297fcf647295849f77cbf99197f4dd27efd6817e54c03451e04972430c src/prometheus/model.rs -2ea975c96fb45bfeeda95583e909abb26328a16e411fa46a1e31b2a359f7e711 src/prometheus/tests.rs +37badc9ae3c63a918a4b8c956beeefdacd78caafa94dd214903d37b62989adf4 src/prometheus/tests.rs 028e1dd9f2ec2afaf699a6b21c2d4b0d96e76f11f5e4ed009c233dadf0f3be51 src/prometheus/traffic.rs cdbfa0e7f49200dc70ea6df8f0594399ee3e0c5fc3d279cc336e2d83d0f8a535 src/prometheus/vhosts_backends.rs f1b0254f53b1a4336bd8170ad441ba072b49a114e497b985cb20699b4e375044 src/prometheus/web_php.rs -569eda61453affd053fbe24fa59652289cb12d22b77e64caa38325bbea48f7df src/proxy/body/mod.rs +315cdf2ec1479433aabee00f616e8c0a9ebe4697bd4928885433b80e52a706cd src/proxy/body/mod.rs 5beeac12ca7f041bdb9b74b2dbc9781f5d5524fcf51294b8b6ad88284754ef20 src/proxy/body/tests.rs e97894a7ca8a3c4dd753c8d4d1bff20cfa50f8380d8864b8cd4197241c018040 src/proxy/cache_helpers.rs 78caad08c548386958f605f82f6c4299ab94a910a1332a1e0817dc53920c96a3 src/proxy/cache_http_invalidation.rs a86b10ce0707b51e925e11ea14f7f40fa108a196166f62cf40007a97f9e17203 src/proxy/cache_http_invalidation_tests.rs a03b1735daa8fa8e40cf5bd9c6e200d211baf19c30f62522d4237ed56642bce7 src/proxy/cache_plan/mod.rs 811a00b55b24638b4bf8a6bea9ed7c0ffe2a758f76bfe63a95e1d31bce1c2c9c src/proxy/cache_plan/tests.rs -3ba479d72d0e94263acaf2eb84f4de0ff2b0b455ee6a4d3f04bb869a5a30a103 src/proxy/error_fallback.rs -aacc3249b3cbce0726dc16e28ee09d78efc4ac89103675287b1adab870bb4e15 src/proxy/forward/cache_fast_path_tests.rs +d6bd4aafab7b1371c31b3d22ddf949a08ea942489649ca12c7615b3b2d5a109a src/proxy/error_fallback.rs +d0d64fd4dd89efd4a9fe089ff4551815cbeaf5b883ddcff803f1b13b5e918dc2 src/proxy/forward/cache_fast_path_tests.rs f7903200756dc6393392560656b23364f0d444d6357d264713b17c63fd8b636b src/proxy/forward/cache_lookup.rs 287012c81144d8f15af4a148ce3ca911d431bc59adff44e697567561aa372bb3 src/proxy/forward/h3_navigation_tests.rs a6580dc359d1f377a48ccc1a902056dc5c2ef86c89180b9b0995068d9817ae8a src/proxy/forward/response.rs afcb095b0511072b8cfc197e13f2cd84454ab699155fa964646b8ee60ef00f81 src/proxy/forward/route_cache.rs 0e6e288fb8a0575aaa4e3bcb5c47ed5412a584386a2b00fb25db8428d652cbf3 src/proxy/forward/success_finish.rs 744daef016d4a6c821ab12eeedaa2f29e2de72e2ac9dae16481d4f43476cb713 src/proxy/forward/success_prepare.rs -e9ac9be64f6f83c4ca2cd5d299649f24dd16197712083c6dc10e55c662c65b80 src/proxy/forward/upstream.rs +00cda8248b4bb4afd2da2b4fdbe13e5b259f28344c6eb03be30b3f677879b51d src/proxy/forward/upstream.rs a6fb336b22c011e7e0936c430d372d20bc77d48ddf0aa491da98843d15f1a126 src/proxy/forward/web_source.rs b4121b28db988ad338c52321436bd276d1c69865d2c18d2c1d1eef1e5b86909d src/proxy/forward_entry.rs 9b25f5e3ea4443a63ee6f45d12e1957dd316fe8e85325fc00ea416faaab1025a src/proxy/h2_graceful.rs -684ea1e2a7591a17e3c633576fc66e2a30d6636738fbce61dab5af44f2c51a67 src/proxy/headers/authority_tests.rs +30057732c35c369740a176f7da285e35a4157571a04328f6c6f1494d4ec8550b src/proxy/headers/authority_tests.rs 66efc52d64d34667ba7419f8a2da89d2efb1af1f426684f4fff8caccfdf19996 src/proxy/headers/mod.rs af9b044761a56e8a6dc57f9d760b289ff4a6bf37db11dbfcd3ec4da5b8f426b6 src/proxy/headers/tests.rs -9a26952f68cbe94cf1bc4a8ca70776a083b37c599de2297cea126799a1788a3e src/proxy/identity.rs +2bc63579975387c92a9459a9677b4a49e76df382b1ee19de12c8341a3ead57e8 src/proxy/identity.rs a08ee061e146cb7a08311106f73a04468c89f77b6ad05a2c623621a2ae9faf2a src/proxy/mod.rs da9148ce6a60226954cc839f606465b8c16a44806f4d5b2e22ad60484509b967 src/proxy/redirect_helpers.rs de1763b89d98607e20f0f6e3c9b546483927cbe5450efc2e5634fe691fd52ad6 src/proxy/request_id/mod.rs 78bbc1263bcd600527bf71acdf5af92bf186267a9084b076d1f75ad6492df659 src/proxy/request_id/tests.rs 580fefdc7dfb27da73f86f4d2067d46a1f856d2f398b0598d37a743e880d3dac src/proxy/types.rs -faf7296f045fb3a748fa1c21024a2fe54b692a415a59c15992ea54e032b8c0ce src/proxy/upstream_helpers.rs +2fc1c0de41d87299771c5a568a5ecdad23521bca48f3bd4ebfaca8542830d399 src/proxy/upstream_helpers.rs 3fb4896f2ddf9dad783005e34e6d4ca0032fb5a69ee38865b5e0ecc9d7f97f30 src/proxy/web_cache_revalidation/mod.rs 5522456cc85948228250feac92308d53b130c4a7af98d24f669aca4dfe6999b6 src/proxy/web_cache_revalidation/tests.rs 3d591201595a3b1e928ded20a8736477a47e5acc3bcbde158f7c17547ce155ba src/proxy/websocket.rs @@ -450,45 +450,45 @@ eb2b0eeeb617cad704732d87acb667530144836e26edb0189929b1f25f35c6b5 src/runtime/do bab3044f4ad8957b65476a0c307bb8a364825b91d2100233b7bd6efa52e771be src/runtime/types.rs 11e99eb425887ad12b73d949f4b497fabc6897fee8fc7647fcb3766269c2fdc0 src/runtime/updates.rs e3dbd60953db5a976985dd796e4268b41a0f3df59764c4664015bd41e2578058 src/secret_store/mod.rs -6dca8da072a3bd610704a391bb6e25c9042971f4ebc60edb47665ce1d74cbc58 src/secret_store/tests.rs +b0c4a46b40b09794fd8a037afca380ee3b95941bf75a0e1102a4e520a4bd8be3 src/secret_store/tests.rs 98854bfeb35cc5266210b1c33dffb36b8441e27e61d90ded8b681430696d156f src/security/mod.rs 0e5fc2aff856bf6fd959cba12b5ddd1e3b3cbeb0fbaadde906c811cdfafdaae7 src/security/tests.rs 0e8589f56e3229dec556b427b87c2cc5a5a9ffaa9ea84fc2522dc72d3239027d src/server.rs 4cb5afb53c824f5c87abaa65ba5dc85e937255c4a3dd14af16df9af7e01afb10 src/server/http.rs 533342fb3bf8b52743d3a7102ffe345be9f86d553b16a1ec322e468b44b0a7eb src/server/http1_connection.rs 8e51738b1a2805c1226f537b85791fb687a52edf870cf2971dd9af75375febf7 src/server/listeners.rs -4e94d7cb95ec3e1e7b19884ec11c881e4fda1e57830525356bb1ed44afd6b17d src/server/sendfile.rs +1c4b84a4aa9f120ed3b7669a145ef51e90d825c559d40088a2b4074bd84a4912 src/server/sendfile.rs 5f6a59c6085c1250a93a70020661d1e8174d3b600b494d16f8eaf7941ee3bb99 src/server/serve/mod.rs 048a05a46108f50bef7da50b0c03b752d76e586b411de8ca3f55313bbdb08aee src/server/serve/tests.rs 669ea1041c103cb952f7a516363c8fe7baf51622f721aabdd57015e0e0279530 src/server/tls_runtime.rs d7f52ea1f6c0d037fc92d17872ec38df589815f9d7fbb038e9c1cc584d3534c4 src/settings.rs b2e2924047019b1ada59c58a4060476151c99be12c0b4092f2cfd201c0756255 src/settings/public_origin.rs -a6a1cc42f0a5e40071b3683ee517c39f3c006ff3843dffaeb49391d58518c4ad src/settings/tests.rs +c599d1cdca84ac18da7e7cdf6e2d1556d4df770481a0607edb96f6068cc051e2 src/settings/tests.rs 2ad7888b263e1dd9f0bd588dc35590c9eb232f8fbacd10ea7554cb55b4dbd25b src/stream.rs c7771539fbb94544947e958e410c46b73f9647011e56b1b229ce7035fe21851f src/stream/health.rs -c02e85af45590866f20933561f85ea9b4f2ead62c2ce41db75113632670a7b5f src/stream/model.rs -5b74acf26aad3b73a10501866b716de5dace753453d5f09c4f22c414e5ad880c src/stream/pool.rs +4b9cb1135c53f4a159bf2fdc1b0c7900653d7651612568f38c12f06e70b8ca72 src/stream/model.rs +42aee5900c3c8697c80220c77a6af0435358e3b1fdaf1a246784aca4f62edaaa src/stream/pool.rs 6ec42b8647c95a0c2025462e0be29c71481ce1c3b1f4d4193926471f1cf06be0 src/stream/runtime.rs 9346cbd71e006e41a7188f28eb86f9b00bac822e4cb31c76c8d6d2ec5d4c4711 src/stream/tests.rs 63924d279f5fdfde24d2e0663518a123dd673cc6ed9ce52233c4b6851bfb1efd src/system_stats/linux/mod.rs d9123c623e2a97d09e41edaa29993b4b2c8cba39a3abdf2b48a205aa9a9c2939 src/system_stats/linux/tests.rs -2c7da96822d605193f0208df70de670612259fd8c762c998ae828848cb210e11 src/system_stats/mod.rs +e0676801259fb09a73825d959b89630bfa0b6f1fd1efd3006af29f60537a2fdf src/system_stats/mod.rs 050aa831b1012c4aaade63863f433f01bfacf5d3f95787e57975572116a30ab2 src/system_stats/tests.rs -41ef9c9ed19a9a0c6742140abdb7c8b8d6d9edc0f828d0cb2ba54eec427696c8 src/tinyauth/mod.rs +6b955b13ef848e90dae1ec2906176336a775e9511f3688779203d922ff7d23a8 src/tinyauth/mod.rs 3d40febfbaf38ca2725630eed6b33106e353086081ea811107e4ec9d24f7de11 src/tinyauth/tests.rs df98becda799b6bf4ff5e2fec7d0e0a486e6b7eca6f9f5ad49ffdafd49a4f498 src/tls.rs 2af2164ecdf2b7fd9eef7c6e358dec053dfac9aedaa501dbb75df742d18638b2 src/tls/acme_tests.rs -84e3da17c59cc90e8575af8158ef4134cd058446a186df675d60106c53dd55ec src/tls/certificates.rs +93e0576ac9077e645a72d164bad34cd75fe6032dd0516284d08bddd2fbda4b7c src/tls/certificates.rs b59a62d723c8683ebd7bb2d2d777d9451064af20b254b98a8c4b9eedfd74bd86 src/tls/external_paths.rs -8d14216afdae33b9dffeb5145d92b8c6fd224c3f8f2baca0cf007b70541daa86 src/tls/external_paths_tests.rs -b705c6e5b2bd9eac2dd310478fdad1ca47610b80dd2d8c91516fb639b0dc2a5e src/tls/server_config.rs -7c0c06c3009c726043168ae47254a84642dbabbbce1b2e718544414312ca3e9b src/tls/tests.rs -d625e2e3f669484432ffc0bf7c1aba94aa5bb709dc4d080daa3bfa8004dee9f2 src/tls/types.rs +937b95bb8621ab98d7cad3d223e4dee305350fbd0149b7527396d3bdcc28a68b src/tls/external_paths_tests.rs +f02bb2775e662b2d75810f631ad3da4e0b56a64ae72b36c0edeae37e979b0fb5 src/tls/server_config.rs +94b70c52fdc7bd6e32fba9eed9124d030c692246f6990d61e01b891765275add src/tls/tests.rs +ce51a3280331d67c8406d2c5bc156ac0b492afa58e1f328bcda76db0720a6a9d src/tls/types.rs a535c8526b38de2990da7781e158b9ea70ef8c1f6fae0aac5ce6e9da21d3a309 src/traffic/mod.rs bd62b4ecdcf831d183c7720766a11703c8612d614e3abf38d2bff28df34710fd src/traffic/tests.rs 65fcac840f8b5f1508213e3f374aa318b450b0f9f90ba827c5ea935b9c60bee7 src/transform/apply.rs 34a06c2dc635e80690961ef61fe84110f9578f55adf89f92a04668783ccb0a98 src/transform/compile.rs -5a16b628af31fced425903a9d4b6ee83e1fe06ba023d574db16467baefe00ad3 src/transform/css.rs +ffc0740462e5566a96912ab3089f18fc28f1c093ca65eec860fc6ea801fd907d src/transform/css.rs 2b9d5c11d4c02b4a3ad3b9bb1a18c63693f245b854084f5eba16595df47b47f8 src/transform/html/mod.rs aa99b0d3029ef92c78371cb90e11f2026cce890d1be8e10ebd7fa9bc5cb13287 src/transform/html/tests.rs 047acd7209c1db19709801abbe3ee1076c1fc33f0fe80cc5d9b60669d5f1bae4 src/transform/js/mod.rs @@ -496,50 +496,50 @@ cf5195ab6bacccae0082da7b3f1bd0382b73fdeb3607f4ae569ca6daab9d10d0 src/transform/ b0d20cf6784bf31457ea5a853e2fa5d85f6452395a78a538392703d97e687a11 src/transform/json/mod.rs 216e24e29ea87be4a124f95f898e155f127fd959d7d5d9b30f8cd0b2b5018f30 src/transform/json/tests.rs d13fa462d6df516a4cfaee9f4e184044c5b6345e311aeb3d9058efafe54e4c5e src/transform/mod.rs -954b86300c3ea47281acb2f43b0771e838f83ac09eb55e882e42de26aee3128f src/transform/model.rs +1a56c6af6d48d52e2a73036b9bfa3cf16b5fa5b057593821e1229269b7ea4c08 src/transform/model.rs 4b52257e1a3643910b5e7043852d7218230c0021c31215d90d0f8e384f2ae233 src/transform/svg/mod.rs 0f53c64f442390ee20a59087a10f8f9ffb58053263e9aa848ddabfae1a6f3f51 src/transform/svg/tests.rs -88cd3729caaa48453d7f9e337052fa85c8a74498460a71e2dbe73245c78ccc8d src/transform/tests.rs +753047a1a731596f4c87351873c48f37b39f54e730795ff7d6895c3e2f0d56fb src/transform/tests.rs 8d38f2f7d4ca4dd453cd0e2337614ec704fbd0a36d3e2374cda43f05dfcad663 src/update_check/mod.rs c42d000d1d576721af3ffe930589388693001b8113554a7658f6dcbc665b5d1b src/update_check/tests.rs -b7ee3629a8fde0972bc1822b764f5fa2c813a760dd5dfb7f1ce643a7db8f9f75 src/upstream.rs +70e6cdf339da86183b4cd07567a4bb99135dccea5b0fb4a6c37c4098ec4762cd src/upstream.rs fe9d4deb34cea2408ac5a1030315989f29a6a7d078a46ac1b59d12aa07f67878 src/upstream/client.rs -165e048e2b302af28988c1a23f0e88c989c3cf093fc8b36547070871390d8376 src/upstream/client_tests.rs +80e9952b6a4b2241aff8ee259d286f0381b3c974032adc2c276764d14878fc67 src/upstream/client_tests.rs 56b1e4242f8153391cd9a01fbb3017a2dbda9d02e83da6bbfb39ed44854534b5 src/upstream/protocol_tests.rs ae880b40197454ea3fbf4d2113984dc372098b2e54b97e87c83cc319de253377 src/upstream/tests.rs 5c42f1befec267293b6f03eada118a11c90adfb4b32e0134d7f0033c7453c7cc src/user_agent/mod.rs 696a2783a24f7f3d69fb820dc3a772ec49ea46ee017087952a8253363a9b7374 src/user_agent/tests.rs d61c427c1e9cf457f1232b822d73e846e9f329cfbe61215e7ca061d53762155d src/vhost.rs 846707452ccf10962f1763be66b8f56c050b0398b48968015fb799dac289c0d6 src/vhost/error_fallback.rs -f0fed03e2067cc1d5fd081bd33ea90621e5012d99b76172c3d45c29a80d22f73 src/vhost/model.rs +a8c5ec5095178a0656a001b0d7100e240d1f32b052ea60df37f8681269638ab3 src/vhost/model.rs fc8af7342a3b147921e9040116af5dfe680b3df910db2416ada18198bbeea7df src/vhost/redirect.rs -dd6e121445402a2ac8744f0e4f23d879b4356f47c1bb00737bd15ba459661978 src/vhost/tests.rs -844b7170335403db30100af18eff182e0cfac327091a2fad466db6ace57c42f5 src/vhost/validation.rs +2651d7e3e3fadf3de74a75eafe2bbad8d02939551ccc0504c1a092a0b33b0db5 src/vhost/tests.rs +190b2bbdc7ef5181ae4dbe1659f6f0c7ead5d09936b109d0c5be7ea2e7edd681 src/vhost/validation.rs 8d1eff4b24f01d137414ef5419f75dd281879228adb70409645b11b3c78c20e9 src/web_server/config.rs 9744e8c29ae72a4d9fbc9a76166a97f8292c4c312295ea40d1cb699e03d80a9d src/web_server/config/compile.rs 5ed79fa5b9c53f95ca200d89a9ba927dda816b61fbdf3b852bd3c9670e3d2d4f src/web_server/config/model.rs a863ad1337fbc353c0f42caea4ac8b3644792e2aa08c37f992b7882b4b8dc6d5 src/web_server/config/validation.rs 86c744216d7132d8294a6e90ba9be5fd871646006313d1c62506d40c81db9b12 src/web_server/fastcgi.rs -002071ef603f1d928a56226b011c76ac5ea2bc55521a0a3fd317c0ced0056c21 src/web_server/fastcgi/protocol.rs +54aa2dc94df97faabd4859a9dafe6a7cb1ba3337bfef5fa51a4432097a985d88 src/web_server/fastcgi/protocol.rs 1ce9c3797f50cb9f27a945fffd1890d9d8e034048c8d4df93a3903905eec1207 src/web_server/fastcgi/response.rs 92d2ab4d7ecb766bbc705f8eba94c1ca33347ffb84b36e0ad0eccfed5915473f src/web_server/filesystem.rs d4fec3682532b0f2d11a0aefcd024f69fb623a39922ddb6c528a2d2de265347d src/web_server/htaccess.rs b4646f705e532f682d6152b6827cc7f4f915766ebcb4af89eba9950965673772 src/web_server/htaccess/model.rs -9c57b7c556984f79404d4afeb9841d0280a198b94c42d887bc6d96b7748a0e67 src/web_server/htaccess/parse.rs +a484113f2c1fea4e98a437acd8a28be6490d7e612f160a8728ceeaadd363f8ee src/web_server/htaccess/parse.rs 6e58e2674940bb0284035856fe9299a0555fee4c566de75e174deb373c3b164f src/web_server/htaccess/rewrite.rs 8ca74d9cc4470045301f70eb4a9fbabd0dbfe74434429148d2230df9f996c06c src/web_server/htaccess/tests.rs 7fe32af33b4d8e079ad724665aa1cfb62f6e98b9406b77168feaac55dac36137 src/web_server/metrics.rs -029bc2b277b2fd7528d5c9f5637453ce6d1d3ec2df7e441f6844cf55edef6b5b src/web_server/mod.rs +0f74d1fad9bc20a5f2ea1753381459c072cd8068cb13e7dfbc74e33eb3ace593 src/web_server/mod.rs e9ba6e09ba2c8f8a33410c219d5ea29214cc53e279a43b17c110d24f17a5a71d src/web_server/request.rs d98c80663847d6d83073df074ebff65952755415574708abcc153bd8d3adfaa9 src/web_server/response.rs -814d677c56c946d40031cd8228fc3c52061ad0b76fa5aa9d53063d265f82e3d8 src/web_server/response/file.rs +fd92b4852a3a974bc27812a215f9f41c763221a0699a11d8bf64e55687f24904 src/web_server/response/file.rs 9197a21a6b22da430e26bda2d1c899dcb64ae615069458f75547d4754a8044c3 src/web_server/response/file_body/mod.rs e5f5fbe702f66bfcbcbc1d1f3cd3d89bdbb929bb86aed94868a2a06a4b9e59e3 src/web_server/response/file_body/tests.rs c63a5935bf8e101f1813e029dd62da7d073eb598453ee98aafe9ffa6f85157c2 src/web_server/response/headers.rs da7c69cd4b26289170d6d89ae3a6d41488fdca3f184cd8f63a160217fa26e49d src/web_server/routing.rs 9e584eb4455a18641c2c89f9e9725745c93f32877abf0f0f2fbea9e3d5ef38c3 src/web_server/security.rs b791762b4ab7a43c3be19df84e523faf3dfb4df055f66d02178f93edb60d2204 src/web_server/serve.rs -642ac020745d963554ab382aa755b8f24d140f5f6b8ea8e7c74da59614f1cfe0 src/web_server/tests.rs +714418c86ab241020a561fcf662a8fe324a6a70cc2fd3a313c56d7939c14b9e8 src/web_server/tests.rs f00743af2cf4a5952caef7a1dcf68e7817b5599a252a9973e1c22045517c0b0e tests/fixtures/argon2id.json e24a0f84f1bfc46964df865ac387d651f7b384d781cbc8f3a9a5b0b60f3d69ca tests/vhost_auth_selection.js eca68a07c2b1e45fdb8f512294f5a432db3ae5a6aec8fb200b47f155cd9a31d6 tools/README.md diff --git a/build-release.sh b/build-release.sh new file mode 100644 index 0000000..ddbd543 --- /dev/null +++ b/build-release.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +cd "$ROOT" + +DIST="$ROOT/dist" +CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/target}" +export CARGO_TARGET_DIR + +fail() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +need() { + command -v "$1" >/dev/null 2>&1 || fail "$1 is required" +} + +need cargo +need rustc +need python3 +need sha256sum + +python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3,10) else 1)' \ + || fail 'Python 3.10+ is required' + +[[ -f Cargo.toml ]] || fail 'run this script from the RustProxy project root' +[[ -f Cargo.lock ]] || fail 'Cargo.lock is required for a reproducible release' +[[ -f scripts/preflight.py ]] || fail 'scripts/preflight.py is missing' + +VERSION="$(python3 scripts/version.py)" +HOST="$(rustc -vV | sed -n 's/^host: //p')" +[[ -n "$VERSION" ]] || fail 'could not determine project version' +[[ -n "$HOST" ]] || fail 'could not determine Rust host target' + +printf 'RustProxy %s release build for %s\n' "$VERSION" "$HOST" +python3 scripts/preflight.py + +cargo fmt --all -- --check +cargo test --locked --all-targets +cargo clippy --locked --all-targets -- -D warnings +rm -rf "$CARGO_TARGET_DIR/release" +cargo build --locked --release + +BIN="$CARGO_TARGET_DIR/release/rust-proxy" +[[ -x "$BIN" ]] || fail "release binary was not created: $BIN" + +ACTUAL_VERSION="$($BIN --version)" +printf 'Built: %s\n' "$ACTUAL_VERSION" + +rm -rf "$DIST" +mkdir -p "$DIST" + +ARTIFACT="rust-proxy-${VERSION}-${HOST}" +cp "$BIN" "$DIST/$ARTIFACT" +chmod 0755 "$DIST/$ARTIFACT" +sha256sum "$DIST/$ARTIFACT" > "$DIST/$ARTIFACT.sha256" + +SOURCE_ZIP="$DIST/RustProxy-${VERSION}-source.zip" +python3 make_zip.py "$SOURCE_ZIP" +sha256sum "$SOURCE_ZIP" > "$SOURCE_ZIP.sha256" + +printf '\nRelease ready:\n' +printf ' binary: %s\n' "$DIST/$ARTIFACT" +printf ' checksum: %s\n' "$DIST/$ARTIFACT.sha256" +printf ' source zip: %s\n' "$SOURCE_ZIP" +printf ' checksum: %s\n' "$SOURCE_ZIP.sha256" diff --git a/build.rs b/build.rs index 2c66062..5076d3d 100644 --- a/build.rs +++ b/build.rs @@ -8,7 +8,10 @@ fn panel_styles( println!("cargo:rerun-if-env-changed=RUST_PROXY_PYTHON"); let helper = root.join("scripts/build/panel_css.py"); println!("cargo:rerun-if-changed={}", helper.display()); - println!("cargo:rerun-if-changed={}", root.join("src/admin/web/css").display()); + println!( + "cargo:rerun-if-changed={}", + root.join("src/admin/web/css").display() + ); let interpreters = match env::var_os("RUST_PROXY_PYTHON") { Some(value) if !value.is_empty() => vec![value], _ => vec!["python3".into(), "python".into()], diff --git a/docs/ACCESS.md b/docs/ACCESS.md index 615893e..2b98abd 100644 --- a/docs/ACCESS.md +++ b/docs/ACCESS.md @@ -43,7 +43,7 @@ New Vhosts store an explicit `authentication` choice; old Vhosts with only `tiny - Point the verifier URL **directly** at the private Tinyauth service (for example `http://tinyauth:3000/api/auth/nginx`), rather than routing it through a second public proxy that might inject conflicting headers. Do not disable Tinyauth's header-spoofing protections. Confirm that Tinyauth trusts only the actual RustProxy address/network when enabling Tinyauth IP-based access controls (`TINYAUTH_AUTH_TRUSTEDPROXIES`). - **Authelia (0.34.15):** select **Authelia → Forward Auth** under **Vhost → Authentication**. Enter the internal service origin (for example `http://authelia:9091`) and the public HTTPS login portal (for example `https://auth.example.com`); RustProxy calls `/api/authz/forward-auth`. It forwards trusted `X-Forwarded-*`, `X-Original-URL` and `X-Original-Method` metadata, verifies the provider result, returns provider-approved `302/303` login redirects only to the configured portal origin, and forwards configured `Remote-*` identity headers after stripping client-forged values. A `200` verifier response alone grants access. `401`/`403` deny; transport failures return 502 and timeouts 504. The Authelia `session.cookies` domains and `authelia_url` are defined in Authelia itself: protecting **unrelated registered domains** generally requires a login URL/cookie configuration within each domain, not one cookie from a single unrelated site. - **authentik (0.34.15):** select **authentik → Forward Auth** and enter the private outpost origin (for example `http://authentik:9000`), not an `/if/flow` login URL. RustProxy calls `/outpost.goauthentik.io/auth/nginx`, forwards verified `X-Authentik-*` identity headers, and responds to a `401` with a same-origin login start at `/outpost.goauthentik.io/start?rd=...`. RustProxy reserves `/outpost.goauthentik.io` (and descendants) on the protected application hostname and forwards them to the configured outpost **without checking authentication again**, after ordinary global/Vhost IP/country/rate rules. No manual Custom Location is needed for that path. In authentik create a separate **Proxy Provider → Forward auth (single application)** for each protected external application URL, assign it to the serving outpost, then enable that Vhost's integration. Multiple unrelated domains can share the same authentik installation; they do **not** share a browser cookie across domains. -- **OIDC (0.34.16, opt-in):** choose **Authelia → OIDC**, **authentik → OIDC**, **Pocket ID → OIDC** or **Other OIDC provider → OIDC** for the protected Vhost. This switches to the shared OIDC implementation instead of the vendor's Forward Auth adapter. Provide the exact HTTPS **Issuer**, Client ID and confidential Client Secret, a strong unique Session Secret (`openssl rand -hex 32`), and `https://YOUR-EXACT-APP-HOST/_rustproxy/oidc/callback` as the **Redirect URL**. Register that exact callback as an allowed redirect URI in the identity provider. The issuer can be `https://auth.gruszczynski.cc` while the protected hostname is `app.linuxiarz.pl`; browser cookies do not cross between them. The issuer's discovery `/.well-known/openid-configuration`, authorization, token and JWKS endpoints must resolve from RustProxy and use the issuer's HTTPS origin with trusted TLS. Use split DNS or the private-address opt-in if the issuer resolves to internal addresses; do not disable HTTPS certificate validation. OIDC currently requires a confidential client using `client_secret_basic`, authorization-code flow, and an RS256 or ES256 signing key. Public PKCE-only clients are not supported. +- **OIDC (0.34.16, opt-in):** choose **Authelia → OIDC**, **authentik → OIDC**, **Pocket ID → OIDC** or **Other OIDC provider → OIDC** for the protected Vhost. This switches to the shared OIDC implementation instead of the vendor's Forward Auth adapter. Provide the exact HTTPS **Issuer**, Client ID and confidential Client Secret, a strong unique Session Secret (`openssl rand -hex 32`), and `https://YOUR-EXACT-APP-HOST/_rustproxy/oidc/callback` as the **Redirect URL**. Register that exact callback as an allowed redirect URI in the identity provider. The issuer can be `https://auth.example.com.cc` while the protected hostname is `app.linuxiarz.pl`; browser cookies do not cross between them. The issuer's discovery `/.well-known/openid-configuration`, authorization, token and JWKS endpoints must resolve from RustProxy and use the issuer's HTTPS origin with trusted TLS. Use split DNS or the private-address opt-in if the issuer resolves to internal addresses; do not disable HTTPS certificate validation. OIDC currently requires a confidential client using `client_secret_basic`, authorization-code flow, and an RS256 or ES256 signing key. Public PKCE-only clients are not supported. - **OIDC session and identity:** RustProxy generates short-lived transaction state with S256 PKCE and nonce; requires a signed ID token with exact issuer, audience, validity and nonce; and encrypts a separate `__Host-` session cookie for the application host (default 3600 seconds, max 86400). The session is **not continuously revalidated with the issuer**; removing a user at the issuer does not invalidate an already-issued RustProxy session before its expiry or local logout (`/_rustproxy/oidc/logout`). Successful login injects `Remote-Sub`, `Remote-Issuer`, `Remote-User` and optional `Remote-Email`, `Remote-Name`, `Remote-Groups`; use `(Remote-Issuer, Remote-Sub)` for stable account identity and do not auto-link accounts solely by email or username. The backend must not be directly accessible from untrusted clients. Non-GET/HEAD requests without a valid session receive 401, and authentication endpoint failures return 502/504. The provider URL must not itself be protected by this Vhost's OIDC integration. For Authelia, enabling its Forward Auth portal alone is insufficient: also configure Authelia as an OIDC issuer under `identity_providers.oidc`, with at least one signing key, and register a confidential client for every callback URL (or separate clients per application). OIDC does not require any special support from the application being proxied, only from the remote identity provider. - **Adding another provider:** Register the vendor and supported methods in `auth_provider::choice::IdentityProvider` and in the Vhost editor. OIDC-compatible vendors reuse the shared `oidc` integration and may need no new Rust transport adapter. New Forward Auth adapters belong in `src//mod.rs` with tests and may reuse `forward_auth::Adapter`; their compiled implementation implements the object-safe `auth_provider::Provider` trait. Runtime request handling continues through one `CompiledFrontend.auth_provider` slot and provider-neutral `error_auth_provider.html`. Preserve legacy Vhost configuration keys; add new providers without copying OIDC protocol code or HTML error templates. @@ -80,7 +80,7 @@ For central login from an unrelated application domain, select **Authelia → OI "authentication": {"provider": "authelia", "method": "oidc"}, "oidc": { "enabled": true, - "issuer_url": "https://auth.gruszczynski.cc", + "issuer_url": "https://auth.example.com.cc", "backend_url": "http://10.0.0.20:9091", "redirect_url": "https://app.linuxiarz.pl/_rustproxy/oidc/callback", "client_id": "app-client-id", diff --git a/docs/CUSTOM_LOCATIONS.md b/docs/CUSTOM_LOCATIONS.md index 66a590e..4874202 100644 --- a/docs/CUSTOM_LOCATIONS.md +++ b/docs/CUSTOM_LOCATIONS.md @@ -11,7 +11,7 @@ Custom locations add exceptions to a Vhost's normal source. They are **not** req | `regex` / `regex_insensitive` | Case-sensitive or insensitive regular expression | Tested in configured order after priority-prefix rules | | `prefix` | Ordinary longest matching path prefix | Fallback among matching rules | -Ambiguous and unsafe encoded path forms are rejected rather than treated as alternative spellings. Avoid overlapping regex rules when order matters; test paths with and without trailing `/`. A Vhost's main source handles paths without a matching custom location. +Encoded `/` separators (`%2F` / `%2f`) are normalized to real `/` only for location matching and security policy. Other ambiguous or unsafe encoded path forms remain rejected. In `preserve` rewrite mode the original encoded request target is forwarded unchanged to the upstream, so `/a%2Fb?x=1` is matched as `/a/b` but reaches the backend as `/a%2Fb?x=1`. Rewritten modes generate a new normalized target instead. Avoid overlapping regex rules when order matters; test paths with and without trailing `/`. ## Actions diff --git a/docs/README.md b/docs/README.md index a24ea85..31a9125 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # RustProxy documentation -Practical guides for **RustProxy 0.34.16**. This index and the listed pages are also available in the authenticated panel at **`/docs → Reader`**. The adjacent **API** tab provides the embedded OpenAPI reference. Paths below are links to actual files in the source archive. +Practical guides for **RustProxy 0.35.0**. This index and the listed pages are also available in the authenticated panel at **`/docs → Reader`**. The adjacent **API** tab provides the embedded OpenAPI reference. Paths below are links to actual files in the source archive. ## Getting started @@ -22,7 +22,7 @@ Practical guides for **RustProxy 0.34.16**. This index and the listed pages are - [Experimental TLS post-quantum key exchange](TLS_POST_QUANTUM.md) — opt-in X25519MLKEM768, compatibility, rollback and validation. - [TCP/UDP Streams](STREAMS.md) — non-HTTP forwarding and its security boundaries. -The Vhosts and Docker discovery guides include the shared adaptive request-rate chart scale introduced in 0.34.11. Version 0.34.12 preserves that adaptive range and adds a disabled-by-default X/Y scale display option under **Settings → Appearance → Charts**. Release 0.34.13 adds global HTTP security policies and UI layout improvements, groups the global User-Agent policy under `global_security`, provides consistent section-based GET/PUT APIs, and moves manual GeoIP refresh to `POST /api/config/geoip/update`. See [Dashboard](DASHBOARD.md). Release 0.34.14 keeps the Tinyauth Nginx-header compatibility patch and dedicated fail-closed 502/504 pages. Release 0.34.15 adds optional Authelia and authentik forward-auth integrations, a shared provider runtime and a single provider-neutral HTML error template. Release 0.34.16 adds optional OIDC central login across unrelated domains. See [Access](ACCESS.md) and [Troubleshooting](TROUBLESHOOTING.md). +The Vhosts and Docker discovery guides include the shared adaptive request-rate chart scale introduced in 0.34.11. Version 0.34.12 preserves that adaptive range and adds a disabled-by-default X/Y scale display option under **Settings → Appearance → Charts**. Release 0.34.13 adds global HTTP security policies and UI layout improvements, groups the global User-Agent policy under `global_security`, provides consistent section-based GET/PUT APIs, and moves manual GeoIP refresh to `POST /api/config/geoip/update`. See [Dashboard](DASHBOARD.md). Release 0.34.14 keeps the Tinyauth Nginx-header compatibility patch and dedicated fail-closed 502/504 pages. Release 0.34.15 adds optional Authelia and authentik forward-auth integrations, a shared provider runtime and a single provider-neutral HTML error template. Release 0.34.16 adds optional OIDC central login across unrelated domains. Release 0.35.0 accepts encoded `/` (`%2F` / `%2f`) in proxied paths while keeping normalized routing/security decisions separate from the original Preserve-mode upstream request target. See [Access](ACCESS.md) and [Troubleshooting](TROUBLESHOOTING.md). ## Integrations and operations diff --git a/src/acl.rs b/src/acl.rs index 2fe32d9..fbf14a1 100644 --- a/src/acl.rs +++ b/src/acl.rs @@ -264,13 +264,19 @@ pub fn routing_path(raw: &str) -> Result { let high = digit(bytes[index + 1]).context("invalid path escape")?; let low = digit(bytes[index + 2]).context("invalid path escape")?; let decoded = (high << 4) | low; - ensure!( - decoded >= 32 - && decoded != 127 - && !matches!(decoded, b'/' | b'\\' | b'%' | b'?' | b'#' | b';'), - "ambiguous encoded path character" - ); - out.push(decoded); + if decoded == b'/' { + if out.last().copied() != Some(b'/') { + out.push(b'/'); + } + } else { + ensure!( + decoded >= 32 + && decoded != 127 + && !matches!(decoded, b'\\' | b'%' | b'?' | b'#' | b';'), + "ambiguous encoded path character" + ); + out.push(decoded); + } index += 3; } else { ensure!( diff --git a/src/acl/dynamic/mod.rs b/src/acl/dynamic/mod.rs index d9d55b2..ec5423f 100644 --- a/src/acl/dynamic/mod.rs +++ b/src/acl/dynamic/mod.rs @@ -37,15 +37,34 @@ pub(crate) fn matches(name: &str, ip: IpAddr) -> bool { pub(crate) fn hostname(value: &str) -> Result { let candidate = value.strip_suffix('.').unwrap_or(value); - ensure!(candidate.len() <= 253 && candidate.contains('.'), "ACL DNS name requires a fully qualified hostname"); - ensure!(candidate.is_ascii() && !candidate.contains('/') && !candidate.contains(':'), "invalid ACL DNS name"); + ensure!( + candidate.len() <= 253 && candidate.contains('.'), + "ACL DNS name requires a fully qualified hostname" + ); + ensure!( + candidate.is_ascii() && !candidate.contains('/') && !candidate.contains(':'), + "invalid ACL DNS name" + ); let labels: Vec<_> = candidate.split('.').collect(); - ensure!(labels.last().is_some_and(|part| part.chars().any(|ch| ch.is_ascii_alphabetic())), "invalid ACL DNS name suffix"); + ensure!( + labels + .last() + .is_some_and(|part| part.chars().any(|ch| ch.is_ascii_alphabetic())), + "invalid ACL DNS name suffix" + ); for label in labels { - ensure!(!label.is_empty() && label.len() <= 63, "invalid ACL DNS label length"); - ensure!(label.as_bytes()[0].is_ascii_alphanumeric() - && label.as_bytes()[label.len() - 1].is_ascii_alphanumeric() - && label.bytes().all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'), "invalid ACL DNS label"); + ensure!( + !label.is_empty() && label.len() <= 63, + "invalid ACL DNS label length" + ); + ensure!( + label.as_bytes()[0].is_ascii_alphanumeric() + && label.as_bytes()[label.len() - 1].is_ascii_alphanumeric() + && label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'), + "invalid ACL DNS label" + ); } Ok(candidate.to_ascii_lowercase()) } @@ -55,27 +74,42 @@ pub(crate) fn configured_hostnames(config: &Config) -> Result> { let mut inspect = |acl: Option<&crate::acl::Acl>| -> Result<()> { if let Some(acl) = acl { for rule in &acl.rules { - if rule.network.parse::().is_ok() || rule.network.parse::().is_ok() { + if rule.network.parse::().is_ok() + || rule.network.parse::().is_ok() + { continue; } let name = hostname(&rule.network)?; - ensure!(rule.action == crate::acl::Action::Allow, - "dynamic DNS ACL rules only support allow; use static IP/CIDR for deny"); + ensure!( + rule.action == crate::acl::Action::Allow, + "dynamic DNS ACL rules only support allow; use static IP/CIDR for deny" + ); names.insert(name); - ensure!(names.len() <= MAX_NAMES, "too many distinct dynamic DNS ACL names (max 128)"); + ensure!( + names.len() <= MAX_NAMES, + "too many distinct dynamic DNS ACL names (max 128)" + ); } } Ok(()) }; inspect(config.global_security.acl.as_ref())?; - for policy in &config.host_policies { inspect(Some(&policy.acl))?; } - for profile in &config.acl_profiles { inspect(profile.acl.as_ref())?; } + for policy in &config.host_policies { + inspect(Some(&policy.acl))?; + } + for profile in &config.acl_profiles { + inspect(profile.acl.as_ref())?; + } for vhost in &config.vhosts { inspect(vhost.acl.as_ref())?; - for route in &vhost.routes { inspect(route.acl.as_ref())?; } + for route in &vhost.routes { + inspect(route.acl.as_ref())?; + } } for agent in &config.docker_agents.agents { - for assignment in &agent.assignments { inspect(assignment.acl.as_ref())?; } + for assignment in &agent.assignments { + inspect(assignment.acl.as_ref())?; + } } Ok(names.into_iter().collect()) } @@ -85,7 +119,8 @@ async fn resolve(name: String) -> (String, Option) { let answer = tokio::time::timeout( Duration::from_secs(LOOKUP_SECONDS), tokio::net::lookup_host((lookup_name.as_str(), 0u16)), - ).await; + ) + .await; let mut ips = match answer { Ok(Ok(sockets)) => sockets.map(|socket| socket.ip()).collect::>(), _ => { @@ -95,21 +130,29 @@ async fn resolve(name: String) -> (String, Option) { }; ips.sort_unstable(); ips.dedup(); - if ips.is_empty() || ips.len() > MAX_ADDRESSES || ips.iter().any(|ip| { - matches!(ip, IpAddr::V6(v6) if v6.to_ipv4_mapped().is_some()) - }) { + if ips.is_empty() + || ips.len() > MAX_ADDRESSES + || ips + .iter() + .any(|ip| matches!(ip, IpAddr::V6(v6) if v6.to_ipv4_mapped().is_some())) + { tracing::warn!(hostname = %name, "dynamic ACL DNS returned an invalid or excessive address set"); return (name, None); } - (name, Some(Addresses { ips, valid_until: Instant::now() + Duration::from_secs(MAX_AGE_SECONDS) })) + ( + name, + Some(Addresses { + ips, + valid_until: Instant::now() + Duration::from_secs(MAX_AGE_SECONDS), + }), + ) } -pub(crate) fn config_changed() { CHANGED.notify_one(); } +pub(crate) fn config_changed() { + CHANGED.notify_one(); +} -pub(crate) async fn poll_loop( - app: Weak, - shutdown: CancellationToken, -) { +pub(crate) async fn poll_loop(app: Weak, shutdown: CancellationToken) { let mut interval = tokio::time::interval(Duration::from_secs(REFRESH_SECONDS)); interval.set_missed_tick_behavior(MissedTickBehavior::Skip); loop { @@ -118,9 +161,13 @@ pub(crate) async fn poll_loop( _ = interval.tick() => {}, _ = CHANGED.notified() => {}, } - let Some(state) = app.upgrade() else { break; }; + let Some(state) = app.upgrade() else { + break; + }; let snapshot = state.config.load(); - let Ok(names) = configured_hostnames(&snapshot.envelope.config) else { continue; }; + let Ok(names) = configured_hostnames(&snapshot.envelope.config) else { + continue; + }; drop(snapshot); let results: HashMap<_, _> = tokio::select! { _ = shutdown.cancelled() => break, diff --git a/src/acl/tests.rs b/src/acl/tests.rs index f5df869..707b560 100644 --- a/src/acl/tests.rs +++ b/src/acl/tests.rs @@ -207,13 +207,29 @@ mod tests { routing_path("/static/js//events.js").unwrap(), "/static/js/events.js" ); + for (path, expected) in [ + ("/x%2Fy", "/x/y"), + ("/x%2fy", "/x/y"), + ("/x%2F%2Fy", "/x/y"), + ("/x//y", "/x/y"), + ("/%2Ffoo", "/foo"), + ] { + assert_eq!(routing_path(path).unwrap(), expected, "{path}"); + } for path in [ "/%2e%2e/secret", "/a/%2E%2E/b", - "/a%2fb", + "/x%2F..%2Fy", + "/x%2F%2E%2E%2Fy", "/a%5cb", + "/%5Cadmin", + "/%25admin", + "/%3Fadmin", + "/%23admin", + "/%3Badmin", "/bad%", "/bad%2", + "/%00", ] { assert!(routing_path(path).is_err(), "{path}"); } @@ -239,10 +255,12 @@ mod tests { "/static/js/events.js" ); assert_eq!(routing_path("//admin///users").unwrap(), "/admin/users"); + assert_eq!(routing_path("/%2fadmin").unwrap(), "/admin"); for path in [ "/api/../admin", "/api/%2e%2e/admin", - "/%2fadmin", + "/api%2F..%2Fadmin", + "/api%2F%2E%2E%2Fadmin", "/%5cadmin", "/%2561dmin", "/%00", @@ -349,7 +367,7 @@ mod tests { for peer in ["10.1.2.3", "203.0.113.5"] { let peer = peer.parse().unwrap(); assert_eq!( - allows_profiles_with_country(&[single.clone()], peer, country), + allows_profiles_with_country(std::slice::from_ref(&single), peer, country), allows_with_country(single.acl.as_ref(), &single.countries, peer, country), ); } diff --git a/src/admin/certificates.rs b/src/admin/certificates.rs index e51e38e..43ea3b5 100644 --- a/src/admin/certificates.rs +++ b/src/admin/certificates.rs @@ -194,7 +194,7 @@ async fn certificate_deploy( ) .await .map_err(auth::internal)?; - return Ok(Json(json!({ + Ok(Json(json!({ "deployed": true, "operation": "metadata-updated", "created": false, @@ -207,7 +207,7 @@ async fn certificate_deploy( "public_tls": true, "http3_new_handshakes": state.config.load().quic_tls.is_some(), "administration_restart_required": false - }))); + }))) } crate::tls::ExternalPairDeployment::Staged { cert_file, diff --git a/src/admin/dashboard_presets.rs b/src/admin/dashboard_presets.rs index 379989a..94eac74 100644 --- a/src/admin/dashboard_presets.rs +++ b/src/admin/dashboard_presets.rs @@ -62,10 +62,12 @@ async fn dashboard_presets( tracing::warn!("skipped invalid saved Overview preset"); continue; }; - let mut prefs = AdminPreferences::default(); - prefs.dashboard_widgets = layout.dashboard_widgets; - prefs.dashboard_hidden_widgets = layout.dashboard_hidden_widgets; - prefs.dashboard_widget_sizes = layout.dashboard_widget_sizes; + let mut prefs = AdminPreferences { + dashboard_widgets: layout.dashboard_widgets, + dashboard_hidden_widgets: layout.dashboard_hidden_widgets, + dashboard_widget_sizes: layout.dashboard_widget_sizes, + ..Default::default() + }; reconcile_dashboard_widgets(&mut prefs); layout.dashboard_widgets = prefs.dashboard_widgets; layout.dashboard_hidden_widgets = prefs.dashboard_hidden_widgets; diff --git a/src/admin/file_logs_query.rs b/src/admin/file_logs_query.rs index dbd3e4c..8da6401 100644 --- a/src/admin/file_logs_query.rs +++ b/src/admin/file_logs_query.rs @@ -182,7 +182,7 @@ fn validate_file_log_query(query: &FileLogQuery) -> Result<(), ApiError> { .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) + .is_some_and(|value| !(2..=FILE_LOG_SCAN_LINES).contains(&value)) || query.offset.unwrap_or(0) > FILE_LOG_MAX_OFFSET || query .limit @@ -233,7 +233,7 @@ async fn file_logs_query( )); } } else { - sources.sort_by(|a, b| b.modified_ms.cmp(&a.modified_ms)); + sources.sort_by_key(|source| std::cmp::Reverse(source.modified_ms)); if sources.len() > FILE_LOG_MAX_SOURCES { truncated = true; sources.truncate(FILE_LOG_MAX_SOURCES); diff --git a/src/admin/openapi.json b/src/admin/openapi.json index 0b7d6cc..382333a 100644 --- a/src/admin/openapi.json +++ b/src/admin/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.0.3", "info": { "title": "RustProxy Administration API", - "version": "0.34.16", + "version": "0.35.0", "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. The API also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only." }, "servers": [ diff --git a/src/admin/preferences_tests.rs b/src/admin/preferences_tests.rs index def3e47..3b41cb0 100644 --- a/src/admin/preferences_tests.rs +++ b/src/admin/preferences_tests.rs @@ -86,18 +86,20 @@ fn corner_style_defaults_and_rejects_unknown_presets() { #[test] fn old_widget_preferences_preserve_order_opt_in_and_survive_unknown_ids() { - let mut prefs = AdminPreferences::default(); - prefs.dashboard_widgets = vec![ - "errors".into(), - "from_future_release".into(), - "requests".into(), - "errors".into(), - ]; - prefs.dashboard_hidden_widgets = vec![ - "errors".into(), - "from_future_release".into(), - "errors".into(), - ]; + let mut prefs = AdminPreferences { + dashboard_widgets: vec![ + "errors".into(), + "from_future_release".into(), + "requests".into(), + "errors".into(), + ], + dashboard_hidden_widgets: vec![ + "errors".into(), + "from_future_release".into(), + "errors".into(), + ], + ..Default::default() + }; reconcile_dashboard_widgets(&mut prefs); assert_eq!(prefs.dashboard_widgets[0], "errors"); assert_eq!(prefs.dashboard_widgets[1], "requests"); @@ -210,9 +212,11 @@ fn saved_dashboard_order_and_visibility_survive_new_factory_preset() { #[test] fn explicit_visible_stream_opt_in_is_not_reset_when_normalizing_saved_order() { - let mut prefs = AdminPreferences::default(); - prefs.dashboard_widgets = default_admin_dashboard_widgets(); - prefs.dashboard_hidden_widgets.clear(); + let mut prefs = AdminPreferences { + dashboard_widgets: default_admin_dashboard_widgets(), + dashboard_hidden_widgets: Vec::new(), + ..Default::default() + }; reconcile_dashboard_widgets(&mut prefs); assert!(prefs.dashboard_hidden_widgets.is_empty()); assert!(prefs.dashboard_widgets.contains(&"cache_outcomes".into())); @@ -224,8 +228,10 @@ fn explicit_visible_stream_opt_in_is_not_reset_when_normalizing_saved_order() { fn dashboard_width_preferences_preserve_legacy_and_allow_custom_grids() { let old: AdminPreferences = serde_json::from_str(r#"{"theme":"dark"}"#).unwrap(); assert!(old.dashboard_widget_sizes.is_none()); - let mut prefs = AdminPreferences::default(); - prefs.dashboard_widget_sizes = Some(std::collections::BTreeMap::new()); + let mut prefs = AdminPreferences { + dashboard_widget_sizes: Some(std::collections::BTreeMap::new()), + ..Default::default() + }; assert!(validate_admin_preferences(&prefs).is_ok()); for (id, span) in [ ("http_protocol", 4), diff --git a/src/admin/tests.rs b/src/admin/tests.rs index f54fd82..95a6702 100644 --- a/src/admin/tests.rs +++ b/src/admin/tests.rs @@ -461,6 +461,7 @@ mod tests { let (router, store, _dir, secret, _) = fixture().await; let old = request("/api/status") .header("authorization", format!("Bearer {}", "a".repeat(64))) + .extension(PeerAddr("127.0.0.1:45000".parse().unwrap())) .body(Body::empty()) .unwrap(); assert_eq!( @@ -1645,6 +1646,7 @@ mod tests { .method("POST") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") + .extension(PeerAddr("127.0.0.1:45000".parse().unwrap())) .body(Body::from("{}")) .unwrap(), ) @@ -1663,7 +1665,11 @@ mod tests { ) .await .unwrap(); - assert_eq!(forbidden_scope.status(), StatusCode::UNAUTHORIZED); + assert_eq!(forbidden_scope.status(), StatusCode::FORBIDDEN); + assert_eq!( + json_body(forbidden_scope).await, + json!({"error":"certificate_api.invalid_route"}) + ); let revoked = router .clone() @@ -1804,7 +1810,11 @@ mod tests { ) .await .unwrap(); - assert_eq!(denied.status(), StatusCode::UNAUTHORIZED); + assert_eq!(denied.status(), StatusCode::FORBIDDEN); + assert_eq!( + json_body(denied).await, + json!({"error":"certificate_api.invalid_route"}) + ); let no_csrf = router .clone() .oneshot( @@ -2774,6 +2784,7 @@ mod tests { request(&path) .header("cookie", cookie) .header("authorization", format!("Bearer {secret}")) + .extension(PeerAddr("127.0.0.1:45000".parse().unwrap())) .body(Body::empty()) .unwrap(), ) @@ -3953,7 +3964,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(old_route.status(), StatusCode::METHOD_NOT_ALLOWED); + assert_eq!(old_route.status(), StatusCode::NOT_FOUND); store.close().await; } @@ -4103,7 +4114,10 @@ mod tests { .await .unwrap(); assert_eq!(summary.status(), StatusCode::OK); - assert_eq!(summary.headers()["cache-control"], "no-store"); + assert_eq!( + summary.headers()["cache-control"], + "private, no-store, no-cache, no-transform" + ); let summary = json_body(summary).await; assert!(summary.get("prometheus").is_none()); assert_eq!( diff --git a/src/auth_provider/choice.rs b/src/auth_provider/choice.rs index 321a8c8..77ff708 100644 --- a/src/auth_provider/choice.rs +++ b/src/auth_provider/choice.rs @@ -58,7 +58,10 @@ pub struct Choice { } impl Choice { pub fn validate(self) -> Result<()> { - ensure!(self.provider.supports(self.method), "unsupported authentication provider and integration method combination"); + ensure!( + self.provider.supports(self.method), + "unsupported authentication provider and integration method combination" + ); Ok(()) } } @@ -69,7 +72,11 @@ mod tests { #[test] fn supported_methods_are_explicit_and_reject_invalid_pairs() { - for provider in [IdentityProvider::Tinyauth, IdentityProvider::Authelia, IdentityProvider::Authentik] { + for provider in [ + IdentityProvider::Tinyauth, + IdentityProvider::Authelia, + IdentityProvider::Authentik, + ] { assert!(provider.supports(IntegrationMethod::ForwardAuth)); assert!(provider.supports(IntegrationMethod::Oidc)); } @@ -84,11 +91,25 @@ mod tests { #[test] fn selection_serializes_as_independent_provider_and_method() { - let selection = Choice { provider: IdentityProvider::Authelia, method: IntegrationMethod::Oidc }; + let selection = Choice { + provider: IdentityProvider::Authelia, + method: IntegrationMethod::Oidc, + }; let json = serde_json::to_value(selection).unwrap(); - assert_eq!(json, serde_json::json!({"provider":"authelia","method":"oidc"})); + assert_eq!( + json, + serde_json::json!({"provider":"authelia","method":"oidc"}) + ); assert_eq!(serde_json::from_value::(json).unwrap(), selection); - assert!(serde_json::from_value::(serde_json::json!({"provider":"pocket_id","method":"forward_auth"})).unwrap().validate().is_err()); - assert!(serde_json::from_value::(serde_json::json!({"provider":"unknown","method":"oidc"})).is_err()); + assert!(serde_json::from_value::( + serde_json::json!({"provider":"pocket_id","method":"forward_auth"}) + ) + .unwrap() + .validate() + .is_err()); + assert!(serde_json::from_value::( + serde_json::json!({"provider":"unknown","method":"oidc"}) + ) + .is_err()); } } diff --git a/src/auth_provider/mod.rs b/src/auth_provider/mod.rs index b46ccd4..43bf820 100644 --- a/src/auth_provider/mod.rs +++ b/src/auth_provider/mod.rs @@ -88,6 +88,7 @@ impl Compiled { ) -> Option> { self.0.serve_reserved(request, context, shutdown) } + #[allow(clippy::result_large_err)] pub async fn authorize( &self, incoming: &HeaderMap, @@ -130,7 +131,9 @@ impl Selection<'_> { if let Some(choice) = self.choice { choice.validate()?; let matches_configuration = match choice.method { - IntegrationMethod::Basic => self.basic_enabled && choice.provider == IdentityProvider::Basic, + IntegrationMethod::Basic => { + self.basic_enabled && choice.provider == IdentityProvider::Basic + } IntegrationMethod::ForwardAuth => match choice.provider { IdentityProvider::Tinyauth => self.tinyauth.enabled, IdentityProvider::Authelia => self.authelia.enabled, @@ -139,7 +142,10 @@ impl Selection<'_> { }, IntegrationMethod::Oidc => self.oidc.enabled, }; - ensure!(matches_configuration, "authentication selection does not match the enabled settings"); + ensure!( + matches_configuration, + "authentication selection does not match the enabled settings" + ); } Ok(()) } @@ -159,9 +165,12 @@ impl Selection<'_> { return Ok(self.authentik.compile(policy, bindings)?.map(Compiled::new)); } if self.oidc.enabled { - let (id, label) = self.choice.map_or(("oidc", "OIDC"), - |choice| (choice.provider.id(), choice.provider.display_name())); - return Ok(self.oidc.compile(policy, bindings)? + let (id, label) = self.choice.map_or(("oidc", "OIDC"), |choice| { + (choice.provider.id(), choice.provider.display_name()) + }); + return Ok(self + .oidc + .compile(policy, bindings)? .map(|provider| Compiled::new(provider.with_provider(id, label)))); } Ok(None) @@ -245,74 +254,118 @@ mod tests { } #[test] fn oidc_is_exclusive_and_missing_legacy_values_remain_disabled() { - let mut tinyauth = tinyauth::Settings::default(); - tinyauth.enabled = true; - tinyauth.url = "http://127.0.0.1:3000".into(); - tinyauth.login_url = "https://auth.example.net".into(); + let tinyauth = tinyauth::Settings { + enabled: true, + url: "http://127.0.0.1:3000".into(), + login_url: "https://auth.example.net".into(), + ..Default::default() + }; let oidc = oidc::Settings { - enabled: true, issuer_url: "https://auth.gruszczynski.cc".into(), + enabled: true, + issuer_url: "https://auth.example.com".into(), backend_url: "http://127.0.0.1:9091".into(), - client_id: "test-client".into(), client_secret: "test-secret".into(), - redirect_url: "https://app.linuxiarz.pl/_rustproxy/oidc/callback".into(), + client_id: "test-client".into(), + client_secret: "test-secret".into(), + redirect_url: "https://app.example.com/_rustproxy/oidc/callback".into(), session_secret: "long-session-secret-at-least-32-characters".into(), ..oidc::Settings::default() }; assert!(Selection { choice: None, - basic_enabled: false, tinyauth: &tinyauth, + basic_enabled: false, + tinyauth: &tinyauth, authelia: &authelia::Settings::default(), - authentik: &authentik::Settings::default(), oidc: &oidc, - }.validate().is_err()); + authentik: &authentik::Settings::default(), + oidc: &oidc, + } + .validate() + .is_err()); } #[test] fn oidc_identity_provider_is_independent_of_integration_method() { - let mut oidc = oidc::Settings::default(); - oidc.enabled = true; - oidc.issuer_url = "https://auth.gruszczynski.cc".into(); - oidc.client_id = "rustproxy-test".into(); - oidc.client_secret = "test-client-secret".into(); - oidc.session_secret = "a-long-random-value-for-testing-oidc-sessions".into(); - oidc.redirect_url = "https://app.linuxiarz.pl/_rustproxy/oidc/callback".into(); - for provider in [IdentityProvider::Tinyauth, IdentityProvider::Authelia, - IdentityProvider::Authentik, IdentityProvider::PocketId, - IdentityProvider::Custom] { + let oidc = oidc::Settings { + enabled: true, + issuer_url: "https://auth.example.com".into(), + client_id: "rustproxy-test".into(), + client_secret: "test-client-secret".into(), + session_secret: "a-long-random-value-for-testing-oidc-sessions".into(), + redirect_url: "https://app.example.com/_rustproxy/oidc/callback".into(), + ..Default::default() + }; + for provider in [ + IdentityProvider::Tinyauth, + IdentityProvider::Authelia, + IdentityProvider::Authentik, + IdentityProvider::PocketId, + IdentityProvider::Custom, + ] { assert!(Selection { - choice: Some(Choice { provider, method: IntegrationMethod::Oidc }), + choice: Some(Choice { + provider, + method: IntegrationMethod::Oidc + }), basic_enabled: false, tinyauth: &tinyauth::Settings::default(), authelia: &authelia::Settings::default(), authentik: &authentik::Settings::default(), oidc: &oidc, - }.validate().is_ok()); + } + .validate() + .is_ok()); } assert!(Selection { - choice: Some(Choice { provider: IdentityProvider::Authelia, method: IntegrationMethod::ForwardAuth }), - basic_enabled: false, tinyauth: &tinyauth::Settings::default(), - authelia: &authelia::Settings::default(), authentik: &authentik::Settings::default(), + choice: Some(Choice { + provider: IdentityProvider::Authelia, + method: IntegrationMethod::ForwardAuth + }), + basic_enabled: false, + tinyauth: &tinyauth::Settings::default(), + authelia: &authelia::Settings::default(), + authentik: &authentik::Settings::default(), oidc: &oidc, - }.validate().is_err()); + } + .validate() + .is_err()); } #[test] fn the_new_selection_cannot_silently_disable_or_switch_authentication() { - let mut tinyauth = tinyauth::Settings::default(); - tinyauth.enabled = true; - tinyauth.url = "http://10.0.0.2:3000".into(); - tinyauth.login_url = "https://auth.example.com".into(); + let tinyauth = tinyauth::Settings { + enabled: true, + url: "http://10.0.0.2:3000".into(), + login_url: "https://auth.example.com".into(), + ..Default::default() + }; let authelia = authelia::Settings::default(); let authentik = authentik::Settings::default(); let oidc = oidc::Settings::default(); let selection = |choice| Selection { - choice, basic_enabled: false, tinyauth: &tinyauth, - authelia: &authelia, authentik: &authentik, oidc: &oidc, + choice, + basic_enabled: false, + tinyauth: &tinyauth, + authelia: &authelia, + authentik: &authentik, + oidc: &oidc, }; assert!(selection(None).validate().is_ok()); - assert!(selection(Some(Choice { provider: IdentityProvider::Tinyauth, - method: IntegrationMethod::ForwardAuth })).validate().is_ok()); - assert!(selection(Some(Choice { provider: IdentityProvider::Authelia, - method: IntegrationMethod::ForwardAuth })).validate().is_err()); - assert!(selection(Some(Choice { provider: IdentityProvider::Tinyauth, - method: IntegrationMethod::Oidc })).validate().is_err()); + assert!(selection(Some(Choice { + provider: IdentityProvider::Tinyauth, + method: IntegrationMethod::ForwardAuth + })) + .validate() + .is_ok()); + assert!(selection(Some(Choice { + provider: IdentityProvider::Authelia, + method: IntegrationMethod::ForwardAuth + })) + .validate() + .is_err()); + assert!(selection(Some(Choice { + provider: IdentityProvider::Tinyauth, + method: IntegrationMethod::Oidc + })) + .validate() + .is_err()); } #[derive(Debug)] diff --git a/src/authelia/tests.rs b/src/authelia/tests.rs index fabae6c..7d9c582 100644 --- a/src/authelia/tests.rs +++ b/src/authelia/tests.rs @@ -4,12 +4,13 @@ mod tests { use http_body_util::BodyExt; fn settings(url: &str) -> Settings { - let mut settings = Settings::default(); - settings.enabled = true; - settings.url = url.into(); - settings.login_url = "https://auth.example.com".into(); - settings.allow_private = true; - settings + Settings(forward_auth::Settings { + enabled: true, + url: url.into(), + login_url: "https://auth.example.com".into(), + allow_private: true, + ..Default::default() + }) } fn compile(url: &str) -> Compiled { settings(url).compile(&policy(), &[]).unwrap().unwrap() } diff --git a/src/authentik/tests.rs b/src/authentik/tests.rs index cc98dde..ed26321 100644 --- a/src/authentik/tests.rs +++ b/src/authentik/tests.rs @@ -6,11 +6,12 @@ mod tests { }; fn settings(url: &str) -> Settings { - let mut settings = Settings::default(); - settings.enabled = true; - settings.url = url.into(); - settings.allow_private = true; - settings + Settings(forward_auth::Settings { + enabled: true, + url: url.into(), + allow_private: true, + ..Default::default() + }) } fn compile(url: &str) -> Compiled { settings(url).compile(&policy(), &[]).unwrap().unwrap() diff --git a/src/backup/archive.rs b/src/backup/archive.rs index b2dfccc..dddc343 100644 --- a/src/backup/archive.rs +++ b/src/backup/archive.rs @@ -56,18 +56,14 @@ struct Manifest { entries: Vec, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] enum EntryKind { + #[default] File, Directory, Symlink, } -impl Default for EntryKind { - fn default() -> Self { - Self::File - } -} #[derive(Debug, Clone, Serialize, Deserialize)] struct ManifestEntry { diff --git a/src/backup/snapshot.rs b/src/backup/snapshot.rs index a4a90f6..848bb11 100644 --- a/src/backup/snapshot.rs +++ b/src/backup/snapshot.rs @@ -41,13 +41,13 @@ fn snapshot_metadata_unchanged(before: &std::fs::Metadata, after: &std::fs::Meta #[cfg(unix)] { use std::os::unix::fs::MetadataExt; - return before.dev() == after.dev() + before.dev() == after.dev() && before.ino() == after.ino() && before.size() == after.size() && before.mtime() == after.mtime() && before.mtime_nsec() == after.mtime_nsec() && before.ctime() == after.ctime() - && before.ctime_nsec() == after.ctime_nsec(); + && before.ctime_nsec() == after.ctime_nsec() } #[cfg(not(unix))] { @@ -92,7 +92,7 @@ fn symlink_target_bytes(target: &Path) -> Result> { !bytes.is_empty() && bytes.len() <= 4096, "invalid symlink target" ); - return Ok(bytes); + Ok(bytes) } #[cfg(not(unix))] { @@ -117,7 +117,7 @@ fn symlink_target_path(bytes: &[u8]) -> Result { #[cfg(unix)] { use std::os::unix::ffi::OsStringExt; - return Ok(PathBuf::from(std::ffi::OsString::from_vec(bytes.to_vec()))); + Ok(PathBuf::from(std::ffi::OsString::from_vec(bytes.to_vec()))) } #[cfg(not(unix))] { @@ -157,7 +157,7 @@ fn create_symlink(target: &Path, link: &Path) -> Result<()> { #[cfg(unix)] { std::os::unix::fs::symlink(target, link)?; - return Ok(()); + Ok(()) } #[cfg(not(unix))] { diff --git a/src/balance/health.rs b/src/balance/health.rs index da030bd..f0caf1d 100644 --- a/src/balance/health.rs +++ b/src/balance/health.rs @@ -116,14 +116,15 @@ impl BackendState { return true; } let mut h = lock(&self.health); - if !h.passive_healthy && h.active_healthy && !h.checked { - if h.passive_ejected_at + if !h.passive_healthy + && h.active_healthy + && !h.checked + && h.passive_ejected_at .is_some_and(|at| at.elapsed() >= h.passive_cooldown) - { - let previous = h.healthy; - Self::clear_passive_ejection(&mut h); - self.finish_transition(&mut h, previous); - } + { + let previous = h.healthy; + Self::clear_passive_ejection(&mut h); + self.finish_transition(&mut h, previous); } h.healthy } diff --git a/src/basic_auth/mod.rs b/src/basic_auth/mod.rs index a332bb2..c55fea6 100644 --- a/src/basic_auth/mod.rs +++ b/src/basic_auth/mod.rs @@ -68,7 +68,7 @@ impl Settings { && self .realm .bytes() - .all(|c| c >= 32 && c < 127 && c != b'"' && c != b'\\'), + .all(|c| (32..127).contains(&c) && c != b'"' && c != b'\\'), "basic.invalid_realm" ); ensure!(self.users.len() <= 64, "basic.too_many_users"); @@ -189,6 +189,7 @@ fn credentials(headers: &HeaderMap) -> Option<(String, Zeroizing, Header )) } impl Compiled { + #[allow(clippy::result_large_err)] pub async fn authorize( &self, headers: &mut HeaderMap, diff --git a/src/cache/cache_regression_tests.rs b/src/cache/cache_regression_tests.rs index 90034e0..7416e84 100644 --- a/src/cache/cache_regression_tests.rs +++ b/src/cache/cache_regression_tests.rs @@ -735,9 +735,11 @@ mod tests { #[test] fn cache_preset_intent_round_trips_with_configuration() { - let mut settings = Settings::default(); - settings.memory_preset = Some(MemoryPreset::Recommended); - settings.cold_preset = Some(ColdPreset::Recommended); + let settings = Settings { + memory_preset: Some(MemoryPreset::Recommended), + cold_preset: Some(ColdPreset::Recommended), + ..Default::default() + }; let encoded = serde_json::to_value(&settings).unwrap(); assert_eq!(encoded["memory_preset"], "recommended"); assert_eq!(encoded["cold_preset"], "recommended"); @@ -749,9 +751,11 @@ mod tests { #[test] fn transient_budget_is_hard_and_released_with_lease() { let cache = Cache::new(1); - let mut s = Settings::default(); - s.transient_max_bytes = 128 * 1024; - s.cold_queue_max_bytes = 64 * 1024; + let s = Settings { + transient_max_bytes: 128 * 1024, + cold_queue_max_bytes: 64 * 1024, + ..Default::default() + }; let first = cache .try_reserve_transient(96 * 1024, &s) .expect("first reservation"); @@ -764,20 +768,24 @@ mod tests { #[test] fn cold_queue_budget_cannot_exceed_transient_budget() { - let mut s = Settings::default(); - s.transient_max_bytes = 8 * 1024 * 1024; - s.cold_queue_max_bytes = 16 * 1024 * 1024; + let s = Settings { + transient_max_bytes: 8 * 1024 * 1024, + cold_queue_max_bytes: 16 * 1024 * 1024, + ..Default::default() + }; assert!(s.validate().is_err()); } #[cfg(target_pointer_width = "64")] #[test] fn cache_limits_allow_explicit_multi_gibibyte_budgets() { - let mut s = Settings::default(); - s.max_bytes = 4usize * 1024 * 1024 * 1024; - s.max_object_bytes = 2usize * 1024 * 1024 * 1024; - s.transient_max_bytes = 512usize * 1024 * 1024; - s.cold_queue_max_bytes = 256usize * 1024 * 1024; + let mut s = Settings { + max_bytes: 4usize * 1024 * 1024 * 1024, + max_object_bytes: 2usize * 1024 * 1024 * 1024, + transient_max_bytes: 512usize * 1024 * 1024, + cold_queue_max_bytes: 256usize * 1024 * 1024, + ..Default::default() + }; assert!(s.validate().is_ok()); s.max_object_bytes = s.max_bytes.saturating_add(1); assert!(s.validate().is_err()); diff --git a/src/cache/cold/cold_tier_tests.rs b/src/cache/cold/cold_tier_tests.rs index 0d04565..b643e2f 100644 --- a/src/cache/cold/cold_tier_tests.rs +++ b/src/cache/cold/cold_tier_tests.rs @@ -979,8 +979,10 @@ mod cold_tier_tests { #[test] fn cold_path_cannot_escape_data_directory() { - let mut settings = Settings::default(); - settings.cold_path = "../outside".into(); + let mut settings = Settings { + cold_path: "../outside".into(), + ..Default::default() + }; assert!(settings.validate().is_err()); settings.cold_path = "/tmp/outside".into(); assert!(settings.validate().is_err()); diff --git a/src/cache/fill.rs b/src/cache/fill.rs index b719e9f..a3dd25f 100644 --- a/src/cache/fill.rs +++ b/src/cache/fill.rs @@ -97,9 +97,7 @@ impl Candidate { self.headers.insert("date", value); } } - for name in ["x-request-id"] { - self.headers.remove(name); - } + self.headers.remove("x-request-id"); let vary = std::mem::take(&mut self.plan.vary); let (body, body_allocation) = if let Some(body) = self.shared.take() { (body, self.shared_allocated) diff --git a/src/cache/lookup/hot.rs b/src/cache/lookup/hot.rs index d1e0abf..6bcda62 100644 --- a/src/cache/lookup/hot.rs +++ b/src/cache/lookup/hot.rs @@ -181,9 +181,7 @@ impl Cache { .fetch_add(1, Ordering::Relaxed); return None; } - let Some(_read_lease) = self.try_reserve_transient(entry.body_bytes, settings) else { - return None; - }; + let _read_lease = self.try_reserve_transient(entry.body_bytes, settings)?; let _cold_read = ByteGaugeGuard::new(&self.cold_read_bytes_active, entry.body_bytes as u64); let body_vec = match tokio::fs::read(&entry.body_path).await { Ok(body) if cold_body_matches(&entry, &body) => body, diff --git a/src/cache/lookup/tiered.rs b/src/cache/lookup/tiered.rs index c307b8d..7d048dd 100644 --- a/src/cache/lookup/tiered.rs +++ b/src/cache/lookup/tiered.rs @@ -27,9 +27,7 @@ impl Cache { headers.insert("date", value); } } - for name in ["x-request-id"] { - headers.remove(name); - } + headers.remove("x-request-id"); let hits = cached.hits.saturating_add(1); let age = plan.initial_age.as_secs(); let remaining_ttl = plan.ttl.saturating_sub(age); diff --git a/src/cache/model/cache.rs b/src/cache/model/cache.rs index bba9dd5..ce3fa4f 100644 --- a/src/cache/model/cache.rs +++ b/src/cache/model/cache.rs @@ -296,6 +296,7 @@ struct ColdDraft { _memory_lease: TransientLease, } +#[allow(clippy::large_enum_variant)] enum ColdCommand { Store(ColdDraft), Delete(Vec), diff --git a/src/cache/model/maintenance.rs b/src/cache/model/maintenance.rs index d778379..6849b9a 100644 --- a/src/cache/model/maintenance.rs +++ b/src/cache/model/maintenance.rs @@ -171,9 +171,9 @@ impl Revalidation { self.reusable_without_validation } pub fn cache_status_forward_reason(&self, request_forces_validation: bool) -> &'static str { - if self.age >= self.ttl { + if self.age >= self.ttl || !self.reusable_without_validation { "stale" - } else if request_forces_validation && self.reusable_without_validation { + } else if request_forces_validation { "request" } else { "miss" diff --git a/src/cache/model/settings.rs b/src/cache/model/settings.rs index e7f8c04..dfc41d1 100644 --- a/src/cache/model/settings.rs +++ b/src/cache/model/settings.rs @@ -48,9 +48,10 @@ fn release_allocator_memory() { } } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum CollapseTimeoutFallback { + #[default] Origin, StaleIfAvailable, } @@ -63,11 +64,6 @@ pub enum StaleErrorClass { Tls, } -impl Default for CollapseTimeoutFallback { - fn default() -> Self { - Self::Origin - } -} #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] diff --git a/src/cache/model/views.rs b/src/cache/model/views.rs index 6fb4b44..6ce8105 100644 --- a/src/cache/model/views.rs +++ b/src/cache/model/views.rs @@ -3,7 +3,7 @@ fn parse_key_hex(value: &str) -> Option { return None; } let mut key = [0_u8; 32]; - for (index, chunk) in value.as_bytes().chunks_exact(2).enumerate() { + for (index, chunk) in value.as_bytes().as_chunks::<2>().0.iter().enumerate() { let text = std::str::from_utf8(chunk).ok()?; key[index] = u8::from_str_radix(text, 16).ok()?; } @@ -137,13 +137,13 @@ fn stale_mode( if matches!(max_stale, Some(Some(limit)) if stale_for > limit) { return None; } + if max_stale.is_some() { + return Some(false); + } if stale_while_revalidate > 0 && stale_for <= stale_while_revalidate { return Some(true); } - match max_stale { - Some(None) | Some(Some(_)) => Some(false), - None => None, - } + None } #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/src/cache/v022_cache_tests.rs b/src/cache/v022_cache_tests.rs index 8e1b382..2a72fe0 100644 --- a/src/cache/v022_cache_tests.rs +++ b/src/cache/v022_cache_tests.rs @@ -574,10 +574,12 @@ mod v022_cache_tests { #[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]; + let settings = Settings { + stale_if_error_timeout: false, + stale_if_error_dns: false, + stale_if_error_statuses: vec![500, 503], + ..Default::default() + }; assert!(!settings.allows_stale_error_class(StaleErrorClass::Timeout)); assert!(!settings.allows_stale_error_class(StaleErrorClass::Dns)); assert!(settings.allows_stale_error_class(StaleErrorClass::Connection)); diff --git a/src/cache/varnish_compatible_tests.rs b/src/cache/varnish_compatible_tests.rs index 4ef1d87..7befcaa 100644 --- a/src/cache/varnish_compatible_tests.rs +++ b/src/cache/varnish_compatible_tests.rs @@ -414,8 +414,10 @@ mod varnish_compatible_tests { #[tokio::test] async fn positive_fallback_ttl_reuses_only_public_no_cache_entries() { let cache = Cache::new(1); - let mut settings = Settings::default(); - settings.default_ttl_seconds = 75; + let settings = Settings { + default_ttl_seconds: 75, + ..Default::default() + }; let ticket = cache .ticket( 1, @@ -503,7 +505,7 @@ mod varnish_compatible_tests { ) .unwrap(); let mut response = Response::new(Body::from("css")); - *response.headers_mut() = headers("public, max-age=60"); + *response.headers_mut() = headers("public, max-age=60, stale-while-revalidate=0"); let p = plan( StatusCode::OK, response.headers(), diff --git a/src/config/compile.rs b/src/config/compile.rs index d8c1ac5..c04bd1c 100644 --- a/src/config/compile.rs +++ b/src/config/compile.rs @@ -1,3 +1,9 @@ +type PhpEndpointPolicy = ( + String, + std::collections::BTreeMap, + std::collections::BTreeMap, +); + pub fn compile_with_active( mut config: Config, revision: u64, @@ -67,14 +73,7 @@ pub fn compile_with_active( "too many Web Services (max 128)" ); let mut web_services = HashMap::new(); - let mut php_endpoint_policies: HashMap< - String, - ( - String, - std::collections::BTreeMap, - std::collections::BTreeMap, - ), - > = HashMap::new(); + let mut php_endpoint_policies: HashMap = HashMap::new(); for service in &mut config.web_services { service .validate() diff --git a/src/config/model.rs b/src/config/model.rs index 1212c8f..e97773e 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -151,7 +151,7 @@ pub struct HostPolicy { pub hosts: Vec, pub acl: Acl, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct AclProfile { pub id: String, @@ -159,16 +159,6 @@ pub struct AclProfile { pub acl: Option, pub country_access: crate::rate_limit::CountryAccess, } -impl Default for AclProfile { - fn default() -> Self { - Self { - id: String::new(), - name: String::new(), - acl: None, - country_access: Default::default(), - } - } -} #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct Backend { diff --git a/src/config/routing.rs b/src/config/routing.rs index 78fcdaf..82a96a7 100644 --- a/src/config/routing.rs +++ b/src/config/routing.rs @@ -101,8 +101,8 @@ fn compile_routing( for definition in host.backends.iter().filter(|_| needs_proxy_pool) { let mut backend = compile_backend_with_timeout( &definition.backend(), - &desired_policy, - &guarded_bindings, + desired_policy, + guarded_bindings, &host.load_balancing.keepalive, host.proxy_timeouts .connect_timeout() @@ -224,7 +224,7 @@ fn compile_routing( authentik: &host.authentik, oidc: &host.oidc, } - .compile(&desired_policy, &guarded_bindings)?, + .compile(desired_policy, guarded_bindings)?, id: host.id.clone(), bindings: host_bindings, acl_profile_id: host.acl_profile_id.clone(), diff --git a/src/config/tests.rs b/src/config/tests.rs index fa4b624..2a51f39 100644 --- a/src/config/tests.rs +++ b/src/config/tests.rs @@ -116,20 +116,23 @@ mod tests { method: crate::auth_provider::IntegrationMethod::Oidc, }); host.oidc.enabled = true; - host.oidc.issuer_url = "https://auth.gruszczynski.cc".into(); + host.oidc.issuer_url = "https://auth.example.com".into(); host.oidc.client_id = "app".into(); host.oidc.client_secret = "client-secret".into(); host.oidc.session_secret = "a-unique-testing-session-secret-32-bytes".into(); host.oidc.redirect_url = "https://localhost/_rustproxy/oidc/callback".into(); assert!(host.validate().is_ok()); let encoded = serde_json::to_value(&host).unwrap(); - assert_eq!(encoded["authentication"], serde_json::json!({"provider":"authelia","method":"oidc"})); - let restored: crate::vhost::Vhost = serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!( + encoded["authentication"], + serde_json::json!({"provider":"authelia","method":"oidc"}) + ); + let mut restored: crate::vhost::Vhost = serde_json::from_value(encoded.clone()).unwrap(); assert_eq!(restored.authentication, host.authentication); assert!(restored.validate().is_ok()); let mut legacy = encoded.as_object().unwrap().clone(); legacy.remove("authentication"); - let old: crate::vhost::Vhost = serde_json::from_value(legacy.into()).unwrap(); + let mut old: crate::vhost::Vhost = serde_json::from_value(legacy.into()).unwrap(); assert!(old.authentication.is_none()); assert!(old.validate().is_ok()); host.authentication = Some(crate::auth_provider::Choice { diff --git a/src/diagnostics.rs b/src/diagnostics.rs index 15a85e1..45f095f 100644 --- a/src/diagnostics.rs +++ b/src/diagnostics.rs @@ -151,7 +151,7 @@ impl Diagnostics { pub fn trim_allocator() -> bool { #[cfg(all(target_os = "linux", target_env = "gnu"))] unsafe { - return libc::malloc_trim(0) != 0; + libc::malloc_trim(0) != 0 } #[cfg(not(all(target_os = "linux", target_env = "gnu")))] { diff --git a/src/docker/client.rs b/src/docker/client.rs index bde982b..8e1e147 100644 --- a/src/docker/client.rs +++ b/src/docker/client.rs @@ -51,7 +51,7 @@ impl Manager { .filter(|agent| agent.enabled) .filter(|agent| { force - || snapshot.get(&agent.id).map_or(true, |observation| { + || snapshot.get(&agent.id).is_none_or(|observation| { now.saturating_sub(observation.checked_at_unix_ms) >= agent.poll_interval_seconds.saturating_mul(1000) }) diff --git a/src/docker/model.rs b/src/docker/model.rs index 5119b78..4f4f003 100644 --- a/src/docker/model.rs +++ b/src/docker/model.rs @@ -212,7 +212,7 @@ fn normalize_fingerprint(value: &str) -> Result { fn fingerprint_bytes(value: &str) -> Result<[u8; 32]> { let value = normalize_fingerprint(value)?; let mut out = [0u8; 32]; - for (index, chunk) in value.as_bytes().chunks_exact(2).enumerate() { + for (index, chunk) in value.as_bytes().as_chunks::<2>().0.iter().enumerate() { let hi = (chunk[0] as char) .to_digit(16) .context("invalid TLS fingerprint")? as u8; diff --git a/src/docker/reconcile.rs b/src/docker/reconcile.rs index 42bb26f..55609c3 100644 --- a/src/docker/reconcile.rs +++ b/src/docker/reconcile.rs @@ -73,14 +73,14 @@ pub fn effective_config( .iter() .filter(|assignment| assignment.enabled) { - let hosts = assignment_hosts(&assignment, &agent.base_domain)?; + let hosts = assignment_hosts(assignment, &agent.base_domain)?; let target = inventory .and_then(|inventory| { inventory .services .iter() .find(|service| service.key == assignment.service_key) - .and_then(|service| route_target(agent, &assignment, inventory, service)) + .and_then(|service| route_target(agent, assignment, inventory, service)) }) .map(|(ip, port)| -> Result<_> { usable_target_ip(ip)?; diff --git a/src/forward_auth/mod.rs b/src/forward_auth/mod.rs index 84fda0a..75c8dd7 100644 --- a/src/forward_auth/mod.rs +++ b/src/forward_auth/mod.rs @@ -17,6 +17,7 @@ pub trait Adapter: Send + Sync + std::fmt::Debug + 'static { fn portal(_settings: &Settings) -> Result> { Ok(None) } + #[allow(clippy::result_large_err)] fn denied( status: StatusCode, response_headers: &HeaderMap, @@ -298,6 +299,7 @@ impl Compiled { headers.remove(name); } } + #[allow(clippy::result_large_err)] pub async fn authorize( &self, incoming: &HeaderMap, diff --git a/src/forwarding.rs b/src/forwarding.rs index 2a3c2ef..59cd93c 100644 --- a/src/forwarding.rs +++ b/src/forwarding.rs @@ -399,10 +399,9 @@ impl Compiled { }; values.insert( "x-forwarded-port", - if cached.is_some() && !(trusted_peer && self.trust_proto) { - cached.expect("tested above").port_header.clone() - } else { - port.to_string().parse()? + match (cached, trusted_peer && self.trust_proto) { + (Some(cached), false) => cached.port_header.clone(), + _ => port.to_string().parse()?, }, ); values.insert("x-forwarded-uri", original_target.parse()?); diff --git a/src/geoip.rs b/src/geoip.rs index d12e293..800ff0d 100644 --- a/src/geoip.rs +++ b/src/geoip.rs @@ -31,19 +31,14 @@ struct V6Range { end: [u8; 16], country: [u8; 2], } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum DatabaseFormat { + #[default] CsvPair, Mmdb, } -impl Default for DatabaseFormat { - fn default() -> Self { - Self::CsvPair - } -} - #[derive(Debug)] enum Backend { Csv { diff --git a/src/geoip/mmdb.rs b/src/geoip/mmdb.rs index dea13d5..6f153e8 100644 --- a/src/geoip/mmdb.rs +++ b/src/geoip/mmdb.rs @@ -336,7 +336,7 @@ fn read_uint( read_be(bytes, h.payload, h.size) } -fn inline_string<'a>(bytes: &'a [u8], offset: usize, base: usize) -> Result<(&'a str, usize)> { +fn inline_string(bytes: &[u8], offset: usize, base: usize) -> Result<(&str, usize)> { let h = header(bytes, offset, base)?; ensure!( h.pointer.is_none() && h.kind == 2, diff --git a/src/geoip/update_tests.rs b/src/geoip/update_tests.rs index c88e487..7e3297a 100644 --- a/src/geoip/update_tests.rs +++ b/src/geoip/update_tests.rs @@ -18,14 +18,20 @@ mod tests { #[test] fn settings_require_https_and_safe_bounds() { - let mut settings = UpdateSettings::default(); - settings.ipv4_url = "http://example.invalid/db.csv".into(); + let mut settings = UpdateSettings { + ipv4_url: "http://example.invalid/db.csv".into(), + ..Default::default() + }; assert!(settings.validate().is_err()); - let mut settings = UpdateSettings::default(); - settings.interval_seconds = 59; + let mut settings = UpdateSettings { + interval_seconds: 59, + ..Default::default() + }; assert!(settings.validate().is_err()); - let mut settings = UpdateSettings::default(); - settings.max_bytes = DEFAULT_MAX_BYTES + 1; + let mut settings = UpdateSettings { + max_bytes: DEFAULT_MAX_BYTES + 1, + ..Default::default() + }; assert!(settings.validate().is_err()); } diff --git a/src/lib.rs b/src/lib.rs index 3ed57b2..c0dca6f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,8 @@ +// Boundary/runtime helpers intentionally keep explicit typed parameters; Clippy's +// argument-count threshold is stylistic and does not improve these call sites. +#![allow(clippy::too_many_arguments)] +#![cfg_attr(test, allow(clippy::field_reassign_with_default))] + pub mod acl; pub mod acme; pub mod admin; @@ -34,12 +39,12 @@ pub mod transform; pub mod auth_provider; pub mod authelia; pub mod authentik; -pub mod oidc; pub mod balance; pub mod forward_auth; pub mod forwarding; pub mod geoip; pub mod http3; +pub mod oidc; pub mod secret_store; pub mod security; pub mod tinyauth; diff --git a/src/logging/format.rs b/src/logging/format.rs index b73fb75..2c5456c 100644 --- a/src/logging/format.rs +++ b/src/logging/format.rs @@ -49,6 +49,7 @@ pub struct SinkStats { pub errors: u64, } +#[allow(clippy::large_enum_variant)] enum HistoryEntry { Owned(LogEntry), Shared(Arc), diff --git a/src/logging/model.rs b/src/logging/model.rs index 554d095..034b8a6 100644 --- a/src/logging/model.rs +++ b/src/logging/model.rs @@ -264,18 +264,14 @@ fn source_value_name(name: &str) -> bool { ) } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] pub enum ErrorLogFormat { + #[default] #[serde(rename = "JSON")] Json, #[serde(rename = "RAW")] Raw, } -impl Default for ErrorLogFormat { - fn default() -> Self { - Self::Json - } -} #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] diff --git a/src/logging/tests.rs b/src/logging/tests.rs index dd6e690..be60d12 100644 --- a/src/logging/tests.rs +++ b/src/logging/tests.rs @@ -62,8 +62,10 @@ mod tests { "transform_effective_enabled".into(), ); format.static_fields.insert("service".into(), "edge".into()); - let mut settings = Settings::default(); - settings.format = format.clone(); + let settings = Settings { + format: format.clone(), + ..Default::default() + }; settings.validate().unwrap(); let value = format_entry(&entry(), &format); assert_eq!(value["http_status"], 200); @@ -399,9 +401,6 @@ mod tests { let mut live = logs.subscribe(); let mut sentinel = entry(); sentinel.id = 6; - let mut history = logs.entries.lock().unwrap(); - history.push_back(HistoryEntry::Owned(sentinel.clone())); - let mut item = entry(); item.path = "/contention/€\"quoted\"".into(); item.authority = Some("app.example:443".into()); @@ -409,14 +408,19 @@ mod tests { let expected = serde_json::to_value(&item).unwrap(); let producer = logs.clone(); let (finished, completion) = std::sync::mpsc::channel(); - let worker = std::thread::spawn(move || { - producer.push(item); - finished.send(()).unwrap(); - }); - let completed = completion.recv_timeout(Duration::from_secs(2)); - drop(history); + let worker = { + let mut history = logs.entries.lock().unwrap(); + history.push_back(HistoryEntry::Owned(sentinel.clone())); + let worker = std::thread::spawn(move || { + producer.push(item); + finished.send(()).unwrap(); + }); + let completed = completion.recv_timeout(Duration::from_secs(2)); + drop(history); + completed.expect("history contention must not block live/sink delivery"); + worker + }; worker.join().unwrap(); - completed.expect("history contention must not block live/sink delivery"); assert_eq!(logs.dropped.load(Ordering::Relaxed), 1); assert_eq!( diff --git a/src/main.rs b/src/main.rs index 2cbbbba..a4d19ac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,8 @@ +// Boundary/runtime helpers intentionally keep explicit typed parameters; Clippy's +// argument-count threshold is stylistic and does not improve these call sites. +#![allow(clippy::too_many_arguments)] +#![cfg_attr(test, allow(clippy::field_reassign_with_default))] + include!("main/bootstrap.rs"); include!("main/admin_runtime.rs"); include!("main/proxy_runtime.rs"); diff --git a/src/oidc/mod.rs b/src/oidc/mod.rs index 37e210e..9fe06e8 100644 --- a/src/oidc/mod.rs +++ b/src/oidc/mod.rs @@ -5,13 +5,24 @@ use crate::{ }; use anyhow::{ensure, Context as _, Result}; use axum::{body::Body, response::Response}; -use base64::{engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, Engine as _}; +use base64::{ + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, + Engine as _, +}; use http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri}; use http_body_util::{BodyExt, Limited}; -use ring::{aead, digest, hmac, rand::{SecureRandom, SystemRandom}}; +use ring::{ + aead, digest, hmac, + rand::{SecureRandom, SystemRandom}, +}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::Value; -use std::{fmt, net::SocketAddr, sync::Arc, time::{Duration, Instant, SystemTime, UNIX_EPOCH}}; +use std::{ + fmt, + net::SocketAddr, + sync::Arc, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; use url::{form_urlencoded, Url}; @@ -42,87 +53,183 @@ pub struct Settings { impl Default for Settings { fn default() -> Self { Self { - enabled: false, issuer_url: String::new(), backend_url: String::new(), client_id: String::new(), - client_secret: String::new(), scopes: "openid profile email".into(), session_secret: String::new(), redirect_url: String::new(), - allow_private: false, address_allowlist: Vec::new(), tls_ca_pem: None, - connect_timeout_seconds: 5, timeout_seconds: 10, session_ttl_seconds: 3600, + enabled: false, + issuer_url: String::new(), + backend_url: String::new(), + client_id: String::new(), + client_secret: String::new(), + scopes: "openid profile email".into(), + session_secret: String::new(), + redirect_url: String::new(), + allow_private: false, + address_allowlist: Vec::new(), + tls_ca_pem: None, + connect_timeout_seconds: 5, + timeout_seconds: 10, + session_ttl_seconds: 3600, } } } fn https_url(raw: &str) -> Result { - ensure!(!raw.is_empty() && raw.len() <= 2048, "OIDC URL must be 1..2048 bytes"); + ensure!( + !raw.is_empty() && raw.len() <= 2048, + "OIDC URL must be 1..2048 bytes" + ); let url = Url::parse(raw)?; - ensure!(url.scheme() == "https" && url.host_str().is_some(), "OIDC requires HTTPS URLs"); - ensure!(url.username().is_empty() && url.password().is_none() && url.query().is_none() && url.fragment().is_none(), "OIDC URL cannot contain credentials, queries or fragments"); + ensure!( + url.scheme() == "https" && url.host_str().is_some(), + "OIDC requires HTTPS URLs" + ); + ensure!( + url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none(), + "OIDC URL cannot contain credentials, queries or fragments" + ); Ok(url) } impl Settings { pub fn validate(&self) -> Result<()> { - ensure!((1..=30).contains(&self.connect_timeout_seconds) - && (1..=60).contains(&self.timeout_seconds) - && self.connect_timeout_seconds <= self.timeout_seconds, - "OIDC timeouts must be 1..30s connect <= 1..60s total"); - ensure!((60..=86400).contains(&self.session_ttl_seconds), "OIDC session lifetime must be 60..86400 seconds"); + ensure!( + (1..=30).contains(&self.connect_timeout_seconds) + && (1..=60).contains(&self.timeout_seconds) + && self.connect_timeout_seconds <= self.timeout_seconds, + "OIDC timeouts must be 1..30s connect <= 1..60s total" + ); + ensure!( + (60..=86400).contains(&self.session_ttl_seconds), + "OIDC session lifetime must be 60..86400 seconds" + ); if self.enabled { let issuer = https_url(&self.issuer_url)?; ensure!(!issuer.path().contains(".."), "OIDC issuer path is invalid"); if !self.backend_url.is_empty() { - let internal = crate::forward_auth::http_url(&self.backend_url, "OIDC internal backend")?; - ensure!(internal.path() == "/" && internal.query().is_none(), - "OIDC internal backend URL must be an origin without a path or query"); + let internal = + crate::forward_auth::http_url(&self.backend_url, "OIDC internal backend")?; + ensure!( + internal.path() == "/" && internal.query().is_none(), + "OIDC internal backend URL must be an origin without a path or query" + ); } - ensure!(!self.client_id.is_empty() && self.client_id.len() <= 256 - && self.client_id.bytes().all(|c| c.is_ascii_alphanumeric() || b"-._~".contains(&c)), - "OIDC client ID must contain 1..256 URL-safe characters"); - ensure!(!self.client_secret.is_empty() && self.client_secret.len() <= 2048, "OIDC client secret required"); - ensure!(self.session_secret.len() >= 32 && self.session_secret.len() <= 4096, "OIDC session secret must be at least 32 bytes"); - ensure!(self.scopes.len() <= 1024 && self.scopes.bytes().all(|c| c.is_ascii_alphanumeric() || b" -_:.".contains(&c)), - "OIDC scopes cannot contain control characters"); + ensure!( + !self.client_id.is_empty() + && self.client_id.len() <= 256 + && self + .client_id + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"-._~".contains(&c)), + "OIDC client ID must contain 1..256 URL-safe characters" + ); + ensure!( + !self.client_secret.is_empty() && self.client_secret.len() <= 2048, + "OIDC client secret required" + ); + ensure!( + self.session_secret.len() >= 32 && self.session_secret.len() <= 4096, + "OIDC session secret must be at least 32 bytes" + ); + ensure!( + self.scopes.len() <= 1024 + && self + .scopes + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b" -_:.".contains(&c)), + "OIDC scopes cannot contain control characters" + ); let scopes: Vec<_> = self.scopes.split_whitespace().collect(); - ensure!(scopes.len() <= 16 && scopes.contains(&"openid") - && scopes.iter().all(|item| item.len() <= 64 && item.bytes().all(|c| c.is_ascii_alphanumeric() || b"-_:.".contains(&c))), - "OIDC scopes must contain openid and valid space-separated names"); + ensure!( + scopes.len() <= 16 + && scopes.contains(&"openid") + && scopes.iter().all(|item| item.len() <= 64 + && item + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"-_:.".contains(&c))), + "OIDC scopes must contain openid and valid space-separated names" + ); let redirect = https_url(&self.redirect_url)?; - ensure!(redirect.path() == CALLBACK, "OIDC redirect URL must end with {CALLBACK}"); + ensure!( + redirect.path() == CALLBACK, + "OIDC redirect URL must end with {CALLBACK}" + ); } Ok(()) } - pub fn compile(&self, policy: &ValidationPolicy, bindings: &[SocketAddr]) -> Result> { + pub fn compile( + &self, + policy: &ValidationPolicy, + bindings: &[SocketAddr], + ) -> Result> { self.validate()?; - if !self.enabled { return Ok(None); } + if !self.enabled { + return Ok(None); + } let issuer = https_url(&self.issuer_url)?; let redirect = https_url(&self.redirect_url)?; let upstream_origin = if self.backend_url.is_empty() { issuer.origin().ascii_serialization() } else { - crate::forward_auth::http_url(&self.backend_url, "OIDC internal backend")?.origin().ascii_serialization() + crate::forward_auth::http_url(&self.backend_url, "OIDC internal backend")? + .origin() + .ascii_serialization() }; let public_host: Uri = issuer.as_str().parse()?; - let host_header = HeaderValue::from_str(public_host.authority().context("OIDC issuer authority missing")?.as_str())?; + let host_header = HeaderValue::from_str( + public_host + .authority() + .context("OIDC issuer authority missing")? + .as_str(), + )?; let definition = Backend { - id: "oidc".into(), url: upstream_origin.clone(), - allow_private: self.allow_private, accept_local_listener_risk: false, - preserve_host: false, protocol: UpstreamProtocol::Http1, + id: "oidc".into(), + url: upstream_origin.clone(), + allow_private: self.allow_private, + accept_local_listener_risk: false, + preserve_host: false, + protocol: UpstreamProtocol::Http1, address_allowlist: self.address_allowlist.clone(), - tls_ca_pem: self.tls_ca_pem.clone(), tls_server_name: None, tls_verify: true, + tls_ca_pem: self.tls_ca_pem.clone(), + tls_server_name: None, + tls_verify: true, }; let keepalive = crate::balance::Keepalive::default(); - let mut backend = crate::config::compile_backend(&definition, policy, bindings, &keepalive)?; + let mut backend = + crate::config::compile_backend(&definition, policy, bindings, &keepalive)?; backend.client = crate::upstream::client_with_timeout( - &definition, backend.address_policy.clone(), false, &keepalive, + &definition, + backend.address_policy.clone(), + false, + &keepalive, Duration::from_secs(self.connect_timeout_seconds), )?; - let fingerprint = digest::digest(&digest::SHA256, - format!("{}:{}", self.client_id, self.redirect_url).as_bytes()); - let suffix = fingerprint.as_ref()[..8].iter().map(|c| format!("{c:02x}")).collect::>().join(""); + let fingerprint = digest::digest( + &digest::SHA256, + format!("{}:{}", self.client_id, self.redirect_url).as_bytes(), + ); + let suffix = fingerprint.as_ref()[..8] + .iter() + .map(|c| format!("{c:02x}")) + .collect::>() + .join(""); Ok(Some(Compiled { - provider_id: "oidc", provider_label: "OIDC", - issuer: self.issuer_url.clone(), client_id: self.client_id.clone(), scopes: self.scopes.clone(), + provider_id: "oidc", + provider_label: "OIDC", + issuer: self.issuer_url.clone(), + client_id: self.client_id.clone(), + scopes: self.scopes.clone(), client_secret: self.client_secret.clone(), - session_secret: scoped_secret(&self.session_secret, &self.issuer_url, &self.client_id, &self.redirect_url), - redirect, backend: Arc::new(backend), cache: Mutex::new(None), - upstream_origin: Url::parse(&upstream_origin)?, host_header, + session_secret: scoped_secret( + &self.session_secret, + &self.issuer_url, + &self.client_id, + &self.redirect_url, + ), + redirect, + backend: Arc::new(backend), + cache: Mutex::new(None), + upstream_origin: Url::parse(&upstream_origin)?, + host_header, internal_override: !self.backend_url.is_empty(), timeout: Duration::from_secs(self.timeout_seconds), session_ttl: self.session_ttl_seconds, @@ -172,8 +279,10 @@ impl Compiled { } impl fmt::Debug for Compiled { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("OidcProvider").field("issuer", &self.issuer) - .field("redirect", &self.redirect).finish_non_exhaustive() + f.debug_struct("OidcProvider") + .field("issuer", &self.issuer) + .field("redirect", &self.redirect) + .finish_non_exhaustive() } } #[derive(Serialize, Deserialize)] @@ -199,7 +308,9 @@ fn now() -> Result { fn random_b64(bytes: usize) -> Result { ensure!(bytes <= 128, "random input too long"); let mut random = vec![0u8; bytes]; - SystemRandom::new().fill(&mut random).map_err(|_| anyhow::anyhow!("random source unavailable"))?; + SystemRandom::new() + .fill(&mut random) + .map_err(|_| anyhow::anyhow!("random source unavailable"))?; Ok(URL_SAFE_NO_PAD.encode(random)) } fn scoped_secret(secret: &str, issuer: &str, client_id: &str, redirect: &str) -> String { @@ -209,17 +320,25 @@ fn scoped_secret(secret: &str, issuer: &str, client_id: &str, redirect: &str) -> } fn crypt_key(secret: &str) -> Result { let hash = digest::digest(&digest::SHA256, secret.as_bytes()); - Ok(aead::LessSafeKey::new(aead::UnboundKey::new(&aead::AES_256_GCM, hash.as_ref()).map_err(|_| anyhow::anyhow!("OIDC cipher initialization failed"))?)) + Ok(aead::LessSafeKey::new( + aead::UnboundKey::new(&aead::AES_256_GCM, hash.as_ref()) + .map_err(|_| anyhow::anyhow!("OIDC cipher initialization failed"))?, + )) } fn seal(secret: &str, value: &T) -> Result { let mut nonce_bytes = [0u8; 12]; - SystemRandom::new().fill(&mut nonce_bytes).map_err(|_| anyhow::anyhow!("random source unavailable"))?; + SystemRandom::new() + .fill(&mut nonce_bytes) + .map_err(|_| anyhow::anyhow!("random source unavailable"))?; let mut bytes = serde_json::to_vec(value)?; ensure!(bytes.len() <= 3072, "OIDC cookie contents too large"); - crypt_key(secret)?.seal_in_place_append_tag( - aead::Nonce::assume_unique_for_key(nonce_bytes), - aead::Aad::from("rustproxy-oidc-v1"), &mut bytes, - ).map_err(|_| anyhow::anyhow!("OIDC cookie encryption failure"))?; + crypt_key(secret)? + .seal_in_place_append_tag( + aead::Nonce::assume_unique_for_key(nonce_bytes), + aead::Aad::from("rustproxy-oidc-v1"), + &mut bytes, + ) + .map_err(|_| anyhow::anyhow!("OIDC cookie encryption failure"))?; let mut data = nonce_bytes.to_vec(); data.extend_from_slice(&bytes); let encoded = URL_SAFE_NO_PAD.encode(data); @@ -232,10 +351,13 @@ fn open(secret: &str, encoded: &str) -> Result { ensure!(bytes.len() > 28, "invalid OIDC cookie"); let mut nonce = [0u8; 12]; nonce.copy_from_slice(&bytes[..12]); - let plaintext = crypt_key(secret)?.open_in_place( - aead::Nonce::assume_unique_for_key(nonce), - aead::Aad::from("rustproxy-oidc-v1"), &mut bytes[12..], - ).map_err(|_| anyhow::anyhow!("invalid OIDC cookie authentication"))?; + let plaintext = crypt_key(secret)? + .open_in_place( + aead::Nonce::assume_unique_for_key(nonce), + aead::Aad::from("rustproxy-oidc-v1"), + &mut bytes[12..], + ) + .map_err(|_| anyhow::anyhow!("invalid OIDC cookie authentication"))?; Ok(serde_json::from_slice(plaintext)?) } fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { @@ -244,7 +366,9 @@ fn cookie_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { for entry in line.to_str().ok()?.split(';') { if let Some((key, value)) = entry.trim().split_once('=') { if key == name { - if selected.is_some() { return None; } + if selected.is_some() { + return None; + } selected = Some(value); } } @@ -256,19 +380,27 @@ fn cookie(name: &str, value: &str, age: u64) -> Result { Ok(format!("{name}={value}; Path=/; Max-Age={age}; HttpOnly; Secure; SameSite=Lax").parse()?) } fn clear_cookie(name: &str) -> HeaderValue { - format!("{name}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax").parse().expect("static valid cookie name") + format!("{name}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Lax") + .parse() + .expect("static valid cookie name") } fn redirect(location: &str, status: StatusCode) -> Response { let mut response = auth_provider::deny(status); match location.parse() { - Ok(value) => { response.headers_mut().insert(header::LOCATION, value); } - Err(_) => { return auth_provider::deny(StatusCode::BAD_GATEWAY); } + Ok(value) => { + response.headers_mut().insert(header::LOCATION, value); + } + Err(_) => { + return auth_provider::deny(StatusCode::BAD_GATEWAY); + } } response } fn sanitize_header(headers: &mut HeaderMap, name: &'static str, value: &str) { if value.len() <= 2048 { - if let Ok(value) = value.parse() { headers.insert(name, value); } + if let Ok(value) = value.parse() { + headers.insert(name, value); + } } } fn identity(session: &Session, issuer: &str) -> Identity { @@ -276,19 +408,31 @@ fn identity(session: &Session, issuer: &str) -> Identity { sanitize_header(&mut headers, "remote-sub", &session.subject); sanitize_header(&mut headers, "remote-issuer", issuer); sanitize_header(&mut headers, "remote-user", &session.username); - if let Some(email) = &session.email { sanitize_header(&mut headers, "remote-email", email); } - if let Some(name) = &session.name { sanitize_header(&mut headers, "remote-name", name); } - if !session.groups.is_empty() { sanitize_header(&mut headers, "remote-groups", &session.groups.join(",")); } + if let Some(email) = &session.email { + sanitize_header(&mut headers, "remote-email", email); + } + if let Some(name) = &session.name { + sanitize_header(&mut headers, "remote-name", name); + } + if !session.groups.is_empty() { + sanitize_header(&mut headers, "remote-groups", &session.groups.join(",")); + } Identity { headers } } impl Compiled { fn url_for_endpoint(&self, raw: &str) -> Result { let url = https_url(raw)?; - ensure!(url.origin() == https_url(&self.issuer)?.origin(), "OIDC endpoints must use the configured issuer origin"); + ensure!( + url.origin() == https_url(&self.issuer)?.origin(), + "OIDC endpoints must use the configured issuer origin" + ); Ok(url) } fn wire_uri(&self, public: &Url) -> Result { - ensure!(public.origin() == https_url(&self.issuer)?.origin(), "OIDC request not on issuer origin"); + ensure!( + public.origin() == https_url(&self.issuer)?.origin(), + "OIDC request not on issuer origin" + ); let mut wire = self.upstream_origin.clone(); wire.set_path(public.path()); wire.set_query(public.query()); @@ -301,64 +445,145 @@ impl Compiled { } } async fn get_json(&self, url: &Url) -> Result { - let mut request = Request::builder().method(Method::GET).uri(self.wire_uri(url)?) + let mut request = Request::builder() + .method(Method::GET) + .uri(self.wire_uri(url)?) .body(Body::empty())?; self.wire_headers(request.headers_mut()); - request.headers_mut().insert(header::ACCEPT, HeaderValue::from_static("application/json")); + request + .headers_mut() + .insert(header::ACCEPT, HeaderValue::from_static("application/json")); let response = self.backend.client.request(request).await?; - ensure!(response.status() == StatusCode::OK, "OIDC endpoint returned an error"); - let body = Limited::new(response.into_body(), 65536).collect().await - .map_err(|error| anyhow::anyhow!("invalid OIDC endpoint response: {error}"))?.to_bytes(); + ensure!( + response.status() == StatusCode::OK, + "OIDC endpoint returned an error" + ); + let body = Limited::new(response.into_body(), 65536) + .collect() + .await + .map_err(|error| anyhow::anyhow!("invalid OIDC endpoint response: {error}"))? + .to_bytes(); Ok(serde_json::from_slice(&body)?) } async fn discovery(&self) -> Result { let mut guard = self.cache.lock().await; if let Some(saved) = guard.as_ref() { - if saved.until > Instant::now() { return Ok(saved.discovery.clone()); } + if saved.until > Instant::now() { + return Ok(saved.discovery.clone()); + } } - let discovery_url = self.url_for_endpoint(&format!("{}/.well-known/openid-configuration", self.issuer.trim_end_matches('/')))?; + let discovery_url = self.url_for_endpoint(&format!( + "{}/.well-known/openid-configuration", + self.issuer.trim_end_matches('/') + ))?; let metadata = self.get_json(&discovery_url).await?; - ensure!(metadata.get("issuer").and_then(Value::as_str) == Some(self.issuer.as_str()), "OIDC discovery issuer mismatch"); - let authorization = self.url_for_endpoint(metadata.get("authorization_endpoint").and_then(Value::as_str).context("missing OIDC authorization endpoint")?)?; - let token = self.url_for_endpoint(metadata.get("token_endpoint").and_then(Value::as_str).context("missing OIDC token endpoint")?)?; - let userinfo = metadata.get("userinfo_endpoint").and_then(Value::as_str) - .map(|url| self.url_for_endpoint(url)).transpose()?; - let jwks_url = self.url_for_endpoint(metadata.get("jwks_uri").and_then(Value::as_str).context("missing OIDC JWKS URI")?)?; + ensure!( + metadata.get("issuer").and_then(Value::as_str) == Some(self.issuer.as_str()), + "OIDC discovery issuer mismatch" + ); + let authorization = self.url_for_endpoint( + metadata + .get("authorization_endpoint") + .and_then(Value::as_str) + .context("missing OIDC authorization endpoint")?, + )?; + let token = self.url_for_endpoint( + metadata + .get("token_endpoint") + .and_then(Value::as_str) + .context("missing OIDC token endpoint")?, + )?; + let userinfo = metadata + .get("userinfo_endpoint") + .and_then(Value::as_str) + .map(|url| self.url_for_endpoint(url)) + .transpose()?; + let jwks_url = self.url_for_endpoint( + metadata + .get("jwks_uri") + .and_then(Value::as_str) + .context("missing OIDC JWKS URI")?, + )?; let jwks = self.get_json(&jwks_url).await?; - ensure!(jwks.get("keys").and_then(Value::as_array).is_some(), "invalid OIDC signing keys"); - let discovery = Discovery { authorization, token, userinfo, jwks }; - *guard = Some(Cached { until: Instant::now() + Duration::from_secs(300), discovery: discovery.clone() }); + ensure!( + jwks.get("keys").and_then(Value::as_array).is_some(), + "invalid OIDC signing keys" + ); + let discovery = Discovery { + authorization, + token, + userinfo, + jwks, + }; + *guard = Some(Cached { + until: Instant::now() + Duration::from_secs(300), + discovery: discovery.clone(), + }); Ok(discovery) } fn is_redirect_host(&self, context: &forwarding::Context) -> bool { - context.scheme == "https" && Url::parse(&context.original_url) - .is_ok_and(|url| url.origin() == self.redirect.origin()) + context.scheme == "https" + && Url::parse(&context.original_url) + .is_ok_and(|url| url.origin() == self.redirect.origin()) } - async fn initiate(&self, context: &forwarding::Context, cookies: &CookieSink) -> Result { - ensure!(self.is_redirect_host(context), "OIDC request origin does not match callback"); + async fn initiate( + &self, + context: &forwarding::Context, + cookies: &CookieSink, + ) -> Result { + ensure!( + self.is_redirect_host(context), + "OIDC request origin does not match callback" + ); let destination = Url::parse(&context.original_url)?; let mut return_to = destination.path().to_owned(); - if let Some(query) = destination.query() { return_to.push('?'); return_to.push_str(query); } - ensure!(return_to.len() <= 1800 && return_to.starts_with('/') && !return_to.starts_with("//"), "invalid OIDC return URL"); + if let Some(query) = destination.query() { + return_to.push('?'); + return_to.push_str(query); + } + ensure!( + return_to.len() <= 1800 && return_to.starts_with('/') && !return_to.starts_with("//"), + "invalid OIDC return URL" + ); let transaction = Pending { - state: random_b64(32)?, nonce: random_b64(32)?, verifier: random_b64(32)?, - return_to, issued: now()?, + state: random_b64(32)?, + nonce: random_b64(32)?, + verifier: random_b64(32)?, + return_to, + issued: now()?, }; let mut authorization = self.discovery().await?.authorization; let challenge = digest::digest(&digest::SHA256, transaction.verifier.as_bytes()); - authorization.query_pairs_mut().append_pair("response_type", "code") + authorization + .query_pairs_mut() + .append_pair("response_type", "code") .append_pair("client_id", &self.client_id) .append_pair("redirect_uri", self.redirect.as_str()) .append_pair("scope", &self.scopes) .append_pair("state", &transaction.state) .append_pair("nonce", &transaction.nonce) .append_pair("code_challenge_method", "S256") - .append_pair("code_challenge", &URL_SAFE_NO_PAD.encode(challenge.as_ref())); + .append_pair( + "code_challenge", + &URL_SAFE_NO_PAD.encode(challenge.as_ref()), + ); let mut response = redirect(authorization.as_str(), StatusCode::FOUND); let mut set_cookie = HeaderMap::new(); - set_cookie.append(header::SET_COOKIE, cookie(&self.state_cookie, &seal(&self.session_secret, &transaction)?, 300)?); - ensure!(cookies.collect(&set_cookie), "OIDC transaction cookie too large"); - response.headers_mut().insert("referrer-policy", HeaderValue::from_static("no-referrer")); + set_cookie.append( + header::SET_COOKIE, + cookie( + &self.state_cookie, + &seal(&self.session_secret, &transaction)?, + 300, + )?, + ); + ensure!( + cookies.collect(&set_cookie), + "OIDC transaction cookie too large" + ); + response + .headers_mut() + .insert("referrer-policy", HeaderValue::from_static("no-referrer")); Ok(response) } async fn exchange(&self, code: &str, pending: &Pending) -> Result { @@ -369,38 +594,89 @@ impl Compiled { .append_pair("redirect_uri", self.redirect.as_str()) .append_pair("code_verifier", &pending.verifier) .finish(); - let id_encoded: String = form_urlencoded::byte_serialize(self.client_id.as_bytes()).collect(); - let secret_encoded: String = form_urlencoded::byte_serialize(self.client_secret.as_bytes()).collect(); + let id_encoded: String = + form_urlencoded::byte_serialize(self.client_id.as_bytes()).collect(); + let secret_encoded: String = + form_urlencoded::byte_serialize(self.client_secret.as_bytes()).collect(); let authorization = STANDARD.encode(format!("{id_encoded}:{secret_encoded}")); - let mut request = Request::builder().method(Method::POST).uri(self.wire_uri(&discovery.token)?) + let mut request = Request::builder() + .method(Method::POST) + .uri(self.wire_uri(&discovery.token)?) .body(Body::from(body))?; self.wire_headers(request.headers_mut()); - request.headers_mut().insert(header::AUTHORIZATION, format!("Basic {authorization}").parse()?); - request.headers_mut().insert(header::ACCEPT, HeaderValue::from_static("application/json")); - request.headers_mut().insert(header::CONTENT_TYPE, HeaderValue::from_static("application/x-www-form-urlencoded")); + request.headers_mut().insert( + header::AUTHORIZATION, + format!("Basic {authorization}").parse()?, + ); + request + .headers_mut() + .insert(header::ACCEPT, HeaderValue::from_static("application/json")); + request.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/x-www-form-urlencoded"), + ); let response = self.backend.client.request(request).await?; - ensure!(response.status() == StatusCode::OK, "OIDC token endpoint returned an error"); - let body = Limited::new(response.into_body(), 65536).collect().await - .map_err(|error| anyhow::anyhow!("invalid OIDC token response: {error}"))?.to_bytes(); + ensure!( + response.status() == StatusCode::OK, + "OIDC token endpoint returned an error" + ); + let body = Limited::new(response.into_body(), 65536) + .collect() + .await + .map_err(|error| anyhow::anyhow!("invalid OIDC token response: {error}"))? + .to_bytes(); let value: Value = serde_json::from_slice(&body)?; - let id_token = value.get("id_token").and_then(Value::as_str).context("missing OIDC ID token")?; - let mut claims = token::verify_id_token(id_token, &discovery.jwks, &self.issuer, &self.client_id, &pending.nonce)?; - if let (Some(userinfo_url), Some(access_token)) = (discovery.userinfo.as_ref(), value.get("access_token").and_then(Value::as_str)) { - ensure!(access_token.len() <= 8192 && !access_token.is_empty(), "invalid OIDC access token"); - let mut request = Request::builder().method(Method::GET).uri(self.wire_uri(userinfo_url)?) + let id_token = value + .get("id_token") + .and_then(Value::as_str) + .context("missing OIDC ID token")?; + let mut claims = token::verify_id_token( + id_token, + &discovery.jwks, + &self.issuer, + &self.client_id, + &pending.nonce, + )?; + if let (Some(userinfo_url), Some(access_token)) = ( + discovery.userinfo.as_ref(), + value.get("access_token").and_then(Value::as_str), + ) { + ensure!( + access_token.len() <= 8192 && !access_token.is_empty(), + "invalid OIDC access token" + ); + let mut request = Request::builder() + .method(Method::GET) + .uri(self.wire_uri(userinfo_url)?) .body(Body::empty())?; self.wire_headers(request.headers_mut()); - request.headers_mut().insert(header::AUTHORIZATION, format!("Bearer {access_token}").parse()?); - request.headers_mut().insert(header::ACCEPT, HeaderValue::from_static("application/json")); + request.headers_mut().insert( + header::AUTHORIZATION, + format!("Bearer {access_token}").parse()?, + ); + request + .headers_mut() + .insert(header::ACCEPT, HeaderValue::from_static("application/json")); let response = self.backend.client.request(request).await?; - ensure!(response.status() == StatusCode::OK, "OIDC UserInfo endpoint returned an error"); - let data = Limited::new(response.into_body(), 65536).collect().await - .map_err(|error| anyhow::anyhow!("invalid OIDC UserInfo response: {error}"))?.to_bytes(); + ensure!( + response.status() == StatusCode::OK, + "OIDC UserInfo endpoint returned an error" + ); + let data = Limited::new(response.into_body(), 65536) + .collect() + .await + .map_err(|error| anyhow::anyhow!("invalid OIDC UserInfo response: {error}"))? + .to_bytes(); token::merge_userinfo(&mut claims, &serde_json::from_slice::(&data)?)?; } Ok(claims) } - async fn callback(&self, request: Request, context: &forwarding::Context, shutdown: &CancellationToken) -> Response { + async fn callback( + &self, + request: Request, + context: &forwarding::Context, + shutdown: &CancellationToken, + ) -> Response { if request.method() != Method::GET || !self.is_redirect_host(context) { return auth_provider::deny(StatusCode::BAD_REQUEST); } @@ -408,14 +684,20 @@ impl Compiled { let mut valid_state = None; let mut error = false; if let Some(query) = request.uri().query() { - if query.len() > 8192 { return auth_provider::deny(StatusCode::BAD_REQUEST); } + if query.len() > 8192 { + return auth_provider::deny(StatusCode::BAD_REQUEST); + } for (key, value) in form_urlencoded::parse(query.as_bytes()) { match key.as_ref() { - "code" if valid_code.is_none() && value.len() <= 4096 => valid_code = Some(value.into_owned()), - "state" if valid_state.is_none() && value.len() <= 256 => valid_state = Some(value.into_owned()), + "code" if valid_code.is_none() && value.len() <= 4096 => { + valid_code = Some(value.into_owned()) + } + "state" if valid_state.is_none() && value.len() <= 256 => { + valid_state = Some(value.into_owned()) + } "error" => error = true, "code" | "state" => error = true, - _ => {}, + _ => {} } } } @@ -426,11 +708,18 @@ impl Compiled { Ok(value) => value, Err(_) => return auth_provider::deny(StatusCode::UNAUTHORIZED), }; - let current = match now() { Ok(now) => now, Err(_) => return auth_provider::deny(StatusCode::BAD_GATEWAY) }; - if error || current < pending.issued || current.saturating_sub(pending.issued) > 300 + let current = match now() { + Ok(now) => now, + Err(_) => return auth_provider::deny(StatusCode::BAD_GATEWAY), + }; + if error + || current < pending.issued + || current.saturating_sub(pending.issued) > 300 || valid_state.as_deref() != Some(pending.state.as_str()) || valid_code.as_deref().is_none_or(str::is_empty) - { return auth_provider::deny(StatusCode::UNAUTHORIZED); } + { + return auth_provider::deny(StatusCode::UNAUTHORIZED); + } let code = valid_code.expect("validated authorization code"); let result = tokio::select! { _ = shutdown.cancelled() => return auth_provider::deny(StatusCode::SERVICE_UNAVAILABLE), @@ -440,44 +729,90 @@ impl Compiled { Ok(Ok(claims)) => claims, Ok(Err(error)) => { tracing::warn!(provider = self.provider_id, %error, "OIDC callback verification failed"); - return auth_provider::gateway_failure(self.provider_label, GatewayFailure::Verification); + return auth_provider::gateway_failure( + self.provider_label, + GatewayFailure::Verification, + ); + } + Err(_) => { + return auth_provider::gateway_failure(self.provider_label, GatewayFailure::Timeout) } - Err(_) => return auth_provider::gateway_failure(self.provider_label, GatewayFailure::Timeout), }; let session = Session { - subject: claims.subject, username: claims.username, email: claims.email, - name: claims.name, groups: claims.groups, + subject: claims.subject, + username: claims.username, + email: claims.email, + name: claims.name, + groups: claims.groups, expires: current.saturating_add(self.session_ttl), }; let value = match seal(&self.session_secret, &session) { Ok(value) => value, - Err(_) => return auth_provider::gateway_failure(self.provider_label, GatewayFailure::Verification), + Err(_) => { + return auth_provider::gateway_failure( + self.provider_label, + GatewayFailure::Verification, + ) + } }; let mut response = redirect(&pending.return_to, StatusCode::SEE_OTHER); - response.headers_mut().append(header::SET_COOKIE, clear_cookie(&self.state_cookie)); - response.headers_mut().insert("referrer-policy", HeaderValue::from_static("no-referrer")); + response + .headers_mut() + .append(header::SET_COOKIE, clear_cookie(&self.state_cookie)); + response + .headers_mut() + .insert("referrer-policy", HeaderValue::from_static("no-referrer")); if let Ok(cookie) = cookie(&self.session_cookie, &value, self.session_ttl) { response.headers_mut().append(header::SET_COOKIE, cookie); - } else { return auth_provider::deny(StatusCode::BAD_GATEWAY); } + } else { + return auth_provider::deny(StatusCode::BAD_GATEWAY); + } response } } impl auth_provider::Provider for Compiled { - fn id(&self) -> &'static str { self.provider_id } - fn method(&self) -> auth_provider::IntegrationMethod { auth_provider::IntegrationMethod::Oidc } - fn strip_identity(&self, headers: &mut HeaderMap) { - let names: Vec<_> = headers.keys().filter(|name| { - let name = name.as_str(); - name.starts_with("remote-") || name.starts_with("x-authentik-") || name.starts_with("x-auth-request-") - || matches!(name, "x-forwarded-user" | "x-forwarded-email" | "x-remote-user" | "x-remote-email" | "x-user" | "x-email") - }).cloned().collect(); - for name in names { headers.remove(name); } + fn id(&self) -> &'static str { + self.provider_id } - fn authorize<'a>(&'a self, incoming: &'a HeaderMap, method: &'a Method, - context: &'a forwarding::Context, shutdown: &'a CancellationToken, - cookies: &'a CookieSink) -> auth_provider::Authorization<'a> { + fn method(&self) -> auth_provider::IntegrationMethod { + auth_provider::IntegrationMethod::Oidc + } + fn strip_identity(&self, headers: &mut HeaderMap) { + let names: Vec<_> = headers + .keys() + .filter(|name| { + let name = name.as_str(); + name.starts_with("remote-") + || name.starts_with("x-authentik-") + || name.starts_with("x-auth-request-") + || matches!( + name, + "x-forwarded-user" + | "x-forwarded-email" + | "x-remote-user" + | "x-remote-email" + | "x-user" + | "x-email" + ) + }) + .cloned() + .collect(); + for name in names { + headers.remove(name); + } + } + fn authorize<'a>( + &'a self, + incoming: &'a HeaderMap, + method: &'a Method, + context: &'a forwarding::Context, + shutdown: &'a CancellationToken, + cookies: &'a CookieSink, + ) -> auth_provider::Authorization<'a> { Box::pin(async move { - if !self.is_redirect_host(context) { return Err(auth_provider::deny(StatusCode::FORBIDDEN)); } + if !self.is_redirect_host(context) { + return Err(auth_provider::deny(StatusCode::FORBIDDEN)); + } if let Some(encoded) = cookie_value(incoming, &self.session_cookie) { if let Ok(session) = open::(&self.session_secret, encoded) { if now().is_ok_and(|current| session.expires > current) { @@ -496,25 +831,44 @@ impl auth_provider::Provider for Compiled { Ok(Ok(response)) => response, Ok(Err(error)) => { tracing::warn!(provider = self.provider_id, %error, "OIDC login initiation failed"); - auth_provider::gateway_failure(self.provider_label, GatewayFailure::Verification) + auth_provider::gateway_failure( + self.provider_label, + GatewayFailure::Verification, + ) + } + Err(_) => { + auth_provider::gateway_failure(self.provider_label, GatewayFailure::Timeout) } - Err(_) => auth_provider::gateway_failure(self.provider_label, GatewayFailure::Timeout), }) }) } - fn reserved_path(&self, path: &str) -> bool { matches!(path, CALLBACK | LOGOUT) } - fn serve_reserved<'a>(&'a self, request: Request, context: &'a forwarding::Context, - shutdown: &'a CancellationToken) -> Option> { + fn reserved_path(&self, path: &str) -> bool { + matches!(path, CALLBACK | LOGOUT) + } + fn serve_reserved<'a>( + &'a self, + request: Request, + context: &'a forwarding::Context, + shutdown: &'a CancellationToken, + ) -> Option> { match request.uri().path() { - CALLBACK => Some(Box::pin(async move { self.callback(request, context, shutdown).await })), + CALLBACK => Some(Box::pin(async move { + self.callback(request, context, shutdown).await + })), LOGOUT => Some(Box::pin(async move { if request.method() != Method::GET || !self.is_redirect_host(context) { return auth_provider::deny(StatusCode::BAD_REQUEST); } let mut response = redirect("/", StatusCode::SEE_OTHER); - response.headers_mut().append(header::SET_COOKIE, clear_cookie(&self.session_cookie)); - response.headers_mut().append(header::SET_COOKIE, clear_cookie(&self.state_cookie)); - response.headers_mut().insert("referrer-policy", HeaderValue::from_static("no-referrer")); + response + .headers_mut() + .append(header::SET_COOKIE, clear_cookie(&self.session_cookie)); + response + .headers_mut() + .append(header::SET_COOKIE, clear_cookie(&self.state_cookie)); + response + .headers_mut() + .insert("referrer-policy", HeaderValue::from_static("no-referrer")); response })), _ => None, diff --git a/src/oidc/tests.rs b/src/oidc/tests.rs index b3cf99e..5e6ce52 100644 --- a/src/oidc/tests.rs +++ b/src/oidc/tests.rs @@ -6,11 +6,11 @@ mod tests { fn enabled() -> Settings { Settings { enabled: true, - issuer_url: "https://auth.gruszczynski.cc".into(), + issuer_url: "https://auth.example.com".into(), client_id: "rustproxy-app".into(), client_secret: "super-secret-client".into(), session_secret: "an-uncorrelated-high-entropy-session-secret-value".into(), - redirect_url: "https://app.linuxiarz.pl/_rustproxy/oidc/callback".into(), + redirect_url: "https://app.example.com/_rustproxy/oidc/callback".into(), ..Settings::default() } } @@ -20,8 +20,14 @@ mod tests { assert!(!Settings::default().enabled); let settings = enabled(); assert!(settings.validate().is_ok()); - assert_eq!(https_url(&settings.issuer_url).unwrap().host_str(), Some("auth.gruszczynski.cc")); - assert_eq!(https_url(&settings.redirect_url).unwrap().host_str(), Some("app.linuxiarz.pl")); + assert_eq!( + https_url(&settings.issuer_url).unwrap().host_str(), + Some("auth.example.com") + ); + assert_eq!( + https_url(&settings.redirect_url).unwrap().host_str(), + Some("app.example.com") + ); } #[test] @@ -30,7 +36,7 @@ mod tests { settings.issuer_url = "http://auth.example.com".into(); assert!(settings.validate().is_err()); settings = enabled(); - settings.redirect_url = "https://app.linuxiarz.pl/other".into(); + settings.redirect_url = "https://app.example.com/other".into(); assert!(settings.validate().is_err()); settings = enabled(); settings.client_secret.clear(); @@ -50,9 +56,12 @@ mod tests { fn encrypted_cookies_are_authenticated_and_not_readable() { let secret = "session-secret-at-least-32-bytes-unique"; let session = Session { - subject: "a-stable-id".into(), username: "alice".into(), - email: Some("alice@example.com".into()), name: None, - groups: vec!["member".into()], expires: now().unwrap() + 3600, + subject: "a-stable-id".into(), + username: "alice".into(), + email: Some("alice@example.com".into()), + name: None, + groups: vec!["member".into()], + expires: now().unwrap() + 3600, }; let encoded = seal(secret, &session).unwrap(); assert!(!encoded.contains("alice")); @@ -73,8 +82,18 @@ mod tests { #[test] fn cookies_cannot_be_reused_across_oidc_clients_or_redirect_hosts() { let shared = "same-admin-configured-secret-for-multiple-applications"; - let a = scoped_secret(shared, "https://auth.example.com", "app1", "https://app1.example.org/_rustproxy/oidc/callback"); - let b = scoped_secret(shared, "https://auth.example.com", "app2", "https://app2.example.org/_rustproxy/oidc/callback"); + let a = scoped_secret( + shared, + "https://auth.example.com", + "app1", + "https://app1.example.org/_rustproxy/oidc/callback", + ); + let b = scoped_secret( + shared, + "https://auth.example.com", + "app2", + "https://app2.example.org/_rustproxy/oidc/callback", + ); assert_ne!(a, b); let encrypted = seal(&a, &serde_json::json!({"sub": "alice"})).unwrap(); assert!(open::(&b, &encrypted).is_err()); @@ -83,7 +102,10 @@ mod tests { #[test] fn duplicate_cookies_cannot_override_a_valid_cookie() { let mut headers = HeaderMap::new(); - headers.insert(header::COOKIE, "a=1; login=ok; login=malicious".parse().unwrap()); + headers.insert( + header::COOKIE, + "a=1; login=ok; login=malicious".parse().unwrap(), + ); assert!(cookie_value(&headers, "login").is_none()); headers.insert(header::COOKIE, "a=1; login=ok".parse().unwrap()); assert_eq!(cookie_value(&headers, "login"), Some("ok")); @@ -92,17 +114,28 @@ mod tests { #[test] fn userinfo_must_match_signed_subject_and_does_not_trust_unverified_email() { let mut claims = token::Claims { - subject: "id-42".into(), username: "id-42".into(), email: None, - name: None, groups: vec![], + subject: "id-42".into(), + username: "id-42".into(), + email: None, + name: None, + groups: vec![], }; - assert!(token::merge_userinfo(&mut claims, &serde_json::json!({ - "sub":"other-user", "preferred_username":"admin" - })).is_err()); + assert!(token::merge_userinfo( + &mut claims, + &serde_json::json!({ + "sub":"other-user", "preferred_username":"admin" + }) + ) + .is_err()); assert_eq!(claims.username, "id-42"); - token::merge_userinfo(&mut claims, &serde_json::json!({ - "sub":"id-42", "preferred_username":"alice", "email":"alice@example.com", - "email_verified":false, "groups":["users"] - })).unwrap(); + token::merge_userinfo( + &mut claims, + &serde_json::json!({ + "sub":"id-42", "preferred_username":"alice", "email":"alice@example.com", + "email_verified":false, "groups":["users"] + }), + ) + .unwrap(); assert_eq!(claims.username, "alice"); assert_eq!(claims.email, None); assert_eq!(claims.groups, vec!["users".to_string()]); @@ -112,9 +145,16 @@ mod tests { fn id_token_signature_issuer_audience_nonce_and_expiry_are_checked() { let rng = SystemRandom::new(); let pkcs8 = signature::EcdsaKeyPair::generate_pkcs8( - &signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng).unwrap(); + &signature::ECDSA_P256_SHA256_FIXED_SIGNING, + &rng, + ) + .unwrap(); let keypair = signature::EcdsaKeyPair::from_pkcs8( - &signature::ECDSA_P256_SHA256_FIXED_SIGNING, pkcs8.as_ref(), &rng).unwrap(); + &signature::ECDSA_P256_SHA256_FIXED_SIGNING, + pkcs8.as_ref(), + &rng, + ) + .unwrap(); let public = keypair.public_key().as_ref(); let jwks = serde_json::json!({ "keys": [{ "kid":"key1", "kty":"EC", "use":"sig", "alg":"ES256", "crv":"P-256", @@ -128,32 +168,75 @@ mod tests { }); let header = serde_json::json!({"alg":"ES256","kid":"key1","typ":"JWT"}); let token = |claims: &Value| { - let data = format!("{}.{}", + let data = format!( + "{}.{}", URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()), - URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap())); + URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap()) + ); let sig = keypair.sign(&rng, data.as_bytes()).unwrap(); format!("{}.{}", data, URL_SAFE_NO_PAD.encode(sig.as_ref())) }; let jwt = token(&claims); - let verified = token::verify_id_token(&jwt, &jwks, - "https://auth.example.com", "rustproxy", "session-nonce").unwrap(); + let verified = token::verify_id_token( + &jwt, + &jwks, + "https://auth.example.com", + "rustproxy", + "session-nonce", + ) + .unwrap(); assert_eq!(verified.subject, "id-42"); assert_eq!(verified.username, "alice"); - assert!(token::verify_id_token(&jwt, &jwks, - "https://other.example.com", "rustproxy", "session-nonce").is_err()); - assert!(token::verify_id_token(&jwt, &jwks, - "https://auth.example.com", "other-client", "session-nonce").is_err()); - assert!(token::verify_id_token(&jwt, &jwks, - "https://auth.example.com", "rustproxy", "wrong-nonce").is_err()); + assert!(token::verify_id_token( + &jwt, + &jwks, + "https://other.example.com", + "rustproxy", + "session-nonce" + ) + .is_err()); + assert!(token::verify_id_token( + &jwt, + &jwks, + "https://auth.example.com", + "other-client", + "session-nonce" + ) + .is_err()); + assert!(token::verify_id_token( + &jwt, + &jwks, + "https://auth.example.com", + "rustproxy", + "wrong-nonce" + ) + .is_err()); let mut expired = claims.clone(); expired["exp"] = serde_json::json!(now().unwrap() - 1); - assert!(token::verify_id_token(&token(&expired), &jwks, - "https://auth.example.com", "rustproxy", "session-nonce").is_err()); + assert!(token::verify_id_token( + &token(&expired), + &jwks, + "https://auth.example.com", + "rustproxy", + "session-nonce" + ) + .is_err()); let mut wrong = claims.clone(); wrong["sub"] = serde_json::json!("another-user"); let forged_parts: Vec<_> = token(&wrong).split('.').map(str::to_string).collect(); - let modified = format!("{}.{}.{}", forged_parts[0], forged_parts[1], jwt.split('.').last().unwrap()); - assert!(token::verify_id_token(&modified, &jwks, - "https://auth.example.com", "rustproxy", "session-nonce").is_err()); + let modified = format!( + "{}.{}.{}", + forged_parts[0], + forged_parts[1], + jwt.split('.').next_back().unwrap() + ); + assert!(token::verify_id_token( + &modified, + &jwks, + "https://auth.example.com", + "rustproxy", + "session-nonce" + ) + .is_err()); } } diff --git a/src/oidc/token.rs b/src/oidc/token.rs index 34bdc7c..8749757 100644 --- a/src/oidc/token.rs +++ b/src/oidc/token.rs @@ -14,7 +14,10 @@ pub(super) struct Claims { } fn field<'a>(value: &'a Value, name: &str) -> Result<&'a str> { - value.get(name).and_then(Value::as_str).context("missing or invalid OIDC claim") + value + .get(name) + .and_then(Value::as_str) + .context("missing or invalid OIDC claim") } fn key_bytes(value: &Value, key: &str) -> Result> { @@ -24,22 +27,42 @@ fn key_bytes(value: &Value, key: &str) -> Result> { } fn validate_signature(key: &Value, alg: &str, signed: &[u8], signature_bytes: &[u8]) -> Result<()> { - ensure!(key.get("use").and_then(Value::as_str).is_none_or(|usage| usage == "sig"), "OIDC JWK is not a signing key"); - ensure!(key.get("alg").and_then(Value::as_str).is_none_or(|declared| declared == alg), "OIDC JWK algorithm mismatch"); + ensure!( + key.get("use") + .and_then(Value::as_str) + .is_none_or(|usage| usage == "sig"), + "OIDC JWK is not a signing key" + ); + ensure!( + key.get("alg") + .and_then(Value::as_str) + .is_none_or(|declared| declared == alg), + "OIDC JWK algorithm mismatch" + ); match alg { "RS256" => { ensure!(field(key, "kty")? == "RSA", "OIDC key type mismatch"); let n = key_bytes(key, "n")?; let e = key_bytes(key, "e")?; signature::RsaPublicKeyComponents { n: &n, e: &e } - .verify(&signature::RSA_PKCS1_2048_8192_SHA256, signed, signature_bytes) + .verify( + &signature::RSA_PKCS1_2048_8192_SHA256, + signed, + signature_bytes, + ) .map_err(|_| anyhow::anyhow!("invalid OIDC signature"))?; } "ES256" => { - ensure!(field(key, "kty")? == "EC" && field(key, "crv")? == "P-256", "OIDC key type mismatch"); + ensure!( + field(key, "kty")? == "EC" && field(key, "crv")? == "P-256", + "OIDC key type mismatch" + ); let x = key_bytes(key, "x")?; let y = key_bytes(key, "y")?; - ensure!(x.len() == 32 && y.len() == 32, "invalid OIDC curve coordinates"); + ensure!( + x.len() == 32 && y.len() == 32, + "invalid OIDC curve coordinates" + ); let mut public = Vec::with_capacity(65); public.push(4); public.extend_from_slice(&x); @@ -69,79 +92,171 @@ pub(super) fn verify_id_token( let header: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(head)?)?; let claims: Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload)?)?; let alg = field(&header, "alg")?; - ensure!(matches!(alg, "RS256" | "ES256"), "OIDC JWT algorithm not allowed"); + ensure!( + matches!(alg, "RS256" | "ES256"), + "OIDC JWT algorithm not allowed" + ); let kid = header.get("kid").and_then(Value::as_str); - let keys = jwks.get("keys").and_then(Value::as_array).context("invalid OIDC JWKS")?; + let keys = jwks + .get("keys") + .and_then(Value::as_array) + .context("invalid OIDC JWKS")?; ensure!(keys.len() <= 32, "too many OIDC signing keys"); - let candidates: Vec<_> = keys.iter().filter(|key| { - kid.map_or(true, |id| key.get("kid").and_then(Value::as_str) == Some(id)) - && key.get("alg").and_then(Value::as_str).is_none_or(|configured| configured == alg) - && key.get("use").and_then(Value::as_str).is_none_or(|configured| configured == "sig") - }).collect(); - ensure!(candidates.len() == 1, "OIDC signing key missing or ambiguous"); + let candidates: Vec<_> = keys + .iter() + .filter(|key| { + kid.is_none_or(|id| key.get("kid").and_then(Value::as_str) == Some(id)) + && key + .get("alg") + .and_then(Value::as_str) + .is_none_or(|configured| configured == alg) + && key + .get("use") + .and_then(Value::as_str) + .is_none_or(|configured| configured == "sig") + }) + .collect(); + ensure!( + candidates.len() == 1, + "OIDC signing key missing or ambiguous" + ); let signed = format!("{head}.{payload}"); - validate_signature(candidates[0], alg, signed.as_bytes(), &URL_SAFE_NO_PAD.decode(signature)?)?; - ensure!(field(&claims, "iss")? == expected_issuer, "OIDC issuer mismatch"); + validate_signature( + candidates[0], + alg, + signed.as_bytes(), + &URL_SAFE_NO_PAD.decode(signature)?, + )?; + ensure!( + field(&claims, "iss")? == expected_issuer, + "OIDC issuer mismatch" + ); let audience = claims.get("aud").context("missing OIDC audience")?; let audiences: Vec<&str> = match audience { Value::String(value) => vec![value], - Value::Array(values) => values.iter().map(|value| value.as_str().context("invalid OIDC audience")).collect::>()?, + Value::Array(values) => values + .iter() + .map(|value| value.as_str().context("invalid OIDC audience")) + .collect::>()?, _ => bail!("invalid OIDC audience"), }; ensure!(audiences.contains(&client_id), "OIDC audience mismatch"); if audiences.len() > 1 { - ensure!(field(&claims, "azp")? == client_id, "OIDC authorized party mismatch"); + ensure!( + field(&claims, "azp")? == client_id, + "OIDC authorized party mismatch" + ); } else if let Some(azp) = claims.get("azp").and_then(Value::as_str) { ensure!(azp == client_id, "OIDC authorized party mismatch"); } - ensure!(field(&claims, "nonce")? == expected_nonce, "OIDC nonce mismatch"); + ensure!( + field(&claims, "nonce")? == expected_nonce, + "OIDC nonce mismatch" + ); let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); - let exp = claims.get("exp").and_then(Value::as_u64).context("missing OIDC expiration")?; + let exp = claims + .get("exp") + .and_then(Value::as_u64) + .context("missing OIDC expiration")?; ensure!(exp > now, "OIDC ID token expired"); - let issued = claims.get("iat").and_then(Value::as_u64).context("missing OIDC issuance time")?; - ensure!(issued <= now.saturating_add(60), "OIDC token issued in the future"); + let issued = claims + .get("iat") + .and_then(Value::as_u64) + .context("missing OIDC issuance time")?; + ensure!( + issued <= now.saturating_add(60), + "OIDC token issued in the future" + ); if let Some(nbf) = claims.get("nbf") { - ensure!(nbf.as_u64().is_some_and(|start| start <= now.saturating_add(60)), "OIDC token is not yet valid"); + ensure!( + nbf.as_u64() + .is_some_and(|start| start <= now.saturating_add(60)), + "OIDC token is not yet valid" + ); } let subject = field(&claims, "sub")?.to_string(); - ensure!(!subject.is_empty() && subject.len() <= 256, "invalid OIDC subject"); - let username = claims.get("preferred_username").and_then(Value::as_str) + ensure!( + !subject.is_empty() && subject.len() <= 256, + "invalid OIDC subject" + ); + let username = claims + .get("preferred_username") + .and_then(Value::as_str) .filter(|name| !name.is_empty() && name.len() <= 256) - .unwrap_or(&subject).to_string(); - let optional = |key| claims.get(key).and_then(Value::as_str) - .filter(|value| !value.is_empty() && value.len() <= 256).map(str::to_string); - let groups = claims.get("groups").and_then(Value::as_array) - .map(|items| items.iter().filter_map(Value::as_str).filter(|s| !s.is_empty() && s.len() <= 128).take(32).map(str::to_string).collect()) + .unwrap_or(&subject) + .to_string(); + let optional = |key| { + claims + .get(key) + .and_then(Value::as_str) + .filter(|value| !value.is_empty() && value.len() <= 256) + .map(str::to_string) + }; + let groups = claims + .get("groups") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .filter(|s| !s.is_empty() && s.len() <= 128) + .take(32) + .map(str::to_string) + .collect() + }) .unwrap_or_default(); let email = if claims.get("email_verified").and_then(Value::as_bool) == Some(false) { None } else { optional("email") }; - Ok(Claims { subject, username, email, name: optional("name"), groups }) + Ok(Claims { + subject, + username, + email, + name: optional("name"), + groups, + }) } - pub(super) fn merge_userinfo(claims: &mut Claims, value: &Value) -> Result<()> { - ensure!(field(value, "sub")? == claims.subject, "OIDC UserInfo subject mismatch"); + ensure!( + field(value, "sub")? == claims.subject, + "OIDC UserInfo subject mismatch" + ); if claims.username == claims.subject { - if let Some(username) = value.get("preferred_username").and_then(Value::as_str) - .filter(|name| !name.is_empty() && name.len() <= 256) { + if let Some(username) = value + .get("preferred_username") + .and_then(Value::as_str) + .filter(|name| !name.is_empty() && name.len() <= 256) + { claims.username = username.to_string(); } } if claims.name.is_none() { - claims.name = value.get("name").and_then(Value::as_str) - .filter(|name| !name.is_empty() && name.len() <= 256).map(str::to_string); + claims.name = value + .get("name") + .and_then(Value::as_str) + .filter(|name| !name.is_empty() && name.len() <= 256) + .map(str::to_string); } - if claims.email.is_none() && value.get("email_verified").and_then(Value::as_bool) != Some(false) { - claims.email = value.get("email").and_then(Value::as_str) - .filter(|name| !name.is_empty() && name.len() <= 256).map(str::to_string); + if claims.email.is_none() && value.get("email_verified").and_then(Value::as_bool) != Some(false) + { + claims.email = value + .get("email") + .and_then(Value::as_str) + .filter(|name| !name.is_empty() && name.len() <= 256) + .map(str::to_string); } if claims.groups.is_empty() { if let Some(groups) = value.get("groups").and_then(Value::as_array) { - claims.groups = groups.iter().filter_map(Value::as_str) - .filter(|name| !name.is_empty() && name.len() <= 128).take(32).map(str::to_string).collect(); + claims.groups = groups + .iter() + .filter_map(Value::as_str) + .filter(|name| !name.is_empty() && name.len() <= 128) + .take(32) + .map(str::to_string) + .collect(); } } Ok(()) diff --git a/src/prometheus/tests.rs b/src/prometheus/tests.rs index 197ab12..21f42b8 100644 --- a/src/prometheus/tests.rs +++ b/src/prometheus/tests.rs @@ -100,7 +100,7 @@ mod tests { state .prometheus_token_hash .store(Some(Arc::new(auth::secret_hash(token)))); - let router = router(state); + let router = router(state, None); let make = |path: &str, peer: Option<&str>, bearer: Option<&str>| { let mut builder = Request::builder().uri(path); if let Some(peer) = peer { diff --git a/src/proxy/body/mod.rs b/src/proxy/body/mod.rs index 838cc9c..298b53c 100644 --- a/src/proxy/body/mod.rs +++ b/src/proxy/body/mod.rs @@ -251,7 +251,7 @@ impl Drop for Exchange { sni: None, authority: None, matched_by: None, - cache_reason: *self + cache_reason: self .cache_reason .get_mut() .unwrap_or_else(|e| e.into_inner()), diff --git a/src/proxy/error_fallback.rs b/src/proxy/error_fallback.rs index f5e22e0..4a223bf 100644 --- a/src/proxy/error_fallback.rs +++ b/src/proxy/error_fallback.rs @@ -86,6 +86,7 @@ enum ErrorFallbackBody { const MAX_ERROR_FALLBACK_DEPTH: u8 = 8; const MAX_ERROR_FALLBACK_REPLAY_BODY_BYTES: u64 = 8 * 1024 * 1024; +#[allow(clippy::result_large_err)] async fn prepare_error_fallback_body( request: &mut Request, enabled: bool, diff --git a/src/proxy/forward/cache_fast_path_tests.rs b/src/proxy/forward/cache_fast_path_tests.rs index aaec1d1..4486e52 100644 --- a/src/proxy/forward/cache_fast_path_tests.rs +++ b/src/proxy/forward/cache_fast_path_tests.rs @@ -912,7 +912,7 @@ mod cache_fast_path_tests { assert_eq!(origin.requests.load(Ordering::Relaxed), 2); fixture.stop().await; - let origin = origin().await; + let origin = self::origin().await; let mut config = config_for(origin.addr, true); config.cache.default_ttl_seconds = 45; let fixture = Fixture::new(config).await; diff --git a/src/proxy/forward/upstream.rs b/src/proxy/forward/upstream.rs index 009019d..f2f569d 100644 --- a/src/proxy/forward/upstream.rs +++ b/src/proxy/forward/upstream.rs @@ -208,7 +208,7 @@ let upstream_result = loop { let selection = match pool .select_retry_wait( context.client_ip, - &retry_headers, + retry_headers, tls, &attempted_backends, ) @@ -232,7 +232,7 @@ let upstream_result = loop { let Some(next_request) = retry_request( &retry_method, upstream_request_version(&next_backend, false), - &retry_headers, + retry_headers, &next_backend, &original_host, &origin_suffix, diff --git a/src/proxy/headers/authority_tests.rs b/src/proxy/headers/authority_tests.rs index 1e2c8a2..c76a823 100644 --- a/src/proxy/headers/authority_tests.rs +++ b/src/proxy/headers/authority_tests.rs @@ -45,7 +45,12 @@ mod authority_tests { .extensions_mut() .insert(LocalAddr("127.0.0.1:8080".parse().unwrap())); assert!(response_context(&snapshot, &invalid).validated.is_none()); - assert!(crate::acl::routing_path_view("/encoded%2fslash").is_err()); + assert_eq!( + crate::acl::routing_path_view("/encoded%2fslash") + .unwrap() + .as_ref(), + "/encoded/slash" + ); } #[test] @@ -477,6 +482,39 @@ mod authority_tests { ); } + #[test] + fn encoded_slash_routes_on_normalized_path_but_preserves_upstream_target() { + let config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap()); + let snapshot = crate::config::compile(config, 1, &validation_policy()).unwrap(); + for suffix in ["/a%2Fb?x=1", "/a%2fb?x=1"] { + let mut request = Request::builder() + .method(Method::GET) + .uri(suffix) + .header(HOST, "localhost") + .body(Body::empty()) + .unwrap(); + request + .extensions_mut() + .insert(LocalAddr("127.0.0.1:8080".parse().unwrap())); + + let context = response_context(&snapshot, &request); + let route = context.route.as_ref().expect("route selected"); + let routing_path = context + .validated + .as_ref() + .and_then(|validated| validated.decoded_path.as_deref()) + .expect("encoded slash creates a normalized routing path"); + assert_eq!(routing_path, "/a/b"); + assert!(route.rewritten_path(routing_path).unwrap().is_none()); + + let public_suffix = request.uri().path_and_query().unwrap().as_str(); + assert_eq!(public_suffix, suffix); + let backend = route.backend.as_ref().expect("reverse-proxy backend"); + let upstream = upstream_uri(backend, public_suffix).unwrap(); + assert_eq!(upstream.path_and_query().unwrap().as_str(), suffix); + } + } + #[test] fn upstream_uri_preserves_nested_url_query_without_rejecting_valid_separators() { let config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap()); @@ -670,6 +708,16 @@ mod graceful_h2_tests { use super::*; use std::fmt; + fn validation_policy() -> crate::config::ValidationPolicy { + crate::config::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(), + } + } + #[derive(Debug)] struct H2DebugError(&'static str); impl fmt::Display for H2DebugError { @@ -697,7 +745,7 @@ mod graceful_h2_tests { let backend = &snapshot.pools[0].members[0].backend; assert_eq!( backend.request_origin_uri.to_string(), - backend.request_origin + format!("{}/", backend.request_origin) ); for suffix in [ "/", diff --git a/src/proxy/identity.rs b/src/proxy/identity.rs index 941cb82..47e45f4 100644 --- a/src/proxy/identity.rs +++ b/src/proxy/identity.rs @@ -10,19 +10,16 @@ struct SelectedRouteContext { route: Option>, validated: Option, } -fn take_routing_identity( - request: &mut Request, - tls: bool, -) -> Result< - ( - Option, - HeaderValue, - String, - Option, - bool, - ), - Response, -> { +type RoutingIdentity = ( + Option, + HeaderValue, + String, + Option, + bool, +); + +#[allow(clippy::result_large_err)] +fn take_routing_identity(request: &mut Request, tls: bool) -> Result { let mut selected_context = request.extensions_mut().remove::(); let validated = selected_context .as_mut() diff --git a/src/proxy/upstream_helpers.rs b/src/proxy/upstream_helpers.rs index 0d7d0e8..44246a3 100644 --- a/src/proxy/upstream_helpers.rs +++ b/src/proxy/upstream_helpers.rs @@ -228,7 +228,7 @@ async fn observe(State(state): State>, mut request: Request, next: Next .map(|context| context.0.clone()); let effective_route_context = final_route_context .as_deref() - .or_else(|| route_context.as_deref()); + .or(route_context.as_deref()); let minimal_welcome = response .extensions() .get::() diff --git a/src/secret_store/tests.rs b/src/secret_store/tests.rs index c5737cf..93687bb 100644 --- a/src/secret_store/tests.rs +++ b/src/secret_store/tests.rs @@ -1,9 +1,23 @@ mod tests { use super::*; + fn private_tempdir() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + dir.path(), + std::fs::Permissions::from_mode(0o700), + ) + .unwrap(); + } + dir + } + #[tokio::test] async fn secret_round_trip_is_encrypted_in_sqlite_and_survives_key_reload() { - let dir = tempfile::tempdir().unwrap(); + let dir = private_tempdir(); crate::auth::prepare_directory(dir.path()).unwrap(); let database = dir.path().join("config.sqlite3"); crate::auth::prepare_database(&database).unwrap(); @@ -47,7 +61,7 @@ mod tests { #[tokio::test] async fn tampered_ciphertext_is_rejected() { - let dir = tempfile::tempdir().unwrap(); + let dir = private_tempdir(); crate::auth::prepare_directory(dir.path()).unwrap(); let database = dir.path().join("config.sqlite3"); crate::auth::prepare_database(&database).unwrap(); diff --git a/src/server/sendfile.rs b/src/server/sendfile.rs index 8dac1f0..123833d 100644 --- a/src/server/sendfile.rs +++ b/src/server/sendfile.rs @@ -67,7 +67,7 @@ impl SendfileSlots { return None; } let mut data = vec![0u8; FRAME_LEN]; - for chunk in data.chunks_exact_mut(TAG_LEN) { + for chunk in data.as_chunks_mut::().0 { chunk.copy_from_slice(&tag); } registry.slots.push(Slot { @@ -357,7 +357,7 @@ mod tests { let (mut socket, slots) = SendfileIo::new(tcp); for round in 0..2 { let expected = if round == 0 { - vec![0, 1, 2, 3].repeat(25_000) + [0, 1, 2, 3].repeat(25_000) } else { vec![9u8; 100_003] }; @@ -411,7 +411,7 @@ mod tests { .unwrap(); server.await.unwrap(); let mut wanted = Vec::new(); - for expected in [vec![0, 1, 2, 3].repeat(25_000), vec![9u8; 100_003]] { + for expected in [[0, 1, 2, 3].repeat(25_000), vec![9u8; 100_003]] { wanted.extend_from_slice( format!( "HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n", diff --git a/src/settings/tests.rs b/src/settings/tests.rs index d52dcc5..125ae79 100644 --- a/src/settings/tests.rs +++ b/src/settings/tests.rs @@ -124,11 +124,13 @@ mod tests { https: Some("0.0.0.0:8443".parse().unwrap()), administration: None, }; - let mut config = crate::config::Config::default(); - config.listeners = Listeners::from_startup( - "0.0.0.0:80".parse().unwrap(), - Some("0.0.0.0:443".parse().unwrap()), - ); + let mut config = crate::config::Config { + listeners: Listeners::from_startup( + "0.0.0.0:80".parse().unwrap(), + Some("0.0.0.0:443".parse().unwrap()), + ), + ..Default::default() + }; config.listeners.endpoints[0] .additional_addresses .push("127.0.0.1:80".parse().unwrap()); diff --git a/src/stream/model.rs b/src/stream/model.rs index 2c22538..2c07a02 100644 --- a/src/stream/model.rs +++ b/src/stream/model.rs @@ -155,7 +155,7 @@ pub fn validate( ); ensure!( !host.listen.ip().is_multicast() - && (host.backends.len() > 0 || !host.upstream.ip().is_multicast()), + && (!host.backends.is_empty() || !host.upstream.ip().is_multicast()), "stream listener/upstream must not use multicast addresses" ); ensure!( diff --git a/src/stream/pool.rs b/src/stream/pool.rs index 3d9ccd6..4ba3bdc 100644 --- a/src/stream/pool.rs +++ b/src/stream/pool.rs @@ -81,12 +81,14 @@ impl HealthCheck { fn decode_probe_hex(value: &str) -> Result> { ensure!( - value.len() <= 256 && value.len() % 2 == 0 && value.is_ascii(), + value.len() <= 256 && value.len().is_multiple_of(2) && value.is_ascii(), "invalid probe hex length" ); value .as_bytes() - .chunks_exact(2) + .as_chunks::<2>() + .0 + .iter() .map(|chunk| { let hex = |byte: u8| -> Option { match byte { diff --git a/src/system_stats/mod.rs b/src/system_stats/mod.rs index b652585..7375ae3 100644 --- a/src/system_stats/mod.rs +++ b/src/system_stats/mod.rs @@ -55,31 +55,31 @@ pub(crate) struct Counters { pub(crate) fn platform_name() -> &'static str { #[cfg(target_os = "linux")] { - return "Linux"; + "Linux" } #[cfg(target_os = "freebsd")] { - return "FreeBSD"; + "FreeBSD" } #[cfg(target_os = "openbsd")] { - return "OpenBSD"; + "OpenBSD" } #[cfg(target_os = "netbsd")] { - return "NetBSD"; + "NetBSD" } #[cfg(target_os = "dragonfly")] { - return "DragonFly BSD"; + "DragonFly BSD" } #[cfg(target_os = "macos")] { - return "macOS"; + "macOS" } #[cfg(target_os = "windows")] { - return "Windows"; + "Windows" } #[cfg(all( target_family = "unix", @@ -93,7 +93,7 @@ pub(crate) fn platform_name() -> &'static str { )) ))] { - return "Unix"; + "Unix" } #[cfg(all(not(target_family = "unix"), not(target_os = "windows")))] { @@ -104,7 +104,7 @@ pub(crate) fn platform_name() -> &'static str { fn source_name() -> &'static str { #[cfg(target_os = "linux")] { - return "systemstat+sysinfo+metrics_process+linux_procfs"; + "systemstat+sysinfo+metrics_process+linux_procfs" } #[cfg(any( target_os = "freebsd", @@ -113,11 +113,11 @@ fn source_name() -> &'static str { target_os = "windows" ))] { - return "systemstat+sysinfo+metrics_process"; + "systemstat+sysinfo+metrics_process" } #[cfg(any(target_os = "netbsd", target_os = "dragonfly"))] { - return "systemstat+sysinfo+metrics_process(dummy)"; + "systemstat+sysinfo+metrics_process(dummy)" } #[cfg(not(any( target_os = "linux", diff --git a/src/tinyauth/mod.rs b/src/tinyauth/mod.rs index c56a628..38d269b 100644 --- a/src/tinyauth/mod.rs +++ b/src/tinyauth/mod.rs @@ -289,6 +289,7 @@ impl Compiled { })?; target.as_str().parse().ok() } + #[allow(clippy::result_large_err)] pub async fn authorize( &self, incoming: &HeaderMap, diff --git a/src/tls/certificates.rs b/src/tls/certificates.rs index e886449..c3612ee 100644 --- a/src/tls/certificates.rs +++ b/src/tls/certificates.rs @@ -286,7 +286,7 @@ pub fn for_all_frontends( } for cert in &mut result.certificates { cert.hosts.retain(|host| { - !assignments.contains_key(&host.to_ascii_lowercase().trim_end_matches('.').to_string()) + !assignments.contains_key(host.to_ascii_lowercase().trim_end_matches('.')) }); for (host, id) in &assignments { if id == &cert.id { diff --git a/src/tls/external_paths_tests.rs b/src/tls/external_paths_tests.rs index a3c7804..2449f61 100644 --- a/src/tls/external_paths_tests.rs +++ b/src/tls/external_paths_tests.rs @@ -3,9 +3,9 @@ fn external_absolute_paths_follow_certbot_style_symlinks_on_each_reload() { use std::os::unix::fs::{symlink, PermissionsExt}; - let root = tempfile::tempdir().unwrap(); + let root = private_tempdir(); let cert_dir = root.path().join("rustproxy-certs"); - fs::create_dir(&cert_dir).unwrap(); + crate::auth::prepare_directory(&cert_dir).unwrap(); initialize_default(&cert_dir).unwrap(); let archive = root.path().join("archive/example.test"); @@ -100,7 +100,7 @@ fn external_absolute_paths_follow_certbot_style_symlinks_on_each_reload() { fn external_key_permissions_accept_acl_style_group_mask_but_reject_unsafe_write_or_world_read() { use std::os::unix::fs::PermissionsExt; - let root = tempfile::tempdir().unwrap(); + let root = private_tempdir(); let key = root.path().join("external.key"); fs::write(&key, b"not-a-real-key").unwrap(); @@ -119,7 +119,7 @@ fn external_key_permissions_accept_acl_style_group_mask_but_reject_unsafe_write_ fn external_paths_reject_writable_non_sticky_parent_directories() { use std::os::unix::fs::PermissionsExt; - let root = tempfile::tempdir().unwrap(); + let root = private_tempdir(); let unsafe_parent = root.path().join("unsafe"); fs::create_dir(&unsafe_parent).unwrap(); fs::set_permissions(&unsafe_parent, fs::Permissions::from_mode(0o770)).unwrap(); @@ -133,7 +133,7 @@ fn external_paths_reject_writable_non_sticky_parent_directories() { fn certificate_input_rejects_fifo_without_waiting_for_a_writer() { use std::os::unix::ffi::OsStrExt; - let root = tempfile::tempdir().unwrap(); + let root = private_tempdir(); let fifo = root.path().join("certificate.pipe"); let path = std::ffi::CString::new(fifo.as_os_str().as_bytes()).unwrap(); assert_eq!(unsafe { libc::mkfifo(path.as_ptr(), 0o600) }, 0); diff --git a/src/tls/server_config.rs b/src/tls/server_config.rs index adcac2a..c9faf15 100644 --- a/src/tls/server_config.rs +++ b/src/tls/server_config.rs @@ -85,10 +85,12 @@ pub struct CertificateMetadata { fn der_text(tag: u8, raw: &[u8]) -> Option { let value = match tag { 0x0c | 0x13 | 0x14 | 0x16 => std::str::from_utf8(raw).ok()?.to_owned(), - 0x1e if raw.len() % 2 == 0 => { + 0x1e if raw.len().is_multiple_of(2) => { let units: Vec = raw - .chunks_exact(2) - .map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]])) + .as_chunks::<2>() + .0 + .iter() + .map(|chunk| u16::from_be_bytes(*chunk)) .collect(); String::from_utf16(&units).ok()? } diff --git a/src/tls/tests.rs b/src/tls/tests.rs index 3f1fca6..671c21c 100644 --- a/src/tls/tests.rs +++ b/src/tls/tests.rs @@ -1,5 +1,20 @@ mod tests { use super::*; + + fn private_tempdir() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + dir.path(), + std::fs::Permissions::from_mode(0o700), + ) + .unwrap(); + } + dir + } + #[test] fn certificate_removal_requires_prior_unassignment_including_administration() { let certificate = Certificate { @@ -67,7 +82,7 @@ mod tests { #[test] fn external_pair_stages_generation_and_rolls_back_cleanly() { - let dir = tempfile::tempdir().unwrap(); + let dir = private_tempdir(); let generated = rcgen::generate_simple_self_signed(vec![ "shared.example.test".into(), "www.shared.example.test".into(), @@ -96,7 +111,7 @@ mod tests { #[test] fn external_pair_reconcile_is_idempotent_and_repairs_missing_generation() { - let dir = tempfile::tempdir().unwrap(); + let dir = private_tempdir(); let generated = rcgen::generate_simple_self_signed(vec!["reconcile.example.test".into()]).unwrap(); let certificate = generated.cert.pem(); @@ -166,7 +181,7 @@ mod tests { #[test] fn default_is_persistent_and_tls_advertises_both_protocols() { - let dir = tempfile::tempdir().unwrap(); + let dir = private_tempdir(); initialize_default(dir.path()).unwrap(); let before = fs::read(dir.path().join("default.pem")).unwrap(); initialize_default(dir.path()).unwrap(); @@ -293,7 +308,7 @@ mod tests { #[test] fn pqc_builds_both_tcp_tls_and_quic_configs() { - let dir = tempfile::tempdir().unwrap(); + let dir = private_tempdir(); initialize_default(dir.path()).unwrap(); let mut settings = Settings::default(); settings.policy.post_quantum = true; @@ -308,7 +323,7 @@ mod tests { #[test] fn administration_tls_supports_default_and_selected_certificate() { - let dir = tempfile::tempdir().unwrap(); + let dir = private_tempdir(); initialize_default(dir.path()).unwrap(); generate(dir.path(), "panel", vec!["panel.localhost".into()]).unwrap(); let settings = Settings { @@ -347,7 +362,7 @@ mod tests { b.signing_key.serialize_pem().as_bytes() ) .is_err()); - let dir = tempfile::tempdir().unwrap(); + let dir = private_tempdir(); initialize_default(dir.path()).unwrap(); generate(dir.path(), "a", vec!["a.localhost".into()]).unwrap(); let mut settings = Settings { @@ -370,7 +385,7 @@ mod tests { } #[test] fn named_generation_never_overwrites_keys() { - let dir = tempfile::tempdir().unwrap(); + let dir = private_tempdir(); generate(dir.path(), "app", vec!["app.localhost".into()]).unwrap(); let before = fs::read(dir.path().join("app.key")).unwrap(); assert!(generate(dir.path(), "app", vec!["other.localhost".into()]).is_err()); @@ -378,7 +393,7 @@ mod tests { } #[test] fn certificate_hostname_bindings_are_not_artificially_capped() { - let dir = tempfile::tempdir().unwrap(); + let dir = private_tempdir(); let hosts = (0..40) .map(|index| format!("host-{index}.example.test")) .collect::>(); @@ -408,7 +423,7 @@ mod tests { } #[test] fn uploads_validate_key_and_names_and_store_only_private_immutable_files() { - let dir = tempfile::tempdir().unwrap(); + let dir = private_tempdir(); initialize_default(dir.path()).unwrap(); let pair = rcgen::generate_simple_self_signed(vec![ "app.localhost".into(), @@ -481,7 +496,7 @@ mod tests { #[test] fn legacy_certificate_coverage_can_be_read_from_existing_pem() { - let dir = tempfile::tempdir().unwrap(); + let dir = private_tempdir(); let pair = rcgen::generate_simple_self_signed(vec![ "app.example.org".into(), "*.example.org".into(), @@ -496,7 +511,7 @@ mod tests { } #[test] fn mismatched_named_certificate_requires_explicit_per_vhost_consent() { - let dir = tempfile::tempdir().unwrap(); + let dir = private_tempdir(); initialize_default(dir.path()).unwrap(); generate(dir.path(), "named", vec!["right.example.test".into()]).unwrap(); let settings = Settings { @@ -511,13 +526,12 @@ mod tests { ..Default::default() }; let mut config = crate::config::demo_config("127.0.0.1:9000".parse().unwrap()); - let vhost = &mut config.vhosts[0]; - vhost.certificate_id = Some("named".into()); - vhost.frontend.hosts = vec!["other.example.test".into()]; + config.vhosts[0].certificate_id = Some("named".into()); + config.vhosts[0].frontend.hosts = vec!["other.example.test".into()]; let blocked = for_vhosts(&settings, &config.vhosts).unwrap(); assert!(compile(&blocked, Some(dir.path())).is_err()); assert!(blocked.forced_mismatches.is_empty()); - vhost.allow_certificate_mismatch = true; + config.vhosts[0].allow_certificate_mismatch = true; let forced = for_vhosts(&settings, &config.vhosts).unwrap(); assert!(forced .forced_mismatches diff --git a/src/tls/types.rs b/src/tls/types.rs index ddc137d..19e4155 100644 --- a/src/tls/types.rs +++ b/src/tls/types.rs @@ -17,7 +17,7 @@ use zeroize::Zeroizing; const MAX_PEM: u64 = 131_072; const MAX_CERTIFICATE_PATH: usize = 4096; -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Default, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Settings { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -28,16 +28,6 @@ pub struct Settings { #[serde(skip)] pub forced_mismatches: HashSet<(String, String)>, } -impl Default for Settings { - fn default() -> Self { - Self { - default_certificate: None, - certificates: Vec::new(), - policy: ProtocolPolicy::default(), - forced_mismatches: HashSet::new(), - } - } -} impl Settings { pub fn is_empty(&self) -> bool { self.default_certificate.is_none() diff --git a/src/transform/css.rs b/src/transform/css.rs index 1e2ee0c..646d92e 100644 --- a/src/transform/css.rs +++ b/src/transform/css.rs @@ -34,7 +34,7 @@ pub fn minify(body: &[u8]) -> Result> { if ch == '/' && chars.peek() == Some(&'*') { chars.next(); let mut previous = '\0'; - while let Some(next) = chars.next() { + for next in chars.by_ref() { if previous == '*' && next == '/' { break; } diff --git a/src/transform/model.rs b/src/transform/model.rs index 8593a66..9a9c77e 100644 --- a/src/transform/model.rs +++ b/src/transform/model.rs @@ -140,7 +140,7 @@ impl Settings { } } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct Overrides { pub enabled: bool, @@ -151,19 +151,6 @@ pub struct Overrides { pub json: Option, pub extensions: Option>, } -impl Default for Overrides { - fn default() -> Self { - Self { - enabled: false, - javascript: None, - css: None, - html: None, - svg: None, - json: None, - extensions: None, - } - } -} impl Overrides { pub fn resolve(&self, global: &Settings) -> Result { let mut resolved = global.clone(); diff --git a/src/transform/tests.rs b/src/transform/tests.rs index c597b08..a421f11 100644 --- a/src/transform/tests.rs +++ b/src/transform/tests.rs @@ -61,11 +61,13 @@ mod tests { #[test] fn resource_preset_metadata_round_trips() { - let mut settings = Settings::default(); - settings.preset = Some(Preset::Uber); - settings.memory_budget_bytes = 1024 * 1024 * 1024; - settings.max_body_bytes = 32 * 1024 * 1024; - settings.max_inflight = 128; + let settings = Settings { + preset: Some(Preset::Uber), + memory_budget_bytes: 1024 * 1024 * 1024, + max_body_bytes: 32 * 1024 * 1024, + max_inflight: 128, + ..Default::default() + }; let encoded = serde_json::to_value(&settings).unwrap(); assert_eq!(encoded["preset"], "uber"); let decoded: Settings = serde_json::from_value(encoded).unwrap(); diff --git a/src/upstream.rs b/src/upstream.rs index c57b1d8..6bf962b 100644 --- a/src/upstream.rs +++ b/src/upstream.rs @@ -221,7 +221,7 @@ impl Service for TimedConnector { let timeout = self.timeout; Box::pin(async move { match tokio::time::timeout(timeout, future).await { - Ok(result) => result.map_err(Into::into), + Ok(result) => result, Err(error) => Err(Box::new(error) as BoxError), } }) diff --git a/src/upstream/client_tests.rs b/src/upstream/client_tests.rs index d922779..da277f8 100644 --- a/src/upstream/client_tests.rs +++ b/src/upstream/client_tests.rs @@ -29,9 +29,11 @@ mod tests { #[test] fn generation_recycles_after_request_budget() { let backend = backend("rotation-test", 18081); - let mut keepalive = crate::balance::Keepalive::default(); - keepalive.pool_recycle_requests = 2; - keepalive.pool_recycle_seconds = 0; + let keepalive = crate::balance::Keepalive { + pool_recycle_requests: 2, + pool_recycle_seconds: 0, + ..Default::default() + }; let client = client_with_keepalive( &backend, AddressPolicy::new(&backend).unwrap(), @@ -52,9 +54,11 @@ mod tests { #[test] fn generation_recycles_after_age_without_request() { let backend = backend("rotation-age-test", 18083); - let mut keepalive = crate::balance::Keepalive::default(); - keepalive.pool_recycle_requests = 0; - keepalive.pool_recycle_seconds = 1; + let keepalive = crate::balance::Keepalive { + pool_recycle_requests: 0, + pool_recycle_seconds: 1, + ..Default::default() + }; let client = client_with_keepalive( &backend, AddressPolicy::new(&backend).unwrap(), @@ -78,9 +82,11 @@ mod tests { #[test] fn generation_recycling_can_be_disabled() { let backend = backend("rotation-off", 18082); - let mut keepalive = crate::balance::Keepalive::default(); - keepalive.pool_recycle_requests = 0; - keepalive.pool_recycle_seconds = 0; + let keepalive = crate::balance::Keepalive { + pool_recycle_requests: 0, + pool_recycle_seconds: 0, + ..Default::default() + }; let client = client_with_keepalive( &backend, AddressPolicy::new(&backend).unwrap(), @@ -96,9 +102,11 @@ mod tests { #[test] fn request_recycling_still_has_exact_threshold_after_clock_sampling() { let backend = backend("requests-boundary", 18085); - let mut keepalive = crate::balance::Keepalive::default(); - keepalive.pool_recycle_requests = 1_000; - keepalive.pool_recycle_seconds = 0; + let keepalive = crate::balance::Keepalive { + pool_recycle_requests: 1_000, + pool_recycle_seconds: 0, + ..Default::default() + }; let client = client_with_keepalive( &backend, AddressPolicy::new(&backend).unwrap(), diff --git a/src/vhost/model.rs b/src/vhost/model.rs index 186d70a..6f57c07 100644 --- a/src/vhost/model.rs +++ b/src/vhost/model.rs @@ -273,17 +273,13 @@ impl Default for LocationResponse { } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Source { + #[default] ReverseProxy, WebServer { web_service_id: String }, } -impl Default for Source { - fn default() -> Self { - Self::ReverseProxy - } -} impl Source { pub fn is_reverse_proxy(&self) -> bool { matches!(self, Self::ReverseProxy) diff --git a/src/vhost/tests.rs b/src/vhost/tests.rs index f90d76c..eaf4bbc 100644 --- a/src/vhost/tests.rs +++ b/src/vhost/tests.rs @@ -21,11 +21,13 @@ mod default_listener_tests { } #[test] fn listener_plan_inherits_legacy_h2_limits_and_prefers_listener_override() { - let mut config = Config::default(); - config.http2 = crate::settings::Http2Connections { - idle_timeout_seconds: 111, - max_connection_lifetime_seconds: 222, - drain_timeout_seconds: 33, + let mut config = Config { + http2: crate::settings::Http2Connections { + idle_timeout_seconds: 111, + max_connection_lifetime_seconds: 222, + drain_timeout_seconds: 33, + }, + ..Default::default() }; config.listeners.endpoints = serde_json::from_value(json!([ { diff --git a/src/vhost/validation.rs b/src/vhost/validation.rs index 224f5bc..92f3c50 100644 --- a/src/vhost/validation.rs +++ b/src/vhost/validation.rs @@ -455,7 +455,7 @@ pub fn listener_plan(config: &Config, policy: &ValidationPolicy) -> Result Result<(StatusCode, HeaderMap)> .context("PHP-FPM returned a malformed CGI header")?; if name.eq_ignore_ascii_case("status") { let code = value - .trim() .split_whitespace() .next() .context("PHP-FPM returned an empty Status header")? diff --git a/src/web_server/htaccess/parse.rs b/src/web_server/htaccess/parse.rs index 135d0c9..35ab1de 100644 --- a/src/web_server/htaccess/parse.rs +++ b/src/web_server/htaccess/parse.rs @@ -217,24 +217,24 @@ fn matching_scopes<'a>(compiled: &'a Compiled, path: &str) -> Vec<&'a Scope> { pub fn denied(compiled: Option<&Compiled>, path: &str) -> bool { let Some(compiled) = compiled else { return false; }; - matching_scopes(compiled, path).into_iter().filter_map(|scope| scope.deny_all).last().unwrap_or(false) + matching_scopes(compiled, path).into_iter().filter_map(|scope| scope.deny_all).next_back().unwrap_or(false) } pub fn index_files<'a>(compiled: Option<&'a Compiled>, directory_components: &[String], fallback: &'a [String]) -> &'a [String] { let Some(compiled) = compiled else { return fallback; }; - compiled.scopes.iter().filter(|scope| scope_matches(scope, directory_components)).filter_map(|scope| scope.directory_index.as_deref()).last().unwrap_or(fallback) + compiled.scopes.iter().filter(|scope| scope_matches(scope, directory_components)).filter_map(|scope| scope.directory_index.as_deref()).next_back().unwrap_or(fallback) } pub fn directory_listing(compiled: Option<&Compiled>, directory_components: &[String], fallback: bool) -> bool { let Some(compiled) = compiled else { return fallback; }; - compiled.scopes.iter().filter(|scope| scope_matches(scope, directory_components)).filter_map(|scope| scope.directory_listing).last().unwrap_or(fallback) + compiled.scopes.iter().filter(|scope| scope_matches(scope, directory_components)).filter_map(|scope| scope.directory_listing).next_back().unwrap_or(fallback) } pub fn mime_type(compiled: Option<&Compiled>, path: &Path) -> Option { let compiled = compiled?; let extension = path.extension()?.to_str()?.to_ascii_lowercase(); let path_text = path.to_string_lossy(); - matching_scopes(compiled, &path_text).into_iter().filter_map(|scope| scope.mime_types.get(&extension).cloned()).last() + matching_scopes(compiled, &path_text).into_iter().filter_map(|scope| scope.mime_types.get(&extension).cloned()).next_back() } pub fn php_extension(compiled: Option<&Compiled>, path: &Path) -> bool { @@ -345,8 +345,8 @@ async fn condition_matches(condition: &RewriteCondition, service: &CompiledServi ConditionMatcher::Regex { regex, negate } => { if let Some(captures) = regex.captures(&value) { let values = captures.iter().map(|value| value.map(|v| v.as_str().to_owned()).unwrap_or_default()).collect::>(); - (if *negate { false } else { true }, if *negate { Vec::new() } else { values }) - } else { (if *negate { true } else { false }, Vec::new()) } + (!*negate, if *negate { Vec::new() } else { values }) + } else { (*negate, Vec::new()) } } } } diff --git a/src/web_server/mod.rs b/src/web_server/mod.rs index 0b9b13f..da70aed 100644 --- a/src/web_server/mod.rs +++ b/src/web_server/mod.rs @@ -10,8 +10,8 @@ mod security; mod serve; pub use config::{ - CacheRule, CompiledService, Hardening, Php, PhpFpmProfile, Precompressed, RewriteRule, Service, - SymlinkPolicy, SendfileMode, + CacheRule, CompiledService, Hardening, Php, PhpFpmProfile, Precompressed, RewriteRule, + SendfileMode, Service, SymlinkPolicy, }; pub use htaccess::Config as HtaccessConfig; pub use request::RequestContext; diff --git a/src/web_server/response/file.rs b/src/web_server/response/file.rs index 3b0aa37..3ce6237 100644 --- a/src/web_server/response/file.rs +++ b/src/web_server/response/file.rs @@ -217,13 +217,11 @@ async fn file_response_inner( )); return response; } - if start != 0 { - if file.seek(std::io::SeekFrom::Start(start)).await.is_err() { - return status_response( - StatusCode::INTERNAL_SERVER_ERROR, - "RustProxy: Web Server seek failed\n", - ); - } + if start != 0 && file.seek(std::io::SeekFrom::Start(start)).await.is_err() { + return status_response( + StatusCode::INTERNAL_SERVER_ERROR, + "RustProxy: Web Server seek failed\n", + ); } let stream = ReaderStream::with_capacity(file.take(body_length), stream_capacity); *response.body_mut() = Body::new(file_body::FileBody::new(stream, body_length)); diff --git a/src/web_server/tests.rs b/src/web_server/tests.rs index d018f65..b6b1251 100644 --- a/src/web_server/tests.rs +++ b/src/web_server/tests.rs @@ -412,10 +412,7 @@ async fn too_many_byte_ranges_are_rejected() { let tmp = TempDir::new().unwrap(); std::fs::write(tmp.path().join("file.txt"), b"0123456789").unwrap(); let service = service(tmp.path(), false); - let ranges = std::iter::repeat("0-0") - .take(17) - .collect::>() - .join(","); + let ranges = std::iter::repeat_n("0-0", 17).collect::>().join(","); let mut ranged = request(Method::GET, "/file.txt"); ranged .headers_mut()